SDK reference
@homerunner-next/widget-core is the only package you import from. This page is the
inventory of its public surface: 14 subpath exports plus one bin, regenerated from
packages/homerunner-widget-core/src at version 0.12.2.
Signatures and export lists are normative here. The behaviour behind most of them is normative elsewhere — each section links to the page that owns it. Hard numbers and error strings are indexed in limits-and-errors.md.
What npm serves today is older than this page describes. That gap, and how to get 0.11+, is stated once in ../get-started/01-install-and-versions.md.
Subpaths
Every subpath ships ESM + CJS + .d.ts. src/ is published alongside dist/, so
go-to-definition lands on real source.
| Subpath | Surface | Where it runs |
|---|---|---|
@homerunner-next/widget-core | Barrel over /manifest, /urls, /globals, /plugin-schema, /contracts | anywhere |
.../package.json | The package manifest itself (version reads from your own scripts) | build scripts |
.../manifest | Manifest types, ref validators, resolvePluginWidget | anywhere |
.../urls | Keyword parsing, dist file names, plugin asset URLs | anywhere |
.../globals | window.HRWidgetRuntime / window.HRPlugins contracts, registration | browser |
.../plugin-schema | zodToManifestSchema, PluginUiSchema types | build scripts |
.../schema | Base widgetSchema, parseWidgetConfig and the stored-settings repairs | widget + SSR |
.../spacing | The spacing model and its CSS resolvers | widget + SSR |
.../contracts | WidgetProps, LayoutWidgetProps, Feed, Widget<T>, page-schema types | anywhere |
.../runtime | mount, shadow DOM, providers, fetchers, error UI | browser (externalized — see below) |
.../utils | Locale, translations, image proxy, query factories, widget context | widget + SSR |
.../vite | viteHomerunnerWidget build preset, stampWidgetCoreVersion | build only |
.../mock | MSW browser mocking for npm run dev | dev only |
.../testing | node vm SSR harness, msw/node, local SSR dev server | dev only |
bin hr-widget-build | The build loop npm run build delegates to (0.11.0+) | build only |
/mock and /testing must never be imported from src/index.tsx, src/widget.tsx or an
SSR entry — /testing imports node:vm, node:http and msw/node, and a stray import
puts MSW in your published bundle.
What the root barrel does not re-export
// packages/homerunner-widget-core/src/index.ts
export * from "./manifest";
export * from "./urls";
export * from "./globals";
export * from "./plugin-schema";
export * from "./contracts";
// `./schema` and `./runtime` are not re-exported from the root to avoid
// pulling zod / React peers into consumers that don't need them.
/schema, /spacing, /runtime, /utils, /vite, /mock and /testing are not
reachable from the barrel. import { parseWidgetConfig } from "@homerunner-next/widget-core"
does not compile — use the subpath.
Dependencies and version pins
Hard dependencies (installed for you): lodash-es, zod-to-json-schema.
| Peer | Range | Optional |
|---|---|---|
react | ^19.2.0 | yes |
react-dom | ^19.2.0 | yes |
@tanstack/react-query | ^5.0.0 | yes |
zod | ^3.0.0 | yes |
vite | ^5.0.0 || ^6.0.0 || ^7.0.0 | yes |
msw | ^2.0.0 | yes |
Every peer is declared optional through peerDependenciesMeta, so npm installs nothing you
did not ask for. Install the ones you use: a pure /urls consumer needs none; a widget
needs react, react-dom, react-query and zod.
Pin the React family exactly — no caret:
| Package | Pin |
|---|---|
react | 19.2.5 |
react-dom | 19.2.5 |
@tanstack/react-query | 5.95.2 |
Client bundles externalize these against the host page’s runtime IIFE, and the SSR sandbox
supplies its own copies. A patch-level drift trips react-dom’s internal
Incompatible React versions check at runtime. Enforcement: advisory (unvalidated) —
nothing at publish reads your package.json versions, so a drift ships and fails on a
customer page.
/schema
| Export | Kind | Signature |
|---|---|---|
widgetSchema | value | The base zod object every widget extends |
widgetFilterSchema | value | {feed_id?: number, platform?: string | null, property?: string} |
WidgetSchemaType | type | z.infer<typeof widgetSchema> |
WidgetFilterSchemaType | type | z.infer<typeof widgetFilterSchema> |
parseWidgetConfig | fn | <S extends ZodTypeAny>(schema: S, options: unknown, pre?: (v) => v) => z.output<S> |
unwrapSettingSchema | fn | (s: ZodTypeAny | undefined) => ZodTypeAny | undefined |
stripNullSettingLeaves | fn | (value: unknown, schema: ZodTypeAny) => unknown |
coerceScalarSettingLeaves | fn | (value: unknown, schema: ZodTypeAny | undefined) => void — mutates in place |
The nine base fields of widgetSchema, their exact defaults, and which of them the
dashboard renders for you are normative in
config-and-ui-schema.md.
parseWidgetConfig
pre defaults to liftLegacySpacing from /spacing. The call runs four steps in order:
stripNullSettingLeaves— drops storednull/''leaves the schema rejects, so the schema default applies instead of a parse throw.coerceScalarSettingLeaves— repairs stringified scalars the schema declares asz.number()/z.boolean()(font.size: "16"becomes16).pre— the legacy-spacing lift, or your own transform.schema.parse(...).
Options reach your component raw on both server and client, so this call is mandatory in
widget.tsx and in every SSR export. Why, and what produces those artifacts, is normative in
../concepts/settings-and-options.md.
// pdp-suite (reference plugin): src/widgets/stay-hero/widget.tsx
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";
export default function StayHero(props: { options?: Record<string, unknown> }) {
const cfg = parseWidgetConfig(configZod, props.options ?? {});
return <h1 className="shero-headline">{cfg.headline}</h1>;
}
stripNullSettingLeaves also skips __proto__ / constructor / prototype keys while
rebuilding objects, so a hostile stored blob cannot swap a prototype under zod.
/spacing
The base widgetSchema.spacing field replaced the old width: {maxWidth, unit} pair in
widget-core 0.9.0. This subpath is the whole model. Both shapes.
Constants
| Constant | Value |
|---|---|
SPACING_SIDES | ["top", "right", "bottom", "left"] |
DEFAULT_MARGIN | top/bottom 0px, left/right unit auto — renders margin: 0 auto |
DEFAULT_PADDING | all four sides 0px |
ZERO_MARGIN | all four sides 0px |
DEFAULT_BASE_SPACING | {margin: DEFAULT_MARGIN, padding: DEFAULT_PADDING, width: {0, auto}, maxWidth: {100, "%"}} |
DEFAULT_SLOT_SPACING | {margin: ZERO_MARGIN, padding: DEFAULT_PADDING, width: {0, auto}, maxWidth: {0, "none"}} |
Functions
| Export | Signature | Notes |
|---|---|---|
resolveWidgetSpacingStyle | (options: Record<string, unknown> | undefined) => CSSProperties | Takes raw options; runs the legacy lift and type repair itself. The one-liner every widget uses. |
resolveSpacingStyle | (spacing: Partial<SpacingValue> | undefined) => CSSProperties | Takes an already-parsed spacing object. Use for layout slots. |
liftLegacySpacing | <T extends Record<string, unknown>>(options: T) => T | Lifts legacy width / top-level margin / padding onto spacing. Non-destructive — the legacy keys stay. |
mirrorLegacySpacing | <T extends Record<string, unknown>>(settings: T) => T | Save-path inverse. mirrorLegacyWidth is a deprecated alias of the same function. |
coerceSpacingValue | (raw: unknown) => Record<string, unknown> | undefined | Repairs wrong-typed members; an uncoercible member is removed so the schema default applies rather than the parse throwing. |
cssLength | (side: SpacingSide | undefined, allowAuto: boolean) => string | Always returns a string; never NaNpx. |
cssDimension | (side: SpacingSide | undefined) => string | undefined | Returns undefined when nothing should be emitted. |
clampPercentToFull | (length: string | number | undefined) => string | number | undefined | Caps a % length at 100%; absolute units pass through. Opt-in. |
marginBoxSchema | (defaults: BoxSpacing<SpacingUnit>) => ZodType | Schema builder |
paddingBoxSchema | () => ZodType | Schema builder |
widthSchema | (defaults: SpacingSide<WidthUnit>) => ZodType | Schema builder |
maxWidthSchema | (defaults: SpacingSide<MaxWidthUnit>) => ZodType | Schema builder |
baseSpacingSchema | value | What widgetSchema.spacing is |
Types: SpacingUnit (px \| % \| auto), PaddingUnit (px \| %), WidthUnit
(px \| % \| auto), MaxWidthUnit (px \| % \| none), SpacingSide<U>, BoxSpacing<U>,
SpacingValue, SpacingSideName, BaseSpacingSchemaType.
Rendering rules
These are what resolveSpacingStyle emits. All are silent runtime behaviour — nothing
warns.
| Input | Emitted |
|---|---|
| any margin | all four margin* properties, always, plus boxSizing: "border-box" |
padding with every side <= 0 | nothing — your stylesheet’s own padding survives |
padding with any side > 0 | all four padding* properties (your stylesheet padding is fully overridden) |
width unit auto | nothing |
width in px | width: min(<n>px, 100%) — a px width can never force horizontal overflow |
width in % | width: <n>% verbatim (use clampPercentToFull if you want it capped) |
maxWidth unit none | nothing |
width / maxWidth value <= 0 | nothing |
any magnitude > 10000, NaN or infinite | margins/padding fall back to 0 in the declared unit; width/maxWidth emit nothing |
// packages/homerunner-widget-core/src/spacing.ts:465 (resolveWidgetSpacingStyle)
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";
// Raw options, not the parsed config — the helper does the lift and repair itself.
<div style={resolveWidgetSpacingStyle(props.options as Record<string, unknown>)}>…</div>
Not supported yet. Plugin layouts get no per-slot spacing UI.
slotSpacingandgaplive on the platform’s privatelayoutWidgetSchema, which is not published to npm, so a plugin layout inherits neither. Declare your own slot fields — you can rebuild an equivalent shape frommarginBoxSchema/paddingBoxSchema/widthSchema/maxWidthSchemaplusDEFAULT_SLOT_SPACING. See layouts.md.
/plugin-schema
| Export | Kind | Signature |
|---|---|---|
zodToManifestSchema | fn | (schema: ZodType) => JSONSchema |
JSONSchema | type | The narrow JSON Schema shape the dashboard form reads |
PluginUiField | type | One field’s UI spec, or a nested PluginUiSchema |
PluginUiSchema | type | {[key: string]: PluginUiField | PluginUiLayoutSpec | undefined; ui?: PluginUiLayoutSpec} |
PluginUiLayoutSpec | type | {layout?: "panel" | "collapsible", label?, description?, showIf?} — there is no fields key |
zodToManifestSchema runs zodToJsonSchema(schema, {$refStrategy: "none"}) and then strips
$schema, $defs, definitions, $ref and the boolean additionalProperties marker.
A schema-valued additionalProperties (what z.record(...) produces) is preserved, so
records round-trip back to zod in the dashboard. All sub-schemas are inlined — the consumer
walks a flat tree and never resolves a $ref.
The twelve component tokens, verbatim from the type:
// packages/homerunner-widget-core/src/plugin-schema.ts:54
export type PluginUiField =
| {
label?: string;
description?: string;
component?:
| "TextInput" | "Textarea" | "NumberInput" | "Checkbox" | "Select"
| "ColorPicker" | "FontSelect" | "LanguageSelect"
| "PlatformSelect" | "PropertySelect" | "WidgetSelect" | "Translations";
optionLabels?: Record<string, string>;
/** Conditional visibility: show this field only when condition is met. */
showIf?: { field: string; equals: unknown };
}
| PluginUiSchema;
Which zod type each token must be paired with, what round-trips and what silently degrades, and which keys are ignored are normative in config-and-ui-schema.md.
/manifest
Types and guards for the manifest itself. Your own CI can reuse them; nothing here runs in a widget.
| Export | Kind | Purpose |
|---|---|---|
PluginManifest | type | The root manifest, both shapes |
PluginWidgetSummary | type | One widgets[] entry (0.11.0+) |
PluginWidgetManifest | type | A sub-manifest — {slug, configSchema, uiSchema?} (0.11.0+) |
ResolvedPluginWidget | type | The normalized serving shape both manifest shapes resolve to |
PluginLayoutSlot | type | {name, accepts?, prefill?} (0.11.0+, prefill 0.12.1+) |
PluginPageKey / PLUGIN_PAGE_KEYS | type / value | The six page keys a layout may declare (0.12.0+) |
isPluginPageKey | fn | (value: unknown) => value is PluginPageKey |
isMultiWidgetManifest | fn | The single discriminator between the two shapes |
resolvePluginWidget | fn | (manifest, widgetSlug: string | null) => ResolvedPluginWidget | null |
normalizeLayoutSlots | fn | (slots: unknown) => PluginLayoutSlot[] | undefined |
pluginLayoutServesPage | fn | (resolved, page) => boolean |
isPluginMediaRef | fn | media/<file> (no subdirectory) or an absolute http(s) URL |
isPluginReadmeRef | fn | media/<file>.md, at most one nested directory, no .. |
isPluginFontRef | fn | absolute http(s), or a relative path with no leading /, no scheme:, no \, no .. |
isSlotPrefillKeyword | fn | /^[a-z][a-z0-9-]{0,63}$/ — system keywords only |
isSystemWidgetKeyword | fn | Alias of isSlotPrefillKeyword |
MAX_SLOT_PREFILL | const | Prefill entries kept per slot |
MAX_PLUGIN_FONTS | const | Declared fonts kept per widget |
MEDIA_REF_MAX_LENGTH | const | Max characters in a media reference |
resolvePluginMediaUrl | fn | (ref: unknown, manifestUrl: string | null | undefined) => string | null |
The numeric values of the three constants, and what happens when you exceed them, are normative in limits-and-errors.md. The field-by-field rules live in manifest-root.md and widget-summary.md.
isMultiWidgetManifest is the entire shape test — there is no version flag. resolvePluginWidget
fails closed: a widget slug against a v1 manifest, a bare keyword against a suite
manifest, an unknown slug, or a v1 manifest missing assets.js all return null. Its
defaults are name -> slug, category -> "content" (anything that is not the literal
"layout"), ssr -> false, mediaSafeAuto only on a strict === true, and icon /
readme dropped unless they pass their ref validators.
/contracts
| Export | Kind | Notes |
|---|---|---|
WidgetProps<T, D> | type | Props every content widget receives |
LayoutWidgetProps<T, D> | type | WidgetProps plus renderedSlots (0.11.0+) |
WidgetCategory | type | "content" | "layout" |
GetInitialDataCtx<T> | type | {options, widgetId, feedId, widgetType, isSSR?, cookies?, headers?, query?} |
GetAwaitedFuncReturnType<T> | type | Awaited<ReturnType<T>> |
Feed | type | The public-api feed row |
Widget<T> | type | A widget row, incl. plugin_manifest and plugin_manifest_url |
SlotBinding | type | {widgetId, ovveride?, override?} |
PageWidgetDef, PageSchema | type | Page-schema shapes |
isLayoutEntry | fn | (entry: {widget: PageWidgetDef}) => boolean |
EXPLORER_BACKED_KEYWORDS | const | ["explorer", "search-bar", "search-results"] |
ExplorerBackedKeyword | type | Union of the three |
isExplorerBackedKeyword | fn | Type guard over the three |
// packages/homerunner-widget-core/src/contracts.ts:27
export interface WidgetProps<T extends WidgetSchemaType = WidgetSchemaType,
D extends Record<string, any> | undefined = undefined> {
options: T;
target?: HTMLElement;
widgetId: string;
feedId: number;
widgetType: string;
resolvedTheme?: "light" | "dark";
data: D;
}
export interface LayoutWidgetProps<T extends WidgetSchemaType = WidgetSchemaType,
D extends Record<string, any> | undefined = undefined>
extends WidgetProps<T, D> {
/** Pre-rendered React nodes for each slot. Keys are slot names. */
renderedSlots: Record<string, ReactNode>;
}
Import these instead of hand-writing a props interface. Which props actually arrive on which
render path — and the fact that options is typed T but arrives raw for plugins — is
normative in component-and-ssr-module.md.
SlotBinding.ovveride is a live legacy misspelling, kept as a read-only fallback because
stored page schemas still carry it. When both keys are present, override wins per key.
Write override; read both if you parse stored schemas yourself.
/urls
| Export | Signature | Notes |
|---|---|---|
PLUGIN_WIDGET_PREFIX | "plugin:" | |
PluginKeywordParts | type | {pluginSlug, widgetSlug: string | null, registryKey} |
parsePluginKeyword | (keyword: string) => PluginKeywordParts | null | registryKey is everything after the prefix |
pluginWidgetDistFile | (parts: PluginKeywordParts, kind: "iife" | "css" | "ssr") => string | |
DEFAULT_PROXY_BASE_URL | "https://assets.homerunner.io" | Overridable per host page via window.HRWidgetRuntime.proxyBaseUrl |
getPluginAssetUrl | (slug: string, file: string, proxyBaseUrl?: string) => string | Builds <base>/p/{slug}/{file}; a trailing slash on the base is stripped |
isAbsoluteUrl | (url: string) => boolean | /^https?:\/\//i only |
resolvePluginAssetUrl | (manifestUrl: string | undefined, urlOrPath: string) => string | Manifest URL first |
distRelativeAssetName | (urlOrPath: string) => string | Keeps the widget directory after dist/ |
extractAssetFilename | (urlOrPath: string) => string | Last path segment, query and hash stripped |
Two corrections worth stating outright, because both were previously documented wrong:
isAbsoluteUrlmatcheshttp://andhttps://and nothing else. A protocol-relative//cdn.example.com/x.jsand adata:URI both count as relative and will be resolved against the manifest URL.resolvePluginAssetUrltakes the manifest URL as its first argument, and throws when the path is relative and no manifest URL is known:Cannot resolve relative plugin asset "<x>": manifest URL not known. Ship absolute URLs in the manifest, or pass the manifest URL when fetching.
The URL each of these produces on each serving path, the /p/ proxy semantics and the CSP
consequences are normative in assets-and-urls.md.
/globals
| Export | Kind | Notes |
|---|---|---|
HRWidgetRuntime | type | The shape of window.HRWidgetRuntime |
HRPlugins | type | Record<string, {component, category?}> |
HRWidgetRegistration | type | {component, mount?, category?} |
getRuntime | fn | () => HRWidgetRuntime; throws when the runtime IIFE has not loaded |
registerPlugin | fn | (key: string, component: ComponentType<any>, meta?: {category}) => void |
registerPluginWidget | fn | (pluginSlug, widgetSlug, component, meta?) => void (0.11.0+) |
registerPluginWidget(a, b, C, m) is exactly registerPlugin(a:b, C, m). Both write
window.HRPlugins[key], keyed by the keyword minus the plugin: prefix — {slug} for a
single-widget plugin, {slug}:{widget} for a suite. Registration is idempotent (last write
wins).
getRuntime() throws:
[widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry.
The real HRWidgetRuntime carries React, ReactDOM and React Query flat — there is no
ReactQuery namespace key:
// packages/homerunner-widget-core/src/globals.ts:15
export interface HRWidgetRuntime {
React; ReactDOM; jsxRuntime;
createRoot; hydrateRoot;
QueryClient; QueryClientProvider;
useQuery; useInfiniteQuery; useMutation; useQueryClient; useIsFetching; useIsMutating;
dehydrate; hydrate;
proxyBaseUrl?: string;
resolveChildJsUrl?: (keyword: string) => string;
apiBaseUrl?: string;
}
Not supported yet. The TypeScript type above is behind the live object. The deployed runtime IIFE also exposes
widgetCore(the whole/runtimesurface, which is how the evergreen externalization resolves),useQueriesandkeepPreviousData— none of which the interface declares. Reading them compiles only through a cast, and that cast is on you until the type is updated.
/runtime
Externalized at build time (0.10.0+). viteHomerunnerWidget maps
@homerunner-next/widget-core/runtime to HRWidgetRuntime.widgetCore in the client IIFE, and
the renderer’s SSR sandbox supplies its own copy. Your source is unchanged — you import
normally and dev mode still resolves node_modules — but the code that ships to a customer
page is the host fleet’s, not the copy you built against. Only /runtime is externalized;
/globals, /schema, /plugin-schema, /spacing, /urls and /utils stay bundled.
Publishing a build stamped runtime.widgetCore < 0.10.0, or not stamped at all, is a
publish ERROR — see packaging-and-publishing.md.
Mount and hydration
| Export | Signature | Audience |
|---|---|---|
mount | (container: HTMLElement, config: MountConfig) => void — returns nothing | author |
MountConfig | type, eight fields (below) | author |
extractWidgetId | (stringId: string) => {widgetId: string, feedId: number} | author |
ClientWrapperForWidget | component — fetches feed + widget config, then render-props your widget | author |
WidgetHydrationTree | component — {queryClient, portalContainer?, shadowHost?, children} | platform-internal |
getSharedQueryClient | () => QueryClient — the window.HRWidget.__QUERY_CLIENT__ singleton | author |
// packages/homerunner-widget-core/src/runtime/mount.tsx:42
export interface MountConfig {
widget: ComponentType<any>;
widgetType: string;
category?: WidgetCategory; // "layout" routes through LayoutCsrRenderer
resolveChildJsUrl?: (keyword: string) => string; // layouts only
cssUrls?: string[];
extraCssUrls?: string[];
resolveDefaultCssUrls?: (widgetType: string) => string[];
shadowDOM?: boolean; // default true
}
WidgetHydrationTree plus an identifierPrefix is how the server and the client agree on
useId output. Call mount(); do not call hydrateRoot yourself. What mount reads and
writes in the DOM, its idempotency guard, per-embed failure isolation and the CSS-URL
precedence rules are normative in runtime-and-mount.md.
Shadow DOM and portals
| Export | Signature | Audience |
|---|---|---|
setupShadowDOM | (hostEl: HTMLElement, cssUrls: string[]) => ShadowDOMSetupResult | null | author (mount calls it) |
ShadowDOMSetupResult | type {shadowRoot, shadowHost, reactRoot, portalContainer, cssReady, cssPending} | author |
migrateSSRContent | (hostEl: HTMLElement, reactRoot: HTMLDivElement) => void — moves nodes, never re-serializes | platform-internal |
getCSSUrlsFromElement | (el: HTMLElement) => string[] — reads the legacy data-hr-css attribute | platform-internal |
isShadowDOMSupported | () => boolean | author |
PortalContainerProvider / usePortalContainer | context provider / () => HTMLElement | undefined | author |
ShadowHostProvider / useShadowHost | context provider / () => HTMLElement | null | author |
useTopLevelPortal | (options?: UseTopLevelPortalOptions) => HTMLElement | undefined | author |
UseTopLevelPortalOptions | type {cssString?: string, enabled?: boolean} — enabled defaults to true | author |
UseTopLevelPortalOptions has two fields. enabled: false skips creating the elevated
portal and returns undefined, which is how an elevateDom-style switch falls back to the
in-shadow portal. Dialog patterns and the token trap are in
../recipes/portals-and-dialogs.md.
Theme
| Export | Signature |
|---|---|
HOST_THEME_ATTR | "data-hr-theme" |
ResolvedHostTheme | type "light" | "dark" |
resolveHostTheme | (colorScheme: string | undefined, fallback?: ResolvedHostTheme) => ResolvedHostTheme |
applyHostTheme | (host: Element | null | undefined, theme: ResolvedHostTheme) => void |
attachHostThemeController | (host, colorScheme, opts?: {fallback?}) => () => void — returns a dispose fn |
mirrorHostTheme | (source: Element, target: Element) => () => void |
resolveHostTheme maps light/dark through unchanged, resolves auto and global
against prefers-color-scheme, and returns fallback (default "light") for anything else.
attachHostThemeController keeps the attribute live across an OS theme flip — the React
resolvedTheme prop does not. Styling guidance is in
../recipes/styling-and-theming.md.
Registry and script loading
| Export | Signature | Audience |
|---|---|---|
registerWidget | (type: string, registration: HRWidgetRegistration) => void | platform-internal |
getRegisteredWidget | (type: string) => HRWidgetRegistration | undefined | author |
whenWidgetRegistered | (type: string) => Promise<HRWidgetRegistration> — rejects on a hard timeout | platform-internal |
HRWidgetRegistration | type, re-exported from /globals | author |
loadScriptOnce | (src: string) => Promise<void> — deduped by src | author |
Data and errors
| Export | Signature | Audience |
|---|---|---|
homerunnerApiBaseUrl | () => string | author |
fetchFeed | (feedId: number) => Promise<Feed> | author |
fetchWidget | <T>(widgetId: string) => Promise<Widget<T>> | author |
useFetchWidget | <T>(feedId: number, widgetId: string) => UseQueryResult<{feed, widget}> | author |
getFinalSettings | <T>(widget: Widget<T>, feed: Feed, optionsOverride?: Partial<T>) => T | author |
WidgetFetchError | class (status: number, message: string), name === "WidgetFetchError" | author |
isRateLimitError | (error: unknown) => boolean — status 429 | author |
isPermanentError | (error: unknown) => boolean — any 4xx | author |
retryTransient | (max: number) => (failureCount, error) => boolean | author |
WidgetErrorState | component {rateLimited, feedId?, widgetId?, notFoundProperty?, renderFailed?} | author |
WidgetRenderBoundary | class component {widgetType, widgetId?, feedId?, hostEl?, themeEl?, resetKey?, children} | platform-internal |
getFinalSettings takes the widget first, then the feed. Its merge precedence and the
reserved __parentColorScheme key are normative in
../concepts/settings-and-options.md.
homerunnerApiBaseUrl() resolves in three steps: window.HRWidgetRuntime.apiBaseUrl, then
process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL, then the hard fallback
https://central.homerunner.io. There is no host sniffing.
fetchFeed and fetchWidget read res.status before parsing (a 429 gateway response may
not be JSON) and throw WidgetFetchError with the messages
Failed to fetch feed (HTTP <n>) and Failed to fetch widget (HTTP <n>).
There is no okOrThrow and no retryUnlessRateLimited in widget-core. Both names appear
in HomeRunner’s private packages only; importing either fails to resolve. Use
WidgetFetchError + retryTransient. Full fetching guidance:
../recipes/fetching-data.md.
Layout
| Export | Signature | Audience |
|---|---|---|
LayoutCsrRenderer | default-exported component | platform-internal (mount uses it) |
LayoutCsrRendererProps | type {feedId, widgetId, LayoutComponent, widgetType, optionsOverride?, resolveChildJsUrl?, rootElement?} | author |
Query-state helpers
| Export | Kind |
|---|---|
dedupeDehydratedState | (state, opts?: {minSharedBytes?}) => MaybeCompactDehydratedState |
inflateDehydratedState | (state: MaybeCompactDehydratedState) => DehydratedState — throws on a dangling ref |
isCompactDehydratedState | type guard |
HR_COMPACT_REF_KEY | "$hr" |
HR_COMPACT_VERSION | 1 |
MIN_SHARED_BYTES | 32 |
CompactDehydratedState / MaybeCompactDehydratedState | types |
All platform-internal — the renderer compacts the SSR query cache and getSharedQueryClient
inflates it. You do not call these.
Visitor preferences
readVisitorPref<T>(scope, name): T | undefined, writeVisitorPref<T>(scope, name, value): void,
VISITOR_PREF_TTL_MS (72 hours in milliseconds).
Deliberately not exported
These exist in src/runtime/ but are absent from the /runtime barrel, so you cannot import
them: fetchPropertyBySlug, resolveMountCssUrls, hasRenderedChildren, UnmountableEl,
adoptDeclarativeShadowRoot, waitForShadowLinks, applyPreloadReservation,
resolvePreloadMinHeight, buildSlotChildOptions.
Not supported yet. Writing your own layout mount is therefore not possible with the published surface: the child-options builder and the DSD adoption helper are both private. Use
mount()withcategory: "layout"— see layouts.md.
/utils
| Export | Signature |
|---|---|
useTranslation | <K extends string>(store: Record<K, Record<string, string>>, currentLocale?: string) => {_t} |
useLocaleDetection | (useAutoDetectedLocale?: boolean, configuredLocale?: string) => {detectedLocale, ...helpers} |
detectBrowserLocale | () => string |
getBestMatchingLocale | locale negotiation helper |
findObjectByLocale / getObjectForCurrentLocale / getAvailableLocales | locale-keyed object helpers |
getProxiedImageUrl | (imageUrl: string, width?: number, height?: number) => string — defaults 64 x 64 |
getFeedQuery | (feedId: number) => {queryKey: ["feed", feedId], queryFn} |
getWidgetQuery | (widgetId: string) => {queryKey: ["widget", widgetId], queryFn} |
WidgetProvider / useWidget | context carrying {options, feedId, widgetId, widgetType, data, resolvedTheme} |
preloadMinHeightHint | (widgetType: string, options?: unknown) => string | null |
_t(key, defaultValue = key) resolves store[key][currentLocale], then
store[key]["en_US"], then defaultValue. useTranslation holds no React state despite the
name, so it is safe to call anywhere. useLocaleDetection deliberately seeds state with the
configured locale and only switches to the browser locale one tick after mount — seeding
from the browser would fork the SSR render from the client’s first render.
preloadMinHeightHint only knows system widget types. A plugin:* widget type always
returns null, which is why a CSR-only plugin widget needs expectedHeight in its summary
(or data-hr-min-height on the embed) to reserve space. See
widget-summary.md.
Three different query keys exist for the same resource and they are not interchangeable:
useFetchWidget caches under ["widget", feedId, widgetId], getWidgetQuery under
["widget", widgetId], getFeedQuery under ["feed", feedId]. Pick one and use it on both
the server and the client.
/vite and hr-widget-build
| Export | Signature |
|---|---|
viteHomerunnerWidget | (opts: ViteHomerunnerWidgetOptions) => {plugins, define, build, server} |
ViteHomerunnerWidgetOptions | {slug: string, command?: "serve" | "build", remoteName?: string, root?: string} |
stampWidgetCoreVersion | <T>(manifest: T, version?: string) => T — pure; returns a copy with runtime.widgetCore set |
Defaults: remoteName is ${slug}${BUILD_WIDGET ? "_" + BUILD_WIDGET : ""} with every
non-alphanumeric replaced by _; root is process.cwd(); an omitted command is treated
as a build.
// pdp-suite (reference plugin): vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { viteHomerunnerWidget } from "@homerunner-next/widget-core/vite";
export default defineConfig(({ command }) => {
const hr = viteHomerunnerWidget({ slug: "pdp-suite", command });
return {
plugins: [react(), ...hr.plugins],
define: hr.define,
build: hr.build,
server: { ...hr.server, port: 3001 },
};
});
What the preset installs
Six plugins, in order: the SSR browser-globals tree-shaker, the widget style plugin (CSS resolver plus dev middleware), the widget asset proxy plugin, the large-asset emitter, the plugin-assets plugin, and the manifest generator.
define is {__HR_WIDGET_DEV__: "true" | "false"} plus — in dev only — every
NEXT_PUBLIC_* key found in .env / .env.local injected as a process.env.X literal.
Production builds skip that injection deliberately: a published IIFE must carry no
environment URL and reads the base URL from window.HRWidgetRuntime.apiBaseUrl instead. A
NEXT_PUBLIC_* key that works in npm run dev can therefore be undefined at SSR time.
server is {cors: true}.
What each pass emits
| Single-widget | Suite (0.11.0+) | |
|---|---|---|
| Client entry | ./src/index.tsx | ./src/widgets/{BUILD_WIDGET}/index.tsx |
| Client output | dist/{slug}.iife.js | dist/{widget}/{widget}.iife.js |
| CSS output | dist/{slug}.css | dist/{widget}/{widget}.css |
SSR entry (BUILD_SSR=1) | ./src/ssr-entry.ts | ./src/widgets/{BUILD_WIDGET}/ssr-entry.ts |
| SSR output | dist/{slug}-ssr.umd.js | dist/{widget}/{widget}-ssr.umd.js |
| Dev CSS route | /dist/{slug}/{slug}.css | /dist/{slug}/{widget}/{widget}.css |
Both passes set cssCodeSplit: false and sourcemap: true. The client pass is iife format
named after remoteName and externalizes react, react/jsx-runtime, react-dom,
react-dom/client, @tanstack/react-query and @homerunner-next/widget-core/runtime against
HRWidgetRuntime. The SSR pass is cjs with exports: "named" and
inlineDynamicImports: true — every dynamic import() is hoisted into the single umd, which
is why browser-only side effects must sit behind a typeof guard.
The SSR tree-shaker rewrites typeof window | document | self | navigator | localStorage | sessionStorage to the literal "undefined" in every .js/.jsx/.ts/.tsx/.mjs/.cjs module on
SSR passes only. Only the typeof form is rewritten — if (document) still runs against the
sandbox’s truthy Proxy stub.
The manifest generator runs at closeBundle on the SSR pass (single-widget) or on the pass
marked HR_BUILD_FINALIZE=1 (suite). It walks dist/, writes a {base}-{sha256[0:8]}{ext}
twin beside every non-.map file, emits dist/manifest.json as
{buildHash, generatedAt, files} keyed by dist-relative path, then rewrites your tracked
root manifest.json through stampWidgetCoreVersion and logs
runtime.widgetCore stamped: <version>. Never hand-write runtime.widgetCore.
Declared assets.fonts are read from the root manifest at config time and silently
truncated to the per-widget maximum before they reach the client IIFE’s document.head
font loader — an over-long list loses its tail with no warning at build or publish (the limit
is in limits-and-errors.md). Asset imports and CSS url()s over the
inline limit get ?no-inline appended so library mode emits them as files instead of data
URLs. Details: assets-and-urls.md.
hr-widget-build (0.11.0+)
Run it through npm run build; never invoke vite build directly on a suite. Preconditions:
run from the project root, manifest.json present, vite installed in the project.
- Single-widget: two passes —
vite build, thenBUILD_SSR=1 vite build. - Suite: validates every summary first, then
rm -rf dist/, then runs the flattened (widget x pass) list withBUILD_WIDGET={slug}, skipping the SSR pass for a widget declaringssr: false, and marks the last passHR_BUILD_FINALIZE=1.
Validation is fail-fast before any pass runs — the CLI prints and exits non-zero for an
invalid slug, a reserved slug, a duplicate slug or a missing src/widgets/{slug}/index.tsx.
A bare vite build on a project whose manifest declares widgets[] throws rather than
producing a plausible-looking broken build. Every exact message is indexed in
limits-and-errors.md.
/mock
Dev-only MSW browser mocking. Imported by your dev entry, never by src/index.tsx.
| Export | Signature |
|---|---|
startMockWorker | (keyword: string, options?: MockOptions) => Promise<MockControl> — idempotent |
createMockHandlers | (keyword?: string, fixtures?: MockFixtures) returning MSW handlers — keyword defaults to plugin:dev-widget |
MockControl | type {worker, scenarios, getState, setScenario, setSettings, setDelay, setBranding, subscribe, stop} |
MOCK_SCENARIOS | ["default", "empty", "single", "large", "loading", "error"] |
MockScenario / MockState / MockOptions / MockFixtures / MockBranding | types |
initMockState / getMockState / setMockState / subscribeMockState | the shared mutable store |
makeProperty | (i: number) |
makePropertiesResponse | (count: number, total?: number) |
makeReview | (i: number) |
makeReviewsResponse | (count: number, total?: number, averageRating?: number) |
makeFeedDetails | (feedId: number, branding: MockBranding) |
makeWidget | (widgetId: string, feedId: number, keyword: string, settings: Record<string, unknown>) |
scenarioCount | (scenario: MockScenario) => number — empty 0, single 1, large 48, otherwise 6 |
DEFAULT_BRANDING | the default MockBranding |
MockOptions defaults: scenario: "default", delayMs: 300, widgetId: "dev-widget",
feedId: 1. MockFixtures keys are properties, reviews, feedDetails, widget,
feedWidgets, propertyFull, calendar, plus an open index signature; each value is raw
JSON or a (request) => json factory. The worker starts with
onUnhandledRequest: "bypass" and serviceWorker.url = "/mockServiceWorker.js".
// packages/homerunner-widget-core/src/mock/browser.ts:55
import { startMockWorker } from "@homerunner-next/widget-core/mock";
const control = await startMockWorker("plugin:pdp-suite:stay-hero", {
scenario: "default",
delayMs: 300,
widgetId: "dev-widget",
feedId: 1,
widgetSettings: { section: { show: true, title: "Stay" } },
branding: { colorScheme: "dark" },
});
control.setScenario("empty"); // also on window.__HR_MOCK__
Not supported yet.
startMockWorkeris a page singleton: repeat calls return the existing control, and the singlekeywordyou passed is baked into the handlers — along with one scenario, one latency, one branding and onesettingsobject for the whole page. A suite works around the keyword half with awidgetfixture factory keyed by the requested id (what--template suiteships); the shared mock state has no workaround. See ../local-dev/dev-sandbox-and-mocking.md.
/testing
Node-only. Imports node:vm, node:http and msw/node.
| Export | Signature |
|---|---|
renderPluginSSR | (opts: RenderPluginSSROptions) => Promise<RenderPluginSSRResult> |
evaluatePluginSSR | (ssrCode: string, apiBaseUrl: string, assetBaseUrl: string, filenameOrOptions?: string | EvaluatePluginSSROptions) => PluginSSRModule |
buildEnforcedFetch | (allowedHosts: string[] | undefined, baseFetch: typeof fetch, centralHost: string | null) => typeof fetch (0.12.2+) |
SSR_EXEC_TIMEOUT_MS | const — the default module-scope budget (0.12.2+) |
createNodeMockServer | (keyword: string, options?: MockOptions) => NodeMockServer |
createSSRDevServer | (opts: SSRDevServerOptions) => SSRDevServer |
resolvePluginSSRTarget | ({rootDir, slug, widget?}) => {target?: PluginSSRTarget, error?: string} (0.12.2+) |
widgetFromInvocation | (argv?: string[], env?: ProcessEnv) => string | undefined (0.12.2+) |
RenderPluginSSROptions | {ssrCode, filename?, ctx: SSRCtx, resolvedTheme?, apiBaseUrl?, assetBaseUrl?, ssrId?, externalFetch?, pluginAssetBase?, execTimeoutMs?} |
RenderPluginSSRResult | {html, dehydratedState, assets, props, ssrId} |
EvaluatePluginSSROptions | {filename?, externalFetch?, pluginAssetBase?, execTimeoutMs?} (0.12.2+) |
PluginSSRModule | {default, getInitialData?, dehydrateState?, getStaticAssets?} |
PluginSSRTarget | {keyword, widget: string | null, ssrFile, iifeFile, cssFile, externalFetch?} (0.12.2+) |
SSRCtx | {options: Record<string, unknown>, feedId: number, widgetId: string} |
NodeMockServer | {server, close} |
SSRDevServer | {server, url, ready, close} (ready 0.12.4+) — ready resolves with the base URL once listening; with port: 0 read url only after awaiting it |
SSRDevServerOptions | {slug, widget?, rootDir?, port?, widgetId?, feedId?, mock?, hydrate?, runtimeUrl?, apiBaseUrl?, assetBaseUrl?, externalFetch?} |
renderPluginSSR runs the production order: evaluate the umd, getInitialData,
dehydrateState, getStaticAssets, renderToString. createSSRDevServer defaults:
port 3003, widgetId: "dev-widget", feedId: 1, apiBaseUrl: "https://beta.homerunner.io",
assetBaseUrl: "https://assets-dev.homerunner.io"; runtimeUrl defaults to
${assetBaseUrl}/w/runtime.iife.js only when hydrate: true.
Four options closed the harness’s fidelity gaps in 0.12.2, and createSSRDevServer
handles all four so you never pass them: externalFetch (an array enforces those hosts,
false disables enforcement, omitted reads the widget’s declared list out of
manifest.json — the dev server passes the manifest’s), pluginAssetBase (the sandbox’s
__HR_PLUGIN_ASSET_BASE__ — the dev server points it at its own /dist/), execTimeoutMs
(module-scope budget, default SSR_EXEC_TIMEOUT_MS) and ssrId (the render’s
identifierPrefix, default {widgetId}:{feedId}:0, returned in the result — the dev
server stamps it into the envelope’s data-hr-ssr-id). What each one buys you, and
what still differs from production, is in
../local-dev/ssr-preview.md; the production sandbox contract
is normative in component-and-ssr-module.md.
Selecting a suite widget (0.12.2+)
createSSRDevServer takes widget. Unset, it falls back to widgetFromInvocation() —
--widget {slug} in argv, then --widget={slug}, then HR_SSR_WIDGET — so a scaffold’s
dev:ssr script previews a suite widget without being edited. The name is resolved against
the root manifest.json’s widgets[] at boot: it selects the keyword
plugin:{slug}:{widget}, the nested dist/{widget}/{widget}-* bundles and that widget’s
declared externalFetch. A single-widget (v1) plugin passes no widget and resolves
plugin:{slug} with the flat dist/{slug}-* paths, unchanged.
resolvePluginSSRTarget is that same lookup, exported for hand-rolled harnesses (a loop
over every widget, a slot-composing render). It returns its errors rather than throwing,
so you choose how to surface them; createSSRDevServer throws the returned string. The four
conditions and their messages are indexed in
limits-and-errors.md.
// packages/create-hr-plugin/template-suite/scripts/dev-ssr.mts — validate, then boot
import {
createSSRDevServer,
resolvePluginSSRTarget,
widgetFromInvocation,
} from "@homerunner-next/widget-core/testing";
const { target, error } = resolvePluginSSRTarget({
rootDir: process.cwd(),
slug: "acme-suite",
widget: widgetFromInvocation(),
});
if (!target) {
console.error(`\n ${error}\n`);
process.exit(1);
}
createSSRDevServer({ slug: "acme-suite", widget: target.widget ?? undefined, port: 3003 });
Registering a widget: the shape both bootstraps take
// pdp-suite (reference plugin): src/widgets/stay-hero/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import StayHero from "./widget";
import "./stay-hero.css";
registerPluginWidget("pdp-suite", "stay-hero", StayHero);
function doMount() {
document
.querySelectorAll<HTMLElement>("[data-hr-widget-container]")
.forEach((container) => {
if (container.querySelector('[data-hr-widget="plugin:pdp-suite:stay-hero"]')) {
mount(container, { widget: StayHero, widgetType: "plugin:pdp-suite:stay-hero" });
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", doMount);
} else {
doMount();
}
A layout adds { category: "layout" } to both calls — see
../get-started/04-add-a-layout.md.
Feature-to-version map
Gate anything you read here against the SDK version you actually installed.
| Version | What it added |
|---|---|
| 0.9.0 | spacing replaces width on the base schema; parseWidgetConfig |
| 0.10.0 | Evergreen runtime — /runtime externalized, runtime.widgetCore stamped; the publish floor |
| 0.10.1 | shadowDOM manifest flag; the raw-options contract codified |
| 0.11.0 | Suites, registerPluginWidget, hr-widget-build, layout widgets, presets, mediaSafeAuto, enforced externalFetch |
| 0.12.0 | assets.fonts, bundled assets, layout pages, marketplace icon / cover |
| 0.12.1 | Slot prefill |
| 0.12.2 | /testing: suite SSR preview (widget, resolvePluginSSRTarget, widgetFromInvocation) and five sandbox-fidelity fixes — /runtime shared, TextEncoder/TextDecoder removed, __HR_PLUGIN_ASSET_BASE__ defined, externalFetch enforced, module scope bounded, plus a reproducible hydration tree |
Nothing in 0.12.2 changes what your published plugin does — it is a local-tooling
release. Your build still stamps runtime.widgetCore with whatever you built against.
This page documents 0.12.2. What the npm registry currently serves is older, and the
consequences (no hr-widget-build bin, no suite support) are stated once with a date in
../get-started/01-install-and-versions.md.
Signatures the previous SDK reference got wrong
If you have an older copy of this page, these are the differences that will break code.
| Symbol | Previously documented | Actual |
|---|---|---|
getFinalSettings | (feed, widget, override?) | (widget, feed, optionsOverride?) |
resolvePluginAssetUrl | (slug, urlOrPath, manifestOrigin?) | (manifestUrl, urlOrPath); throws when relative with no base |
isAbsoluteUrl | true for http://, https://, //, data: | ^https?:// only |
DEFAULT_PROXY_BASE_URL | https://dashboard.homerunner.io | https://assets.homerunner.io |
MountConfig | five fields | eight fields, incl. category, extraCssUrls, resolveChildJsUrl |
UseTopLevelPortalOptions | {cssString?} | {cssString?, enabled?} |
HRWidgetRuntime | {React, ReactDOM, jsxRuntime, ReactQuery, …} | React Query exports are flat; there is no ReactQuery key |
HRPlugins | Record<string, {component}> | Record<string, {component, category?}> |
widgetSchema | carries width | carries spacing (since 0.9.0) |
PluginUiLayoutSpec | {layout, label, fields} | {layout?, label?, description?, showIf?} — no fields |
PluginUiField.component | eleven tokens | twelve — WidgetSelect was missing |
/spacing, /schema parse helpers, ~25 /runtime exports | absent | documented above |