Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

SubpathSurfaceWhere it runs
@homerunner-next/widget-coreBarrel over /manifest, /urls, /globals, /plugin-schema, /contractsanywhere
.../package.jsonThe package manifest itself (version reads from your own scripts)build scripts
.../manifestManifest types, ref validators, resolvePluginWidgetanywhere
.../urlsKeyword parsing, dist file names, plugin asset URLsanywhere
.../globalswindow.HRWidgetRuntime / window.HRPlugins contracts, registrationbrowser
.../plugin-schemazodToManifestSchema, PluginUiSchema typesbuild scripts
.../schemaBase widgetSchema, parseWidgetConfig and the stored-settings repairswidget + SSR
.../spacingThe spacing model and its CSS resolverswidget + SSR
.../contractsWidgetProps, LayoutWidgetProps, Feed, Widget<T>, page-schema typesanywhere
.../runtimemount, shadow DOM, providers, fetchers, error UIbrowser (externalized — see below)
.../utilsLocale, translations, image proxy, query factories, widget contextwidget + SSR
.../viteviteHomerunnerWidget build preset, stampWidgetCoreVersionbuild only
.../mockMSW browser mocking for npm run devdev only
.../testingnode vm SSR harness, msw/node, local SSR dev serverdev only
bin hr-widget-buildThe 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.

PeerRangeOptional
react^19.2.0yes
react-dom^19.2.0yes
@tanstack/react-query^5.0.0yes
zod^3.0.0yes
vite^5.0.0 || ^6.0.0 || ^7.0.0yes
msw^2.0.0yes

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:

PackagePin
react19.2.5
react-dom19.2.5
@tanstack/react-query5.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

ExportKindSignature
widgetSchemavalueThe base zod object every widget extends
widgetFilterSchemavalue{feed_id?: number, platform?: string | null, property?: string}
WidgetSchemaTypetypez.infer<typeof widgetSchema>
WidgetFilterSchemaTypetypez.infer<typeof widgetFilterSchema>
parseWidgetConfigfn<S extends ZodTypeAny>(schema: S, options: unknown, pre?: (v) => v) => z.output<S>
unwrapSettingSchemafn(s: ZodTypeAny | undefined) => ZodTypeAny | undefined
stripNullSettingLeavesfn(value: unknown, schema: ZodTypeAny) => unknown
coerceScalarSettingLeavesfn(value: unknown, schema: ZodTypeAny | undefined) => voidmutates 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:

  1. stripNullSettingLeaves — drops stored null / '' leaves the schema rejects, so the schema default applies instead of a parse throw.
  2. coerceScalarSettingLeaves — repairs stringified scalars the schema declares as z.number() / z.boolean() (font.size: "16" becomes 16).
  3. pre — the legacy-spacing lift, or your own transform.
  4. 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

ConstantValue
SPACING_SIDES["top", "right", "bottom", "left"]
DEFAULT_MARGINtop/bottom 0px, left/right unit auto — renders margin: 0 auto
DEFAULT_PADDINGall four sides 0px
ZERO_MARGINall 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

ExportSignatureNotes
resolveWidgetSpacingStyle(options: Record<string, unknown> | undefined) => CSSPropertiesTakes raw options; runs the legacy lift and type repair itself. The one-liner every widget uses.
resolveSpacingStyle(spacing: Partial<SpacingValue> | undefined) => CSSPropertiesTakes an already-parsed spacing object. Use for layout slots.
liftLegacySpacing<T extends Record<string, unknown>>(options: T) => TLifts legacy width / top-level margin / padding onto spacing. Non-destructive — the legacy keys stay.
mirrorLegacySpacing<T extends Record<string, unknown>>(settings: T) => TSave-path inverse. mirrorLegacyWidth is a deprecated alias of the same function.
coerceSpacingValue(raw: unknown) => Record<string, unknown> | undefinedRepairs wrong-typed members; an uncoercible member is removed so the schema default applies rather than the parse throwing.
cssLength(side: SpacingSide | undefined, allowAuto: boolean) => stringAlways returns a string; never NaNpx.
cssDimension(side: SpacingSide | undefined) => string | undefinedReturns undefined when nothing should be emitted.
clampPercentToFull(length: string | number | undefined) => string | number | undefinedCaps a % length at 100%; absolute units pass through. Opt-in.
marginBoxSchema(defaults: BoxSpacing<SpacingUnit>) => ZodTypeSchema builder
paddingBoxSchema() => ZodTypeSchema builder
widthSchema(defaults: SpacingSide<WidthUnit>) => ZodTypeSchema builder
maxWidthSchema(defaults: SpacingSide<MaxWidthUnit>) => ZodTypeSchema builder
baseSpacingSchemavalueWhat 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.

InputEmitted
any marginall four margin* properties, always, plus boxSizing: "border-box"
padding with every side <= 0nothing — your stylesheet’s own padding survives
padding with any side > 0all four padding* properties (your stylesheet padding is fully overridden)
width unit autonothing
width in pxwidth: 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 nonenothing
width / maxWidth value <= 0nothing
any magnitude > 10000, NaN or infinitemargins/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. slotSpacing and gap live on the platform’s private layoutWidgetSchema, which is not published to npm, so a plugin layout inherits neither. Declare your own slot fields — you can rebuild an equivalent shape from marginBoxSchema / paddingBoxSchema / widthSchema / maxWidthSchema plus DEFAULT_SLOT_SPACING. See layouts.md.

/plugin-schema

ExportKindSignature
zodToManifestSchemafn(schema: ZodType) => JSONSchema
JSONSchematypeThe narrow JSON Schema shape the dashboard form reads
PluginUiFieldtypeOne field’s UI spec, or a nested PluginUiSchema
PluginUiSchematype{[key: string]: PluginUiField | PluginUiLayoutSpec | undefined; ui?: PluginUiLayoutSpec}
PluginUiLayoutSpectype{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.

ExportKindPurpose
PluginManifesttypeThe root manifest, both shapes
PluginWidgetSummarytypeOne widgets[] entry (0.11.0+)
PluginWidgetManifesttypeA sub-manifest — {slug, configSchema, uiSchema?} (0.11.0+)
ResolvedPluginWidgettypeThe normalized serving shape both manifest shapes resolve to
PluginLayoutSlottype{name, accepts?, prefill?} (0.11.0+, prefill 0.12.1+)
PluginPageKey / PLUGIN_PAGE_KEYStype / valueThe six page keys a layout may declare (0.12.0+)
isPluginPageKeyfn(value: unknown) => value is PluginPageKey
isMultiWidgetManifestfnThe single discriminator between the two shapes
resolvePluginWidgetfn(manifest, widgetSlug: string | null) => ResolvedPluginWidget | null
normalizeLayoutSlotsfn(slots: unknown) => PluginLayoutSlot[] | undefined
pluginLayoutServesPagefn(resolved, page) => boolean
isPluginMediaReffnmedia/<file> (no subdirectory) or an absolute http(s) URL
isPluginReadmeReffnmedia/<file>.md, at most one nested directory, no ..
isPluginFontReffnabsolute http(s), or a relative path with no leading /, no scheme:, no \, no ..
isSlotPrefillKeywordfn/^[a-z][a-z0-9-]{0,63}$/ — system keywords only
isSystemWidgetKeywordfnAlias of isSlotPrefillKeyword
MAX_SLOT_PREFILLconstPrefill entries kept per slot
MAX_PLUGIN_FONTSconstDeclared fonts kept per widget
MEDIA_REF_MAX_LENGTHconstMax characters in a media reference
resolvePluginMediaUrlfn(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

ExportKindNotes
WidgetProps<T, D>typeProps every content widget receives
LayoutWidgetProps<T, D>typeWidgetProps plus renderedSlots (0.11.0+)
WidgetCategorytype"content" | "layout"
GetInitialDataCtx<T>type{options, widgetId, feedId, widgetType, isSSR?, cookies?, headers?, query?}
GetAwaitedFuncReturnType<T>typeAwaited<ReturnType<T>>
FeedtypeThe public-api feed row
Widget<T>typeA widget row, incl. plugin_manifest and plugin_manifest_url
SlotBindingtype{widgetId, ovveride?, override?}
PageWidgetDef, PageSchematypePage-schema shapes
isLayoutEntryfn(entry: {widget: PageWidgetDef}) => boolean
EXPLORER_BACKED_KEYWORDSconst["explorer", "search-bar", "search-results"]
ExplorerBackedKeywordtypeUnion of the three
isExplorerBackedKeywordfnType 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

ExportSignatureNotes
PLUGIN_WIDGET_PREFIX"plugin:"
PluginKeywordPartstype{pluginSlug, widgetSlug: string | null, registryKey}
parsePluginKeyword(keyword: string) => PluginKeywordParts | nullregistryKey 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) => stringBuilds <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) => stringManifest URL first
distRelativeAssetName(urlOrPath: string) => stringKeeps the widget directory after dist/
extractAssetFilename(urlOrPath: string) => stringLast path segment, query and hash stripped

Two corrections worth stating outright, because both were previously documented wrong:

  • isAbsoluteUrl matches http:// and https:// and nothing else. A protocol-relative //cdn.example.com/x.js and a data: URI both count as relative and will be resolved against the manifest URL.
  • resolvePluginAssetUrl takes 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

ExportKindNotes
HRWidgetRuntimetypeThe shape of window.HRWidgetRuntime
HRPluginstypeRecord<string, {component, category?}>
HRWidgetRegistrationtype{component, mount?, category?}
getRuntimefn() => HRWidgetRuntime; throws when the runtime IIFE has not loaded
registerPluginfn(key: string, component: ComponentType<any>, meta?: {category}) => void
registerPluginWidgetfn(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 /runtime surface, which is how the evergreen externalization resolves), useQueries and keepPreviousData — 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

ExportSignatureAudience
mount(container: HTMLElement, config: MountConfig) => void — returns nothingauthor
MountConfigtype, eight fields (below)author
extractWidgetId(stringId: string) => {widgetId: string, feedId: number}author
ClientWrapperForWidgetcomponent — fetches feed + widget config, then render-props your widgetauthor
WidgetHydrationTreecomponent — {queryClient, portalContainer?, shadowHost?, children}platform-internal
getSharedQueryClient() => QueryClient — the window.HRWidget.__QUERY_CLIENT__ singletonauthor
// 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

ExportSignatureAudience
setupShadowDOM(hostEl: HTMLElement, cssUrls: string[]) => ShadowDOMSetupResult | nullauthor (mount calls it)
ShadowDOMSetupResulttype {shadowRoot, shadowHost, reactRoot, portalContainer, cssReady, cssPending}author
migrateSSRContent(hostEl: HTMLElement, reactRoot: HTMLDivElement) => voidmoves nodes, never re-serializesplatform-internal
getCSSUrlsFromElement(el: HTMLElement) => string[] — reads the legacy data-hr-css attributeplatform-internal
isShadowDOMSupported() => booleanauthor
PortalContainerProvider / usePortalContainercontext provider / () => HTMLElement | undefinedauthor
ShadowHostProvider / useShadowHostcontext provider / () => HTMLElement | nullauthor
useTopLevelPortal(options?: UseTopLevelPortalOptions) => HTMLElement | undefinedauthor
UseTopLevelPortalOptionstype {cssString?: string, enabled?: boolean}enabled defaults to trueauthor

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

ExportSignature
HOST_THEME_ATTR"data-hr-theme"
ResolvedHostThemetype "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

ExportSignatureAudience
registerWidget(type: string, registration: HRWidgetRegistration) => voidplatform-internal
getRegisteredWidget(type: string) => HRWidgetRegistration | undefinedauthor
whenWidgetRegistered(type: string) => Promise<HRWidgetRegistration> — rejects on a hard timeoutplatform-internal
HRWidgetRegistrationtype, re-exported from /globalsauthor
loadScriptOnce(src: string) => Promise<void> — deduped by srcauthor

Data and errors

ExportSignatureAudience
homerunnerApiBaseUrl() => stringauthor
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>) => Tauthor
WidgetFetchErrorclass (status: number, message: string), name === "WidgetFetchError"author
isRateLimitError(error: unknown) => boolean — status 429author
isPermanentError(error: unknown) => boolean — any 4xxauthor
retryTransient(max: number) => (failureCount, error) => booleanauthor
WidgetErrorStatecomponent {rateLimited, feedId?, widgetId?, notFoundProperty?, renderFailed?}author
WidgetRenderBoundaryclass 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

ExportSignatureAudience
LayoutCsrRendererdefault-exported componentplatform-internal (mount uses it)
LayoutCsrRendererPropstype {feedId, widgetId, LayoutComponent, widgetType, optionsOverride?, resolveChildJsUrl?, rootElement?}author

Query-state helpers

ExportKind
dedupeDehydratedState(state, opts?: {minSharedBytes?}) => MaybeCompactDehydratedState
inflateDehydratedState(state: MaybeCompactDehydratedState) => DehydratedState — throws on a dangling ref
isCompactDehydratedStatetype guard
HR_COMPACT_REF_KEY"$hr"
HR_COMPACT_VERSION1
MIN_SHARED_BYTES32
CompactDehydratedState / MaybeCompactDehydratedStatetypes

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() with category: "layout" — see layouts.md.

/utils

ExportSignature
useTranslation<K extends string>(store: Record<K, Record<string, string>>, currentLocale?: string) => {_t}
useLocaleDetection(useAutoDetectedLocale?: boolean, configuredLocale?: string) => {detectedLocale, ...helpers}
detectBrowserLocale() => string
getBestMatchingLocalelocale negotiation helper
findObjectByLocale / getObjectForCurrentLocale / getAvailableLocaleslocale-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 / useWidgetcontext 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

ExportSignature
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-widgetSuite (0.11.0+)
Client entry./src/index.tsx./src/widgets/{BUILD_WIDGET}/index.tsx
Client outputdist/{slug}.iife.jsdist/{widget}/{widget}.iife.js
CSS outputdist/{slug}.cssdist/{widget}/{widget}.css
SSR entry (BUILD_SSR=1)./src/ssr-entry.ts./src/widgets/{BUILD_WIDGET}/ssr-entry.ts
SSR outputdist/{slug}-ssr.umd.jsdist/{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, then BUILD_SSR=1 vite build.
  • Suite: validates every summary first, then rm -rf dist/, then runs the flattened (widget x pass) list with BUILD_WIDGET={slug}, skipping the SSR pass for a widget declaring ssr: false, and marks the last pass HR_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.

ExportSignature
startMockWorker(keyword: string, options?: MockOptions) => Promise<MockControl> — idempotent
createMockHandlers(keyword?: string, fixtures?: MockFixtures) returning MSW handlers — keyword defaults to plugin:dev-widget
MockControltype {worker, scenarios, getState, setScenario, setSettings, setDelay, setBranding, subscribe, stop}
MOCK_SCENARIOS["default", "empty", "single", "large", "loading", "error"]
MockScenario / MockState / MockOptions / MockFixtures / MockBrandingtypes
initMockState / getMockState / setMockState / subscribeMockStatethe 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) => numberempty 0, single 1, large 48, otherwise 6
DEFAULT_BRANDINGthe 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. startMockWorker is a page singleton: repeat calls return the existing control, and the single keyword you passed is baked into the handlers — along with one scenario, one latency, one branding and one settings object for the whole page. A suite works around the keyword half with a widget fixture factory keyed by the requested id (what --template suite ships); 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.

ExportSignature
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_MSconst — 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.

VersionWhat it added
0.9.0spacing replaces width on the base schema; parseWidgetConfig
0.10.0Evergreen runtime — /runtime externalized, runtime.widgetCore stamped; the publish floor
0.10.1shadowDOM manifest flag; the raw-options contract codified
0.11.0Suites, registerPluginWidget, hr-widget-build, layout widgets, presets, mediaSafeAuto, enforced externalFetch
0.12.0assets.fonts, bundled assets, layout pages, marketplace icon / cover
0.12.1Slot 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.

SymbolPreviously documentedActual
getFinalSettings(feed, widget, override?)(widget, feed, optionsOverride?)
resolvePluginAssetUrl(slug, urlOrPath, manifestOrigin?)(manifestUrl, urlOrPath); throws when relative with no base
isAbsoluteUrltrue for http://, https://, //, data:^https?:// only
DEFAULT_PROXY_BASE_URLhttps://dashboard.homerunner.iohttps://assets.homerunner.io
MountConfigfive fieldseight fields, incl. category, extraCssUrls, resolveChildJsUrl
UseTopLevelPortalOptions{cssString?}{cssString?, enabled?}
HRWidgetRuntime{React, ReactDOM, jsxRuntime, ReactQuery, …}React Query exports are flat; there is no ReactQuery key
HRPluginsRecord<string, {component}>Record<string, {component, category?}>
widgetSchemacarries widthcarries spacing (since 0.9.0)
PluginUiLayoutSpec{layout, label, fields}{layout?, label?, description?, showIf?} — no fields
PluginUiField.componenteleven tokenstwelve — WidgetSelect was missing
/spacing, /schema parse helpers, ~25 /runtime exportsabsentdocumented above