HomeRunner plugin development
A HomeRunner plugin is a React component, an optional server-render module, and a
manifest.json that tells the platform what you ship. You submit it as a source zip: you
host nothing, and you never build the artifact a customer loads. A HomeRunner reviewer reads
your source, runs your build, and publishes the result to the plugin CDN under an immutable
version prefix. Customers then install it per feed and configure it in a dashboard form
generated from your zod schema. From there your widget renders alongside first-party ones —
server-rendered into Declarative Shadow DOM where the request supports it, hydrated by the
platform’s shared runtime, or client-mounted on a CSR embed.
A plugin can carry a single widget or a whole suite of them under one version, one review and one publish — including layout widgets that host other widgets in named slots.
Start here
Building one widget? Read Install and versions,
then Your first widget. npx create-hr-plugin my-plugin
produces exactly this shape, and its dev sandbox and SSR preview both work out of the box.
Building a suite — several widgets, or a layout, or a one-click preset? Scaffold it
directly with npx create-hr-plugin my-suite --template suite (create-hr-plugin 0.8.0+),
which writes a working three-widget suite including a layout. Do the first-widget page anyway
(the component, schema and build steps are identical), then
Build a suite and
Add a layout widget.
Choose deliberately: the manifest shape is frozen at your first publish, and changing it afterwards needs a brand-new plugin slug. Converting is free until then — but only until then. The two manifest shapes explains the decision and its cost.
How this book is organised
- Get started — a working toolchain and a widget on screen: Install and versions → Your first widget.
- Concepts — the mental model, in prose: what the files are (Anatomy of a plugin), which shape you picked, how a render actually happens (How a widget renders), where settings come from (Settings and options), and who does what between your laptop and a customer page (Lifecycle).
- Contracts — normative reference: every field, rule and error string, one owner per fact. Start at Manifest: root fields; every hard number and every message the platform can show you is indexed in Limits and error index.
- Recipes — task-shaped guides: Styling and theming, Fetching data, Portals and dialogs, Composing a page.
- Local development — drive every state offline with the dev sandbox, and verify server rendering with SSR preview.
- Ship — Preflight and submit, then Publishing, versions and rollback and Install, customers and kill switches.
- When something breaks — Troubleshooting is organised by symptom; unfamiliar words live in the Glossary.
At a glance
# derived from packages/create-hr-plugin/bin/create-hr-plugin.js and both templates' package.json
# non-interactive, argv only: [--template <single|suite>] [--author <name>] [--no-install]
npx create-hr-plugin my-plugin # one widget (the default)
npx create-hr-plugin my-suite --template suite # several widgets + a layout
cd my-plugin
npm run dev # Vite dev sandbox on http://localhost:3001, MSW-mocked by default
npm run build # = npm run build:manifest && hr-widget-build
npm run dev:ssr # SSR preview on http://localhost:3003 — run a build first.
# A suite has no default widget, so name one:
# npm run dev:ssr -- --widget intro-card
npm run preview # = npm run build && node serve.js → http://localhost:3002
npm run build is two steps, not the three older versions of this guide described: your
project’s scripts/inject-schema.mjs writes the config and UI schema into the manifest (or into
per-widget sub-manifests), then the SDK’s hr-widget-build bin drives every Vite pass, hashes
dist/, writes dist/manifest.json and stamps runtime.widgetCore. It rewrites tracked files,
so expect manifest.json to be dirty in git afterwards — commit it.
npm run preview (create-hr-plugin 0.8.0+) serves dist/, /manifest.json, the /w/ hashed
redirect and the /p/ asset-proxy path, and the page boots the built bundles against the
real runtime IIFE. It runs with no mocks, so point its host ids at a real widget and feed first
— Your first widget has the details.
SDK versions
You import from one package, @homerunner-next/widget-core. The build stamps the version it
used into runtime.widgetCore, and that stamp is what the publish gate reads — never write it
by hand.
| widget-core | What it added |
|---|---|
| 0.10.0 | Evergreen runtime (/runtime externalized from your bundle). The publish floor. |
| 0.11.0 | Suites, registerPluginWidget, the hr-widget-build bin, layout widgets, presets. |
| 0.12.0 | assets.fonts, bundled assets, layout pages, marketplace icon and cover. |
| 0.12.1 | Slot prefill. |
| 0.12.2 | createSSRDevServer previews one named widget of a suite (--widget {slug}). |
| 0.12.3 | The SSR preview links a stylesheet for a widget that exports no getStaticAssets. |
This book documents widget-core 0.12.3 and create-hr-plugin 0.8.1, whose templates pin
^0.12.1 (--template single) and ^0.12.3 (--template suite).
Both are on npm as of 2026-09-08 — npx create-hr-plugin <slug> --template suite works straight
from the registry. Install and versions owns the version
matrix and what each release added.
Install and versions
Get a toolchain that works, then find out exactly which SDK you ended up with and what it
can do. This page is the book’s normative home for two things: the runtime version pins
your package.json must carry, and which SDK version each feature needs — see
What each SDK version gives you before you decide whether
to build a single widget or a suite, because that choice is frozen at your first publish.
Node
| Version | Why | |
|---|---|---|
| Hard floor | 20.19.0 (or 22.12.0+) | Vite 7 declares engines: ^20.19.0 || >=22.12.0, and the scaffold pins vite: ^7.3.1. |
| Feature floor | 20.12.0 | process.loadEnvFile landed in 20.12 (and 21.7). npm run dev:ssr uses it. |
| Recommended | 22.12+ LTS | Satisfies both, and matches the Node this SDK is developed on. |
The 20.12 floor is the one that bites, because it fails silently. The SSR preview
harness loads .env.local and .env itself — Vite is not involved — and swallows any
failure:
// packages/create-hr-plugin/template/scripts/dev-ssr.mts:26-32 (verbatim)
// — byte-identical at template-suite/scripts/dev-ssr.mts:41-47
for (const file of [".env.local", ".env"]) {
try {
process.loadEnvFile(file);
} catch {
// file absent — fine
}
}
On Node 20.0–20.11 process.loadEnvFile is not a function, the TypeError lands in that
catch, and the script carries on with no env loaded at all — HR_SSR_HYDRATE,
HR_RUNTIME_URL, PORT and your NEXT_PUBLIC_* keys are simply absent and every default
silently applies. You get a working-looking server pointed at the wrong places
(../local-dev/ssr-preview.md covers those keys). Nothing
warns you either: the template declares no engines field, so npm install prints no
EBADENGINE for the 20.12 requirement. Enforcement level: advisory (unvalidated) — see
the legend in ../contracts/limits-and-errors.md.
Scaffold a project
# packages/create-hr-plugin/bin/create-hr-plugin.js:26-82 — the whole argv surface
npx create-hr-plugin acme-weather --author "Acme Corp" # one widget (the default)
npx create-hr-plugin acme-suite --template suite # several widgets + a layout
cd acme-weather
The CLI is entirely non-interactive. It asks nothing; everything comes from argv.
--help, -h, or no arguments at all prints this and exits 0:
# packages/create-hr-plugin/bin/create-hr-plugin.js:29-47 — the help text, verbatim
Usage: create-hr-plugin <plugin-name> [options]
Creates a HomeRunner widget plugin project (vm.Script SSR + IIFE client).
Arguments:
plugin-name Plugin slug — lowercase letters, digits and hyphens
(e.g. "acme-weather"). Becomes the plugin's manifest id.
Options:
--template <t> Project shape: "single" (one widget, default) or
"suite" (several widgets + a layout, one publish).
--template=<t> works too.
--author <name> Author name (default: from git config)
--no-install Skip npm install
--help, -h Show this help
Examples:
npx create-hr-plugin acme-weather --author "Acme Corp"
npx create-hr-plugin acme-suite --template suite
| Behaviour | Detail |
|---|---|
| Slug | argv[0], always. Put flags after the slug — a first argument starting with - is refused rather than turned into a directory name. |
| Slug validation (0.8.1+) | Checked before any file is written, against the publish audit’s own rule /^[a-z0-9][a-z0-9-]*$/ plus the reserved names dist, widgets, media. Earlier versions accepted anything and you found out at publish, with the project already built and committed. The regex belongs to manifest.id — ../contracts/manifest-root.md. |
--template <single|suite> (0.8.0+) | Project shape; default single. --template=suite works too (0.8.1+ — 0.8.0 parsed only the space-separated form and silently scaffolded single, which matters because the shape is frozen at first publish). Anything else, including --template with nothing usable after it, exits 1. |
--author <name> | Falls back to git config user.name. If that command fails (git absent, or user.name unset) the author becomes the literal string Plugin Author. A dangling --author with no value falls back the same way. |
--no-install | Skips npm install. Without it, a failed install is not fatal: the scaffold prints a warning and still exits 0. |
| Target exists | Hard error, exit 1. |
| Copy mechanism | Every template file is read as UTF-8 text, token-replaced and rewritten. Nothing in either template directory may be binary. |
_gitignore | Renamed to .gitignore after the copy (npm strips .gitignore from published packages). |
Every failure message the scaffolder can print is quoted verbatim in ../contracts/limits-and-errors.md.
--template single writes the single-widget project; --template suite writes a
three-widget suite with a layout, a preset and a media/ folder. Both shapes run
npm run dev, npm run dev:ssr, npm run build and npm run preview. See
../concepts/manifest-shapes.md for the choice you are
making — it is frozen at your first publish — then
02-your-first-widget.md or
03-build-a-suite.md to run it.
The React-family pins
Pin these exactly, with no caret, in your plugin’s package.json:
| Package | Pin |
|---|---|
react | 19.2.5 |
react-dom | 19.2.5 |
@tanstack/react-query | 5.95.2 |
Shape tag: Both. Pin only the ones you actually import — the real suite pdp-suite
declares no React Query dependency at all. @types/react and @types/react-dom are
caret-ranged (^19.1.3); only the runtime packages are exact.
Your client bundle does not ship React. viteHomerunnerWidget externalises react,
react/jsx-runtime, react-dom and react-dom/client against the host page’s runtime
IIFE, and the SSR sandbox hands your server bundle the renderer’s own copies. So the React
you compile against and the React you execute against are different installs, and they must
agree to the patch.
# pnpm-workspace.yaml:13-16 — the platform's own rationale, verbatim
# Why exact pins for react/react-dom: plugin client umds externalize react/react-dom/* against
# `window.HRWidgetRuntime` (= the runtime IIFE). Even patch drift between the renderer's
# server-side React and a plugin's bundled react-dom internals will trip the
# `Incompatible React versions` runtime check.
A caret is enough to break this. "react": "^19.2.5" resolves to whatever 19.x is newest
the day you install, and react-dom asserts the two match exactly:
# node_modules/react-dom/cjs/react-dom-client.development.js:27934-27938
Incompatible React versions: The "react" and "react-dom" packages must have the exact same
version.
Two traps in that message. It is a development-build assertion, so a mismatch can pass a
production build on your machine and misbehave on a customer page instead of throwing. And
enforcement is advisory (unvalidated) — nothing at submit, review or publish reads your
package.json, so a drifted pin ships.
What each SDK version gives you
runtime.widgetCore in your built manifest.json records the version you actually built
with. The build writes it; never hand-write it — see
../contracts/manifest-root.md for the field and
../contracts/packaging-and-publishing.md for the
gate it feeds.
| widget-core | What it added | On npm today? |
|---|---|---|
| 0.8.0 | The /mock dev-mocking subpath and the /testing SSR harness (renderPluginSSR, createNodeMockServer, createSSRDevServer) | yes (superseded) |
| 0.9.0 | spacing replaces width on the base schema; parseWidgetConfig | no (never published) |
| 0.10.0 | Evergreen runtime — /runtime externalised, runtime.widgetCore stamped. The publish floor. | yes |
| 0.10.1 | The shadowDOM manifest flag; the raw-options contract | yes |
| 0.11.0 | Suites (widgets[]), registerPluginWidget, the hr-widget-build bin, layout widgets, presets, mediaSafeAuto, enforced externalFetch | yes |
| 0.12.0 | assets.fonts, bundled assets, layout pages, marketplace icon / cover | yes |
| 0.12.1 | Slot prefill | yes |
| 0.12.2 | createSSRDevServer takes a widget option (--widget {slug} / HR_SSR_WIDGET), so npm run dev:ssr previews one named widget of a suite; resolvePluginSSRTarget and widgetFromInvocation exported; the harness’s sandbox moved closer to the renderer’s | yes |
| 0.12.3 | The SSR preview falls back to the widget’s manifest assets.css when the bundle exports no getStaticAssets — the shape --template suite scaffolds, which previewed unstyled before this | yes — latest |
create-hr-plugin tracks it:
| create-hr-plugin | What it added | On npm today? |
|---|---|---|
| 0.6.0 | The template that pins widget-core ^0.10.0 and builds in three steps | yes — latest |
| 0.7.0 | npm run build delegates to hr-widget-build; template pins ^0.11.0 | no |
| 0.8.0 | --template suite; npm run preview renders the built widget; public/mockServiceWorker.js no longer ships in dist/; both templates pin ^0.12.1 | no |
| 0.8.1 | --template=suite (equals form); slug validation before any file is written; npm run dev:ssr in the suite template (which moves its pin to ^0.12.3) | no |
Version-gate notes appear inline throughout the book as (0.11.0+). The full export inventory for 0.12.2 is ../contracts/sdk-exports.md.
Versions on npm
Both packages are published and current (checked 2026-09-08):
npm latest | |
|---|---|
@homerunner-next/widget-core | 0.12.3 |
create-hr-plugin | 0.8.1 |
So npx create-hr-plugin <slug> and npx create-hr-plugin <slug> --template suite both work
straight from the registry, and every feature in the table above is installable. Nothing in this
book needs a tarball or a private build.
Two things are still worth checking rather than assuming:
- Which version you actually ran.
npxcaches. If a scaffold comes out single-widget after you asked for a suite, you ran a pre-0.8.0 CLI, which had no--templateflag and silently ignored the argument — and the manifest shape is frozen at your first publish. Confirm withnpx create-hr-plugin --help(it lists--templatefrom 0.8.0) ornpx create-hr-plugin@latest. Older CLIs also had no slug validation, so an invalidmanifest.idwent unreported until the publish audit. - What an existing project pins. A project scaffolded before 0.8.0 pins an older widget-core
and still uses the three-step
build:manifest && build:iife && build:ssrbuild. Suites need 0.11.0+, and a suite’snpm run dev:ssrneeds 0.12.3 — on 0.12.2 it previews unstyled, because the scaffolded widgets export nogetStaticAssets. Bump the pin and switchbuildtobuild:manifest && hr-widget-buildbefore following 03-build-a-suite.md.
Do not start a plugin as single-widget “for now” if you intend to ship a suite. The manifest shape is frozen at your first publish and changing it needs a brand-new plugin slug (../concepts/manifest-shapes.md). Converting is free before you publish and impossible after.
Check what you actually have
# run these in your plugin directory before you trust any version-gated instruction
node -v # want 20.19+ or 22.12+
npm ls @homerunner-next/widget-core react react-dom
ls node_modules/.bin/hr-widget-build # absent => widget-core < 0.11.0
npm view @homerunner-next/widget-core dist-tags # what the registry serves right now
After a build, the authoritative answer is in your own manifest: runtime.widgetCore is the
exact version the build ran with. Symptoms that trace back to a version mismatch are indexed
in ../troubleshooting.md.
Your first widget
Twenty minutes, end to end: scaffold a project, run it against mocked data, change its settings schema and its component, build it, and read what the build produced.
This tutorial produces a single-widget plugin — --template single, the CLI’s default.
The other shape, --template suite, is scaffolded the same way and covered in
03-build-a-suite.md; the component, schema and build steps below are
identical in both. The choice is frozen at your first publish — the last section says what
to do about that. This page also assumes a working toolchain; get one from
01-install-and-versions.md, which owns the Node floor, the React
pins and the dated note on what npm serves versus what the template pins.
1. Scaffold
# packages/create-hr-plugin/bin/create-hr-plugin.js — the real flags
# --template defaults to "single"; pass it explicitly if you like being explicit
npx create-hr-plugin acme-weather --author "Acme Corp"
cd acme-weather
The CLI never prompts; the full argv surface and every failure mode are in
01-install-and-versions.md. What matters here is what it derives
from the slug: the display name (Acme Weather), the component name (AcmeWeatherWidget), and
the keyword your widget is addressed by everywhere — plugin:acme-weather. The manifest’s id
is the slug; nothing re-slugifies it later.
The tree
acme-weather/
├── manifest.json # plugin identity + the v1 widget fields; the build rewrites it
├── package.json # scripts, and exact react / react-dom / react-query pins
├── vite.config.ts # viteHomerunnerWidget({ slug, command }) + your overrides
├── tsconfig.json # include is ["src/**/*", "vite.config.ts"] only
├── index.html # DEV sandbox page — not a production artifact
├── serve.js # zero-dep node:http static server (npm run serve / preview)
├── vercel.json + api/ # optional self-hosted demo shim; never needed to publish
├── mocks/fixtures.ts # mock data for npm run dev AND npm run dev:ssr
├── public/ # holds the MSW service worker
├── scripts/ # inject-schema.mjs (build:manifest), dev-ssr.mts (dev:ssr)
└── src/
├── index.tsx # client IIFE entry — registerPlugin() + mount()
├── widget.tsx # your React component (and getInitialData)
├── ssr.ts # dehydrateState + getStaticAssets
├── ssr-entry.ts # SSR bundle entry — re-exports widget.tsx + ssr.ts
├── config.ts # zod schema → manifest configSchema + uiSchema
├── widget.css # Tailwind entry + the .hr-widget-section styles
├── data.ts # sample public-api fetcher + a query-key factory
├── components/ui/ # shadcn-style: you own these, edit them freely
└── dev/ # dev sandbox entry + control bar — never published
Which of those are yours, which the build generates, and which never leave your laptop is laid out in ../concepts/anatomy-of-a-plugin.md.
2. Run the dev sandbox
npm run dev # http://localhost:3001
npm run dev is mocked by default. src/dev/main.tsx starts an MSW worker bound to
plugin:acme-weather, seeds the widget’s settings with configZod.parse({}) so every
options.<field> read is defined, turns React Query retries off so the error scenario shows
its error state instead of a long spinner, mounts every matching container, and renders a
control bar along the bottom for scenario, theme, latency and a live settings JSON editor.
?scenario=empty|single|large|loading|error picks the fixture shape at boot and ?mock=off
disables the worker. .env.local is optional — it is consulted only on the ?mock=off
path (and by npm run dev:ssr), so you never need a live HomeRunner feed to build the widget.
The full sandbox contract is in
../local-dev/dev-sandbox-and-mocking.md.
3. Add a setting
src/config.ts is the whole settings surface. You extend the shared base schema, and you
export a JSON Schema built from it:
// packages/create-hr-plugin/template/src/config.ts — the scaffold, with one field added
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
zodToManifestSchema,
type PluginUiSchema,
} from "@homerunner-next/widget-core/plugin-schema";
export const configZod = widgetSchema.extend({
title: z.string().default("Acme Weather").describe("Widget title"),
units: z.enum(["metric", "imperial"]).default("metric").describe("Units"),
});
/** Symbolic UI for plugin-specific fields only. */
export const uiSchema: PluginUiSchema = {
title: { component: "TextInput" },
units: {
component: "Select",
optionLabels: { metric: "Celsius", imperial: "Fahrenheit" },
},
};
/** JSON Schema shipped in `manifest.configSchema`. */
export const configSchema = zodToManifestSchema(configZod);
export type PluginConfig = z.infer<typeof configZod>;
Three things are doing real work. widgetSchema brings in the base fields every widget shares
— spacing, colour scheme, fonts, section header, custom CSS and the rest — so the dashboard
renders those controls without you declaring anything. .default(...) and .describe(...) on
your own fields are what make the customer’s form open clean and self-explaining. And
uiSchema names only your fields: a plugin entry replaces a top-level key of the base UI
schema wholesale, so re-declaring a base key destroys that panel.
The base field list, the component vocabulary, and which zod constructs survive the round trip
to JSON Schema and back (versus which ones take the whole config panel down) are in
../contracts/config-and-ui-schema.md. Read the
round-trip tables before you reach for a union, a tuple or z.any().
4. Edit the component
src/widget.tsx is the component the browser and the server both render. The scaffold already
shows the two calls that are not optional:
// packages/create-hr-plugin/template/src/widget.tsx — the scaffold's real component,
// with <WidgetSection>, the new `units` setting, and the imported prop type
import React from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";
import type { WidgetProps } from "@homerunner-next/widget-core/contracts";
import { configZod, type PluginConfig } from "./config";
import { cn, WidgetSection } from "./components/ui";
import "./widget.css";
type WeatherData = { message?: string };
export default function AcmeWeatherWidget(
props: WidgetProps<PluginConfig, WeatherData>,
) {
// `props.options` is the RAW stored settings blob on BOTH server and client:
// parseWidgetConfig strips the dashboard's null/'' save artifacts, coerces
// stringified scalars and applies your defaults. Never read it directly.
const options = React.useMemo(
() => parseWidgetConfig(configZod, props.options) as PluginConfig,
[props.options],
);
const isDark = props.resolvedTheme === "dark";
return (
// The base schema's `spacing` renders on the outer element; the helper does
// its own defaulting and repair, so raw or parsed options both work.
<div style={resolveWidgetSpacingStyle(options)}>
<WidgetSection show={options.section.show} title={options.section.title} />
<div
className={cn(
"rounded-lg border p-4 font-sans",
isDark ? "border-zinc-700 bg-zinc-900" : "border-zinc-200 bg-white",
)}
>
<h3 className="mb-2 text-lg font-semibold">{options.title}</h3>
<p className="text-sm text-zinc-500">
{props.data?.message ?? "Loading…"} ({options.units})
</p>
</div>
</div>
);
}
WidgetSection comes from src/components/ui/ — shadcn-style components the scaffold copies
into your project, not a package import. Its real props are {show, title, className}; it
renders the heading only, is not a wrapper, takes no options, and returns null when show
is false or title is empty.
props.options arriving raw is the rule that catches most first-time authors, and it applies
to getInitialData and dehydrateState in src/ssr.ts too — parse there as well, or your
server-side query keys will not match the ones your hydrated component builds.
Two corrections to the scaffold, both worth making now. The template declares its own
WidgetProps interface inline; import the real one from
@homerunner-next/widget-core/contracts instead, because the inline copy omits fields. And
props.resolvedTheme is fine for a class swap after mount but is not something to key first
paint on — dark at first paint has to come from CSS. See
../contracts/component-and-ssr-module.md for the
full prop contract and the SSR module, and
../recipes/styling-and-theming.md for theming.
Save with npm run dev running: Vite hot-reloads the component, and the sandbox’s settings
editor pushes new options at the mounted widget without a rebuild.
5. Build
npm run build
build is npm run build:manifest && hr-widget-build — two steps, not the three-step
build:manifest → build:iife → build:ssr chain older documentation describes:
| Step | Script | What it does |
|---|---|---|
| 1 | build:manifest (tsx scripts/inject-schema.mjs) | Imports src/config.ts, writes configSchema and uiSchema into the tracked manifest.json, and logs manifest.json updated — configSchema: N properties, uiSchema: M field specs. |
| 2 | hr-widget-build | widget-core’s bin. For a single-widget project it runs two Vite passes, labelled iife build and ssr build. |
build:iife and build:ssr still exist in package.json, but build no longer calls them —
they are single-widget escape hatches. On a suite, a bare vite build refuses to run at all.
(hr-widget-build needs widget-core 0.11.0+.)
Expect manifest.json to be dirty in git after every build, and commit it. The build
rewrites that tracked file twice: build:manifest injects the schemas, and the final Vite pass
stamps runtime.widgetCore with the SDK version it built against. Never hand-write that stamp
— but do commit it, because the publish audit gates on it. See
../contracts/packaging-and-publishing.md.
Read dist/
# a real built single-widget plugin (hr-plugins/featured-stays); .map files omitted here
dist/
├── featured-stays.iife.js # client bundle
├── featured-stays.iife-cb11ab13.js # its content-hashed twin
├── featured-stays.css
├── featured-stays-d4188b32.css
├── featured-stays-ssr.umd.js # SSR bundle — CommonJS despite the name
├── featured-stays-ssr.umd-122b8a33.js
├── manifest.json # the BUILD manifest: {buildHash, generatedAt, files}
├── mockServiceWorker.js # built by an OLD scaffold — see the note below
└── mockServiceWorker-7b2c8364.js
Three files are the plugin: the client IIFE named in assets.js, the stylesheet in
assets.css, and the SSR bundle in ssr.url. The hashed twin beside each is what a customer
page actually loads, and dist/manifest.json is the plain-name → hashed-name map the renderer
and the asset proxy read (source maps are excluded from it but still ship). Filenames are fixed
by the SDK; renaming an output is a publish error. All normative in
../contracts/assets-and-urls.md.
Your own dist/ will not have those last two files. public/mockServiceWorker.js — the
dev-only MSW worker — used to be copied into dist/ by Vite on every production build, hashed
and recorded in the build manifest. (create-hr-plugin 0.8.0+) both templates’ vite.config.ts
pass publicDir: command === "build" ? false : "public", so the worker stays out of the build
while npm run dev is untouched. featured-stays was built before that fix; if your scaffold
predates 0.8.0, make the same one-line change.
6. Preview the server render
npm run build # dev:ssr reads dist/, so build first
npm run dev:ssr # http://localhost:3003 → redirects to /preview?scenario=default
scripts/dev-ssr.mts loads .env.local then .env, then runs your built SSR bundle through
the same node:vm contract the production renderer uses, against the same mocks, with a
scenario tab strip and an inspector showing the server HTML, the props and the dehydrated query
cache. HR_SSR_HYDRATE=1 additionally runs real client hydration and surfaces mismatch
warnings. Without a prior build it tells you the SSR bundle is missing.
It is close to production, not identical. What still differs is the environment around the sandbox — the envelope, the asset base, the pre-parsed settings — and each difference is enumerated in Where the preview differs from production.
npm run preview — the built bundles, no mocks
npm run preview is npm run build && node serve.js. serve.js is a zero-dependency
node:http server on port 3002 (not Express, and not port 3000) that serves
/manifest.json and /dist/* with Access-Control-Allow-Origin: *, answers /w/{file} with
a 302 to the hashed twin, and answers /p/{slug}/{file} from dist/ — the plugin-asset proxy
path your built IIFE resolves its stylesheet through.
(create-hr-plugin 0.8.0+) the page renders. index.html still loads the Vite dev entry
/src/dev/main.tsx, which does not exist under serve.js; its onerror boots the preview
path instead — the runtime IIFE first (your built bundle externalises React, React Query and
widget-core against window.HRWidgetRuntime), then proxyBaseUrl pointed at the local server,
then /dist/acme-weather.iife.js. Earlier scaffolds had no such fallback and rendered an empty
mount div.
There are no mocks on this path — it talks to whichever Central the runtime IIFE points at.
Before you use it, change the host div’s id="{widgetId}:{feedId}" in index.html to a real
pair, and point HR_RUNTIME_URL at the environment you are testing against. For mocked visual
work stay in npm run dev; for the server path use npm run dev:ssr.
curl -s localhost:3002/manifest.json | head -20
curl -sI localhost:3002/w/acme-weather.iife.js # expect 302 → /dist/<hashed>
Next: choose your shape, while it is still free
You now have a widget that renders, a settings form the dashboard can draw, an SSR bundle and a
built dist/. You have not created media/ (icons, cover, README — authored content nothing
generates) and you have not submitted anything; both belong to
../ship/preflight-and-submit.md.
One decision is still open, and only until your first publish. A plugin is either the single-widget shape you just built or the modern suite shape — one plugin, many widgets, one version, one review, one publish. A suite may hold a single widget, so “suite” is not the same as “big”. That shape is frozen at first publish: flipping it later passes submit and even passes review, then dies at publish with a shape-change rejection, and the only remedy is a brand-new slug — losing every stored keyword and every embed URL a customer has copied. Today it costs you a re-scaffold, or a file move.
- Ever going to ship a second widget, a layout or a preset? Start over with
npx create-hr-plugin <slug> --template suite, or convert the project you just built — both routes are in 03-build-a-suite.md. - Want the decision spelled out with its costs on both sides: ../concepts/manifest-shapes.md.
Build a suite
A suite is one plugin that ships several widgets under one slug, one version, one review
and one publish. The root manifest carries a widgets[] array instead of a single widget’s
fields, each widget lives in its own folder, and each one gets a namespaced keyword
plugin:{plugin}:{widget} instead of the bare plugin:{plugin}. Requires
@homerunner-next/widget-core 0.11.0+ — see
Install and versions.
There are two ways in, and the first is the one you want for a new plugin:
- Scaffold one —
npx create-hr-plugin <slug> --template suite(create-hr-plugin 0.8.0+). The next section. - Convert the single-widget project from Your first widget. The rest of the page — and also the field-by-field tour of what the scaffold wrote, if you took route 1.
The shape is frozen at your first publish — converting afterwards needs a new plugin slug — so do this before you submit, not after. See The two manifest shapes.
Scaffold one
# packages/create-hr-plugin/bin/create-hr-plugin.js — --template, 0.8.0+
npx create-hr-plugin acme-suite --template suite # --template=suite works too (0.8.1+)
cd acme-suite
You get a suite that builds, publishes and passes both audits with zero errors and zero warnings as generated. It ships three widgets, one per surface you have to know about:
| Widget | Slug | Category | SSR | Why it is there |
|---|---|---|---|---|
| Page Frame | page-frame | layout | required | Declares slots with prefill, renders renderedSlots, declares pages |
| Intro Card | intro-card | content | yes | The ordinary server-rendered content widget |
| Quick Facts | quick-facts | content | no | CSR-only: "ssr": false, an expectedHeight, and deliberately no ssr-entry.ts |
# packages/create-hr-plugin/template-suite/ — what `--template suite` writes
acme-suite/
├── manifest.json # widgets[] summaries + presets[] — the suite root
├── package.json · tsconfig.json · vite.config.ts · .env.example · .gitignore
├── scripts/
│ ├── inject-schema.mjs # build:manifest — config.ts → widgets/{slug}.manifest.json
│ └── dev-ssr.mts # npm run dev:ssr -- --widget {slug}
├── media/ # AUTHORED: icon.svg, {slug}.svg, README.md, widgets/{slug}.md
├── index.html · src/dev/ · mocks/fixtures.ts · public/ # dev sandbox — never published
├── serve.js · vercel.json # static hosting for the built output
└── src/
├── shared/property.ts # code >1 widget uses, imported relatively
└── widgets/
├── page-frame/ # config.ts · widget.tsx · index.tsx · page-frame.css
│ └── ssr-entry.ts # present because the summary declares ssr.url
├── intro-card/ # …the same five files
│ └── ssr-entry.ts
└── quick-facts/ # …NO ssr-entry.ts — the summary says "ssr": false
widgets/{slug}.manifest.json is missing from that list because nothing has generated it yet;
your first npm run build writes it, and you commit the result — step 4.
Two things this template deliberately does not ship, both of which the single-widget
template does: a src/components/ui/ kit, and Tailwind. A layout’s stylesheet is loaded
document-level and unisolated on a server-rendered page, so a CSS framework’s global reset
would land on the customer’s whole document — see
Styling and theming.
npm run dev # :3001 — all three widgets, MSW-mocked
npm run build # build:manifest && hr-widget-build
npm run dev:ssr -- --widget intro-card # :3003 — ONE widget's built SSR bundle
npm run preview # build + serve.js on :3002, no mocks
npm run dev mounts all three: each widget’s own src/widgets/{slug}/index.tsx registers and
auto-mounts it exactly as its built IIFE does, and the layout’s mocked settings carry slots,
so page-frame resolves intro-card and quick-facts as real slot children through the
client composition path. That works only because mocks/fixtures.ts overrides /widget/{id}
with a factory answering each dev id with its own keyword — the mock worker’s built-in
route answers every id with one keyword, and a layout would then refuse to render its own
children. Full sandbox contract:
Dev sandbox and mocking.
npm run dev:ssr (widget-core 0.12.2+, and create-hr-plugin 0.8.1+ for the script that
drives it) has no default widget in a suite, so name one:
--widget intro-card, --widget=intro-card, or HR_SSR_WIDGET=intro-card in .env.local.
It renders dist/{widget}/{widget}-ssr.umd.js under plugin:{slug}:{widget} and seeds the
mocked settings from that widget’s own configZod.parse({}). Naming nothing, naming a widget
the manifest does not declare, or naming the CSR-only quick-facts exits 1 with a message
listing the widgets that do work — the harness, its scenarios and its divergences from the
renderer are in Previewing SSR locally.
The scaffold’s page-frame is a complete, working layout; Add a layout
widget walks the same five files against the published reference suite so
you can see which parts are layout-specific.
Or convert a single-widget project
The rest of this page converts the project from Your first widget
into a suite. If you scaffolded one instead, read it as a tour of what --template suite
already wrote — and as the procedure for adding a fourth widget.
What already understands a suite
Three parts of the scaffold branch on widgets[] by themselves and need no edits at all:
| File | Why it needs no change |
|---|---|
vite.config.ts | It never names an entry. viteHomerunnerWidget({ slug, command }) reads the entry from the environment. |
scripts/inject-schema.mjs | It already has a multi-widget branch — step 4 below. |
tsconfig.json | include is ["src/**/*", "vite.config.ts"], which covers src/widgets/**. |
There is no rollup input map anywhere in a suite build. Multi-entry is driven entirely by
two environment variables that the hr-widget-build CLI sets per pass:
// packages/homerunner-widget-core/src/vite/index.ts — build.lib.entry, abridged
entry: buildWidget // buildWidget = process.env.BUILD_WIDGET
? isSSR // isSSR = process.env.BUILD_SSR === "1"
? `./src/widgets/${buildWidget}/ssr-entry.ts`
: `./src/widgets/${buildWidget}/index.tsx`
: isSSR
? "./src/ssr-entry.ts"
: "./src/index.tsx",
fileName and the CSS asset name branch the same way, which is what nests the output under
dist/{widget}/. With BUILD_WIDGET unset the config still resolves the flat entries, so a
bare vite build on a suite would emit a plausible-looking, entirely broken dist/ —
viteHomerunnerWidget reads your root manifest at config time and throws instead. The exact
message is in Limits and error index. Always build with
npm run build.
The conversion at a glance
| Path | Fate |
|---|---|
src/index.tsx, src/widget.tsx, src/config.ts, src/widget.css | move into src/widgets/{slug}/ |
src/ssr-entry.ts | replace with a per-widget one, and only for widgets that server-render |
src/ssr.ts | move into the widget folder, or delete — suite widgets rarely need it |
src/data.ts, src/components/ui/ | keep where they are; import them relatively |
manifest.json | rewrite (step 1) |
index.html, src/dev/, mocks/fixtures.ts | replace with the suite-shaped versions from --template suite, or delete — the single-widget harness statically imports the flat ../widget and ../config and hardcodes one keyword |
public/ | keep only if you keep the sandbox — it holds the MSW worker |
vercel.json | replace with the suite template’s — the single-widget one rewrites /w/:path* to /api/widget-asset, and you are about to delete that function. The suite’s simply drops the rewrite |
serve.js | keep — the two templates’ routing is byte-identical, and it already handles nested paths: /p/{slug}/{file} matches greedily, so /p/acme/hero/hero.css resolves to dist/hero/hero.css, and /w/ keys straight off dist/manifest.json, whose entries are already nested. The suite copy only rewords the comments and the startup log |
api/widget-asset.ts | delete — a flat /w/ shim, and a demo-only one |
package.json scripts build:iife, build:ssr | delete — they are the bare vite build a suite refuses |
package.json scripts dev:ssr, serve, preview | keep, if you took the harness with them |
vite.config.ts, scripts/inject-schema.mjs, tsconfig.json | no change |
media/ | create it by hand if you want icons, covers or READMEs |
A scaffolded suite’s package.json keeps six scripts: dev (vite), dev:ssr
(tsx scripts/dev-ssr.mts), build (npm run build:manifest && hr-widget-build),
build:manifest (tsx scripts/inject-schema.mjs), serve and preview. The published
reference suite keeps only the first, third and fourth — a suite needs no sandbox to be
publishable. --template suite pins @homerunner-next/widget-core ^0.12.2 (its
npm run dev:ssr needs the 0.12.2 harness); --template single pins ^0.12.1. A converted
project on ^0.11.0 builds and publishes, but has no assets.fonts, bundled assets,
marketplace icons, layout pages or slot prefill. What the registry actually serves is in
Install and versions.
1. Rewrite the manifest
Delete the v1 root fields — widgetType, ssr, assets, and the configSchema / uiSchema
pair your last build injected — and add widgets[]. Nothing else at the root changes.
// The scaffolded plugin rewritten as a two-widget suite. Identity fields (id, version,
// name, description, author) carry over unchanged from the template's manifest.json;
// summary shapes follow the published reference suite's manifest.
{
"id": "acme-weather",
"version": "1.0.0",
"name": "Acme Weather",
"author": { "name": "Plugin Author" },
"runtime": { "react": "^19.0.0" }, // build stamps "widgetCore" — never hand-write it
"widgets": [
{
"slug": "forecast",
"name": "Forecast",
"ssr": { "url": "dist/forecast/forecast-ssr.umd.js" },
"assets": { "js": "dist/forecast/forecast.iife.js",
"css": "dist/forecast/forecast.css" },
"manifest": "widgets/forecast.manifest.json" // written by build:manifest
},
{
"slug": "snapshot",
"name": "Snapshot",
"ssr": false, // CSR-only — see step 3
"assets": { "js": "dist/snapshot/snapshot.iife.js",
"css": "dist/snapshot/snapshot.css" },
"expectedHeight": 140,
"manifest": "widgets/snapshot.manifest.json"
}
]
}
Point assets and ssr.url at the logical filenames the build emits — never at a hashed
twin. The manifest references are filled in for you by build:manifest.
Every field, its rule and its exact publish error live in Manifest: widget summary; the root fields are in Manifest: root fields.
2. Move the sources into src/widgets/{slug}/
acme-weather/
├── manifest.json · package.json · package-lock.json · tsconfig.json · vite.config.ts
├── scripts/inject-schema.mjs # unchanged
├── widgets/ # GENERATED by build:manifest — commit it
│ ├── forecast.manifest.json
│ └── snapshot.manifest.json
├── media/ # authored by you; nothing generates it
└── src/
├── components/ui/ # optional shared code, imported relatively
└── widgets/
├── forecast/ # index.tsx · widget.tsx · config.ts · forecast.css
│ └── ssr-entry.ts # present ONLY because the summary declares ssr.url
└── snapshot/ # index.tsx · widget.tsx · config.ts · snapshot.css
# NO ssr-entry.ts — the summary says "ssr": false
| File | Required? | Notes |
|---|---|---|
index.tsx | yes | hr-widget-build refuses to start if any declared slug has no src/widgets/{slug}/index.tsx. |
config.ts | in practice yes | build:manifest imports it by exact path and exits 1 if it is missing. |
widget.tsx | convention | Any filename works; index.tsx and ssr-entry.ts import it. |
{slug}.css | optional | The reference suite imports it from both index.tsx and widget.tsx. |
ssr-entry.ts | iff ssr.url | One re-export line. A CSR-only widget has no such file at all. |
Two things break silently when you move files:
- Relative imports. The scaffold’s
src/widget.tsximports./components/ui; fromsrc/widgets/forecast/that becomes../../components/ui. - The
@/*alias is TypeScript-only.tsconfig.jsonmaps@/*→./src/*, but nothing adds the matching Viteresolve.alias, so@/components/uitype-checks and then fails to build. Keep relative paths.
A server-rendered widget’s ssr-entry.ts is one line — that is the entire file in the
published reference suite (pdp-suite, src/widgets/stay-hero/ssr-entry.ts):
export { default } from "./widget";
Suite widgets typically export only default — no getInitialData, no dehydrateState, no
getStaticAssets. What each optional export does, and the sandbox it runs in, is in
Component and SSR module.
3. Add the second widget, CSR-only
The clearest way to internalise the SSR-entry rule is to make your second widget CSR-only:
"ssr": false plus an expectedHeight in the summary, and no ssr-entry.ts in the
folder.
"ssr": falseis a deliberate, explicit value. Omittingssrentirely is a publish error — it is not the same asfalse.hr-widget-buildskips the SSR pass for that widget, sodist/snapshot/never gets a-ssr.umd.js, and anssr-entry.tsleft in the folder is simply never built. The reverse also fails: declaressr.urlwith nossr-entry.tsand the vite pass dies on a missing entry.- On a server-rendered page the renderer emits an empty host div carrying
data-hr-min-heightfromexpectedHeight. Set it, or the page reserves nothing and shifts when your bundle paints (Manifest: widget summary). - A
category: "layout"widget must declaressr.url. The publish audit acceptsssr: falseon a layout, but the render then fails — Layout widgets and Add a layout widget.
Each widget registers and mounts itself. This is the one file that genuinely differs from the
single-widget scaffold — registerPluginWidget(plugin, widget, Component) instead of
registerPlugin(slug, Component), and a namespaced widgetType:
// pdp-suite (the published reference suite) — src/widgets/stay-facts/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import StayFacts from "./widget";
import "./stay-facts.css";
registerPluginWidget("pdp-suite", "stay-facts", StayFacts);
function doMount() {
document
.querySelectorAll<HTMLElement>("[data-hr-widget-container]")
.forEach((container) => {
if (container.querySelector('[data-hr-widget="plugin:pdp-suite:stay-facts"]')) {
mount(container, { widget: StayFacts, widgetType: "plugin:pdp-suite:stay-facts" });
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", doMount);
} else {
doMount();
}
Copy it per widget. The only edits are the two imports, the two keyword strings — for the
example above, registerPluginWidget("acme-weather", "snapshot", …) and
plugin:acme-weather:snapshot — and, for a layout, a category on both calls. The registry
keys, the [data-hr-widget-container] scan and the full MountConfig are specified in
Runtime, mount and the DOM contract.
config.ts and widget.tsx keep the shape the scaffold gave you: extend widgetSchema,
export configSchema from zodToManifestSchema, and parse props.options with
parseWidgetConfig before reading a single field — options arrive RAW on both server and
client. See Settings and options and
Config schema and UI schema.
4. Regenerate the sub-manifests
A suite root carries no schemas. build:manifest writes one sub-manifest per widget and
stamps the reference back onto the summary. This script lives in your project, not in the
SDK — both templates ship the identical script, so you only need to know what it does:
// packages/create-hr-plugin/template/scripts/inject-schema.mjs — the multi-widget branch
// (byte-identical in template-suite/scripts/inject-schema.mjs)
if (Array.isArray(manifest.widgets) && manifest.widgets.length > 0) {
const outDir = path.join(root, "widgets");
fs.mkdirSync(outDir, { recursive: true });
for (const widget of manifest.widgets) {
const slug = widget.slug;
const configPath = path.join(root, "src", "widgets", slug, "config.ts");
if (!fs.existsSync(configPath)) {
console.error(` missing ${configPath} for widget "${slug}"`);
process.exit(1);
}
const { configSchema, uiSchema } = await import(pathToFileURL(configPath).href);
const sub = { slug, configSchema };
if (uiSchema) sub.uiSchema = uiSchema;
fs.writeFileSync(path.join(outDir, `${slug}.manifest.json`), JSON.stringify(sub, null, 2) + "\n");
widget.manifest = `widgets/${slug}.manifest.json`;
}
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
}
widgets/*.manifest.json is generated but committed and shipped in both zips — the
reviewer only runs your build, and a referenced sub-manifest missing from the zip is a
publish error. Only the dashboard ever fetches these files, and only to draw a config form.
Details in Config schema and UI schema.
5. Build
npm run build is still npm run build:manifest && hr-widget-build. The CLI is what turns
one vite config into many bundles:
- It validates every summary before any pass runs — slug shape, the reserved names
dist/widgets/media, duplicate slugs, and the presence ofsrc/widgets/{slug}/index.tsx. Each failure exits non-zero with its own message (Limits and error index). - It deletes
dist/once. Per-widget passes then run withemptyOutDir: false, so running a single pass by hand leaves stale output from a previous build. - It flattens the widgets into a (widget × pass) list — an iife pass for every widget, plus
an SSR pass for every widget that does not declare
ssr: false— and runs each withBUILD_WIDGET={slug}(andBUILD_SSR=1on SSR passes). - It marks the last pass
HR_BUILD_FINALIZE=1. Only that pass hashesdist/, writes the shareddist/manifest.json, and stampsruntime.widgetCoreinto your tracked root manifest. Expectmanifest.jsondirty in git after every build; commit it.
build:iife and build:ssr survive in the single-widget scaffold’s package.json as
escape hatches. On a suite they are the bare vite build that fails by design — delete them
if you converted. --template suite never ships them.
The dist/ a suite produces
One directory per widget, one shared build manifest, and a content-hashed twin beside every
non-.map file:
# pdp-suite (the published reference suite) — dist/ after one `npm run build`
dist/
├── manifest.json
├── pdp-frame/ pdp-frame.iife.js pdp-frame.iife-be6fddd4.js pdp-frame.iife.js.map
│ pdp-frame.css pdp-frame-9992937d.css
│ pdp-frame-ssr.umd.js pdp-frame-ssr.umd-5872d9b8.js …js.map
├── stay-hero/ … + hero-bg-Cr1v4Zys.png hero-bg-Cr1v4Zys-1cc265f5.png
├── stay-facts/ iife + css only ← "ssr": false
└── booking-cta/ iife + css only ← "ssr": false
// pdp-suite — dist/manifest.json, abridged. Keys AND values are dist-relative.
{
"buildHash": "77328e0a",
"generatedAt": "2026-09-07T06:25:25.567Z",
"files": {
"pdp-frame/pdp-frame.iife.js": "pdp-frame/pdp-frame.iife-be6fddd4.js",
"stay-facts/stay-facts.css": "stay-facts/stay-facts-945e0d55.css",
"stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png"
}
}
Bundled images emitted from a widget folder are keyed there too; sourcemaps are published but deliberately absent from the map. How these names become URLs is in Keywords, assets and URLs.
What a suite costs you today
Both of the tooling gaps this section used to list are closed: --template suite
(create-hr-plugin 0.8.0+) generates a suite-aware dev sandbox, and npm run dev:ssr
(widget-core 0.12.2+, and create-hr-plugin 0.8.1+ for the script that drives it) previews
a named suite widget. Two real limits are left, and neither affects publishing, installing,
rendering or SSR in production.
Not supported yet. The dev sandbox has one mock state for the whole page. Scenario, latency, feed branding and the widget
settingsobject are a page singleton innpm run dev:src/dev/main.tsxseedssettingswith the union of every widget’s schema defaults and each widget’s zod strips the keys that are not its own, which works only while no two widgets share a field name. Give two widgets atitleand they share its value in the sandbox — and the control bar’s settings editor edits that one object and pushes it to every keyword.npm run dev:ssrrenders one widget, so it seeds only that widget’s own defaults and has no such collision. Details in Dev sandbox and mocking.
Not supported yet. There is no server-side slot composition locally.
npm run dev:ssrrenders the one widget you named and nothing else, so a layout renders its shell with empty slots: on a server-rendered page the platform renders each bound child and hands the results to your layout asrenderedSlots, and no local harness does that.npm run devcomposes the layout the client way, which is the other half of the contract. Install the plugin on a real feed to exercise the server half — Previewing SSR locally and Layout widgets.
If you converted a project by hand and kept the single-widget harness, you have a third
problem that is yours rather than the platform’s: src/dev/main.tsx statically imports the
flat ../widget and ../config, index.html hardcodes one plugin:{slug} keyword, and the
mock worker’s built-in /widget/{id} route answers every id with that one keyword — so a
layout resolves its own keyword for each slot child and refuses to render it. Take the suite
template’s index.html, src/dev/ and mocks/fixtures.ts instead; the fixture’s widget
factory is the part that makes three widgets coexist. The dev CSS route also becomes
/dist/{plugin}/{widget}/{widget}.css.
Before you zip
npm run buildis clean,dist/manifest.jsonexists, andmanifest.jsonnow carries aruntime.widgetCorestamp.- Every declared
assets.js/assets.css/ssr.urlpath matches whatdist/actually contains, including the{widget}/directory. widgets/*.manifest.jsonis committed, and so is everymedia/file you reference.- No
dist/,node_modules/or.git/in the zip;package-lock.jsonis present. - Every widget declaring
ssr.urlhas anssr-entry.ts; every"ssr": falsewidget has none and has anexpectedHeight.
The full preflight, the audits and what happens after upload are in Preflight and submit and Packaging and publishing rules.
Where to go next
- Add a layout widget — the capability only a suite has.
- Manifest: widget summary — every
widgets[]field. - Composing a page — presets, slots and page assignment.
- Troubleshooting — build failures, empty config panels, blank widgets.
Add a layout widget
A layout widget renders none of its own content. It declares named slots, the customer
binds other widgets into them, and the platform hands your component the already-rendered
children as renderedSlots. Assigned to a server-rendered page, your layout replaces the
platform’s own page layout for that feed.
This page builds pdp-frame — the layout that ships in the real pdp-suite plugin —
file by file, in the order you would write them. Every rule, cap and error string it
touches is stated once in Layout widgets; this page is the
build order, not the contract.
Two things to settle first. Layouts are suite-only: a v1 single-widget manifest
resolves every widget as content, and the shape is frozen at first publish, so scaffold or
convert to a suite before you start (Build a suite,
The two manifest shapes). And layouts need widget-core
0.11.0+, pages needs 0.12.0+, slots[].prefill needs 0.12.1+ — npm’s latest
is older than all three (Install and versions).
If you would rather start from a working layout than from this page,
npx create-hr-plugin <slug> --template suite (create-hr-plugin 0.8.0+) scaffolds one:
page-frame, a four-slot layout with prefill, pages, a preset and light-DOM-safe CSS.
It is the same eight steps below, already written. Read this page either way — the scaffold
shows you a layout, not why each part is shaped as it is.
What you are building
pdp-frame reuses the slot vocabulary of the platform’s own property-details layout —
hero, main, sidebar, bottom — so system widgets drop straight in; what it remixes
is the grid, lifting the sidebar rail beside the hero. Its folder
(pdp-suite 1.3.3 — src/widgets/pdp-frame/):
config.ts zod config — including the mandatory `slots` object
index.tsx client entry: registerPluginWidget + mount, both with a category
pdp-frame.css light-DOM-safe shell CSS
ssr-entry.ts one line — a layout needs no data hooks
widget.tsx the component: renders `renderedSlots`
That is the same five files a content widget uses inside a suite — a layout differs in what goes into them.
Step 1 — declare the summary
Add one entry to widgets[] in the root manifest.json:
// pdp-suite 1.3.3 (the reference suite) — manifest.json, the pdp-frame summary
// (trimmed: the summary's own "description" string is elided.)
{
"slug": "pdp-frame",
"name": "PDP Frame",
"category": "layout",
"slots": [
{ "name": "hero", "prefill": ["property-title", "gallery"] },
{ "name": "main", "prefill": ["property-meta", "bednbath", "description", "amenities", "calendar"] },
{ "name": "sidebar", "prefill": ["booking"] },
{ "name": "bottom", "prefill": ["reviews"] }
],
"ssr": { "url": "dist/pdp-frame/pdp-frame-ssr.umd.js" },
"assets": {
"js": "dist/pdp-frame/pdp-frame.iife.js",
"css": "dist/pdp-frame/pdp-frame.css",
"fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
},
"manifest": "widgets/pdp-frame.manifest.json",
"pages": ["pdp", "misc"],
"icon": "media/pdp-frame.svg",
"readme": "media/widgets/pdp-frame.md"
}
What the layout-specific fields buy you:
category: "layout"is what routes the widget through the layout path at all. Anything that is not exactly that string silently becomes a content widget — and a layout keyword rendered as content then fails.slots[]names the regions. The names are yours; mirroring the system PDP vocabulary is a choice. Seeslots[]— and read thecontent-slot rule there before you putlistings,checkoutorconfirmationinpages, because those pages ignore your vocabulary.slots[].prefill(0.12.1+) lists system widget keywords the dashboard binds when the customer creates the layout, so it opens populated. Creation-time only, never re-applied, and your own widgets cannot go here — that is what a preset is for (slots[].prefill).ssr: { "url": … }is mandatory: the server composes your children, then renders your shell."ssr": falseis not caught by the publish audit — it fails at render time (SSR).pages(0.12.0+) makes the layout assignable to a server-rendered page. Omit it and the layout still works as an embed and in the picker; it just never reaches the feed’s page-layout picker.assets.fonts(0.12.0+) carries the Playfair Display face the slot labels use; declared fonts load document-level, because@font-faceis ignored inside a shadow root (Keywords, assets and URLs).icon/readme(0.12.0+) aremedia/files you author and commit.
Note the absence of expectedHeight: it reserves space for a CSR-only host div and never
applies to a layout. Every non-layout field belongs to
Manifest: widget summary.
Step 2 — declare the mandatory slots config field
This is the step people skip, and its failure is silent. The dashboard does not render a
separate slot editor — it injects one into your own config form under a field named
slots, so without that field there is nowhere to persist and the customer’s bindings are
dropped on save.
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/config.ts
// (trimmed: the shipped file also exports a `uiSchema`; a layout needs none.)
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import { zodToManifestSchema } from "@homerunner-next/widget-core/plugin-schema";
export const configZod = widgetSchema.extend({
showSlotLabels: z.boolean().default(false).describe("Show slot region labels"),
stickyRail: z.boolean().default(true).describe("Keep the sidebar rail pinned"),
// One key per manifest slot name, character for character.
slots: z
.object({
hero: z.array(z.string()).default([]),
main: z.array(z.string()).default([]),
sidebar: z.array(z.string()).default([]),
bottom: z.array(z.string()).default([]),
})
.default({})
.describe("Widgets bound into each region"),
});
export type FrameConfig = z.infer<typeof configZod>;
export const configSchema = zodToManifestSchema(configZod);
Three things to get right, all covered in
The mandatory slots config field:
the keys equal the manifest slot names exactly; every slot is
z.array(z.string()).default([]) with .default({}) on the object, so the field survives
configZod.parse({}) at creation; and you do not write a uiSchema entry for slots,
because the dashboard replaces that key with its own panel
(Config schema and UI schema). Your project’s own
pre-build script turns this into widgets/pdp-frame.manifest.json — see
Build a suite.
Step 3 — write the component
The component reads two props: raw options, and renderedSlots. It renders regions and
nothing else.
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/widget.tsx
// Corrected: the shipped file hand-rolls its props type. Import the SDK type instead.
import React, { type ReactNode } from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import type { LayoutWidgetProps } from "@homerunner-next/widget-core/contracts";
import { configZod, type FrameConfig } from "./config";
import "./pdp-frame.css";
// `options` arrives RAW on both paths, and SSR supplies neither `widgetType`
// nor `target` — so relax the SDK type at the boundary and parse it yourself.
type FrameProps = Omit<Partial<LayoutWidgetProps<FrameConfig>>, "options"> & {
options?: Record<string, unknown>;
};
export default function PdpFrame(props: FrameProps) {
const cfg = parseWidgetConfig(configZod, props.options ?? {});
const slots = props.renderedSlots ?? {};
const region = (name: string, node: ReactNode) => (
<section className={`pdpf-region pdpf-${name}`} data-testid={`pdpf-${name}`}>
{cfg.showSlotLabels ? <span className="pdpf-label">{name}</span> : null}
{node ?? (cfg.showSlotLabels ? <div className="pdpf-empty">empty “{name}” slot</div> : null)}
</section>
);
return (
<div className={`pdpf${cfg.stickyRail ? " pdpf-sticky-rail" : ""}`} data-testid="pdp-frame">
{region("hero", slots.hero)}
{region("main", slots.main)}
{region("sidebar", slots.sidebar)}
{region("bottom", slots.bottom)}
</div>
);
}
Why it is written that way:
parseWidgetConfig(configZod, props.options ?? {})is not optional — settings reach a plugin raw on both server and client (Settings and options).props.renderedSlots ?? {}, then one lookup per region. Never assume a key exists: the two render paths disagree about what an empty slot looks like (The component contract), and theregion()helper’snode ?? …fallback is the defensive shape you want.- Nothing in here may throw. A content widget that throws costs one widget; a layout that throws during SSR costs the customer’s whole page (Failure semantics).
Step 4 — write the SSR entry
Because the summary declares ssr.url, the build runs an SSR pass for this widget, and that
pass needs src/widgets/pdp-frame/ssr-entry.ts. For a layout it is one line:
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/ssr-entry.ts
// SSR entry — the renderer hands `renderedSlots` to the default export.
export { default } from "./widget";
No getInitialData, no dehydrateState, no getStaticAssets: composition is the job, and
the children fetch their own data. Those hooks still work if you need them — they run
before the shell renders, inside the same try/catch
(Component and SSR module).
Step 5 — register and mount with a category
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/index.tsx
// (trimmed: the container scan and DOMContentLoaded guard are the standard
// suite bootstrap — see Build a suite. Only the category is layout-specific.)
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import PdpFrame from "./widget";
import "./pdp-frame.css";
registerPluginWidget("pdp-suite", "pdp-frame", PdpFrame, { category: "layout" });
// …then, for each matching [data-hr-widget-container] on the page:
mount(container, {
widget: PdpFrame,
widgetType: "plugin:pdp-suite:pdp-frame",
category: "layout",
});
This is the standard suite bootstrap plus { category: "layout" } in two places. The
registry write and the mount() call sit in the same IIFE, so passing it once is enough to
route correctly — pass it twice anyway, and never omit it from both. Omitting it everywhere
is the one destructive mistake in this file: the shell is then treated as content, and the
client render wipes the server’s composed slot children. Mechanics in
Registration and mount and
Runtime, mount and the DOM contract.
Step 6 — write light-DOM-safe CSS
Your stylesheet has to survive two homes: document level and unisolated on every
server-rendered page (the shell is light DOM there), and inside a shadow root on a CSR
embed. So: class-namespace everything, never depend on :host, never paint a background on
the shell. The reasoning and the rule table are in
Layout CSS.
/* pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/pdp-frame.css (trimmed) */
/* Light-DOM-safe shell: class-based rules only, no :host-only styles, no
painted background (the same CSS also runs inside a shadow root on CSR
embeds). */
.pdpf {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-areas:
"hero sidebar"
"main sidebar"
"bottom bottom";
gap: 28px 32px;
align-items: start;
}
.pdpf-hero { grid-area: hero; }
.pdpf-main { grid-area: main; }
.pdpf-sidebar { grid-area: sidebar; }
.pdpf-bottom { grid-area: bottom; }
.pdpf-sticky-rail .pdpf-sidebar { position: sticky; top: 24px; }
.pdpf-region { position: relative; min-width: 0; }
.pdpf-label { font-family: "Playfair Display", serif; /* declared in assets.fonts */ }
/* …plus a max-width: 860px block that collapses the grid to one column and
reorders the areas to "hero" "sidebar" "main" "bottom". */
Two habits worth copying: every selector is prefixed .pdpf, and the narrow breakpoint
reorders sidebar above main so a bound booking rail stays reachable on a phone.
Theming — including why a layout inherits none of the platform’s slot spacing — is in
Styling and theming.
Step 7 — build
npm run build (your schema-inject pass, then hr-widget-build) loops every widget.
Because pdp-frame declares ssr.url it gets two passes, and dist/pdp-frame/ holds
pdp-frame.iife.js, pdp-frame.css and pdp-frame-ssr.umd.js, each with a
content-hashed twin. If the SSR pass cannot resolve src/widgets/pdp-frame/ssr-entry.ts
the build fails there — the fastest signal that you declared ssr.url without writing the
entry. The pass loop and the shared build manifest belong to
Build a suite and
Keywords, assets and URLs.
Then preview the server render (widget-core 0.12.2+) — a suite has no default widget, so name this one:
npm run build
npm run dev:ssr -- --widget pdp-frame # http://localhost:3003
That runs the layout’s built SSR bundle through the same vm contract the renderer uses, which
is enough to catch an SSR-only crash, a disallowed import or an empty render in the shell
itself. --widget=pdp-frame and HR_SSR_WIDGET=pdp-frame do the same thing.
Not supported yet. The preview renders the named widget and nothing else, so your layout renders with empty slots: on a server-rendered page the platform renders each bound child and hands the results to your component as
renderedSlots, and no local harness does that.npm run devcomposes the layout the client way instead, which exercises the other half of the contract. Install the plugin on a real feed for the server half — see Previewing SSR locally and Dev sandbox and mocking.
Step 8 — offer a one-click preset
A preset is a root-level entry that creates the layout, its children and every binding in one click. Its power is that a single slot array may mix your own widget slugs with system widget keywords:
// pdp-suite 1.3.3 (the reference suite) — manifest.json, root level
"presets": [
{
"name": "PDP Starter",
"layout": "pdp-frame",
"slots": {
"hero": ["stay-hero", "property-title", "gallery"],
"main": ["stay-facts", "description", "amenities", "calendar"],
"sidebar": ["booking", "booking-cta"],
"bottom": ["reviews"]
}
}
]
stay-hero, stay-facts and booking-cta are this plugin’s own content widgets; the rest
are system keywords the platform ensures or creates on the feed. That is the difference
from prefill, which only takes system keywords — a preset is how you compose your suite
into a finished page. What one click creates, in what order, and the referential rules that
make it publishable are in Presets.
Not supported yet. Preset slot keys are never checked against your layout’s declared slot names. Write
sidbarand the plugin publishes, the widget is created and bound, and it renders nowhere. Diff your preset keys againstslots[].nameby hand.
What the customer ends up seeing
- The widget picker lists your layout beside your content widgets, marked as a layout and carrying a Layers glyph instead of the content one — plus a separate entry per preset (The customer-facing surface).
- The layout’s playground shows the injected Slot Configuration panel at the top of your config form, one row per manifest slot.
- Feed → Page URLs → Page layouts offers the layout for every page its summary declares, next to Automatic; an assignment the renderer cannot honour degrades to Automatic rather than breaking the page (page assignment).
- Composing a page end to end, including the property a layout pushes down to its children, is in Composing a page.
Three things that look like bugs and are not
- On a server-rendered page the shell is plain light DOM — no shadow root, no Declarative Shadow DOM template. Deliberate: each slot child is its own island, which is what keeps third-party payment fields working.
- The client mount on that page is a no-op. Running the CSR path would discard the server’s composed children and re-fetch everything.
- Layouts cannot nest — refused on both render paths, and the slot picker never offers one (Layouts cannot nest).
Not supported yet. Because of (1) and (2), a plugin layout shell can never be interactive on a server-rendered page — no React tree is ever attached to it. Its slot children hydrate themselves normally, so put interactivity in a content widget and bind it into a slot.
Before you submit
Walk the full checklist, then the packaging rules in
Packaging and publishing rules — your media/
icon and readme must be committed, because the reviewer only runs your build. The submission
flow itself is Preflight and submit.
Anatomy of a plugin
A plugin is three things wearing one directory: source you author, artifacts your build produces, and content nothing produces but the platform still expects. Every consumer downstream — the reviewer, central, the renderer, the dashboard, the browser — reads a different subset of those files. Once you know which file is which, the rest of this book is detail.
This page is a map, not a rule set. Field rules live on the contracts pages.
Two trees
The scaffold’s project and a real published plugin look surprisingly different. Roughly
half of what create-hr-plugin gives you is a local sandbox that never reaches a
customer, and the one real suite in the fleet ships none of it.
(a) The single-widget scaffold
# what `npx create-hr-plugin my-widget` writes — `--template single`, the
# default (create-hr-plugin 0.8.1)
my-widget/
├── manifest.json # SHIPPED. Identity + the v1 single-widget fields. Rewritten by the build.
├── package.json # SHIPPED (source zip). Pins react/react-dom/react-query exactly.
├── package-lock.json # appears after `npm install`. Ship it — absent is a publish warning.
├── tsconfig.json # SHIPPED (source zip), never published
├── vite.config.ts # SHIPPED (source zip). Delegates to viteHomerunnerWidget().
├── README.md # yours, developer-facing. NOT the marketplace readme — that is media/.
├── .env.example # dev only. Copy to .env.local; consulted by `?mock=off` and dev:ssr.
├── .gitignore # written from the template's `_gitignore` (npm strips real dotfiles)
├── index.html # DEV ONLY — deletable. Loads /src/dev/main.tsx, falling back to
│ # the BUILT bundles when that 404s under `npm run preview`.
├── serve.js # DEV/DEMO ONLY — deletable. Zero-dep node:http static server.
├── vercel.json # DEMO ONLY — deletable. Self-hosted preview deploy config.
├── api/
│ └── widget-asset.ts # DEMO ONLY — deletable. Vercel edge /w/ → hashed-dist redirect.
├── public/
│ └── mockServiceWorker.js # DEV ONLY — kept out of `dist/` since 0.8.0; see below.
├── mocks/
│ └── fixtures.ts # DEV ONLY — deletable. Overrides for the mock endpoints.
├── scripts/
│ ├── inject-schema.mjs # YOURS, and required. zod → JSON Schema → manifest.json.
│ └── dev-ssr.mts # DEV ONLY — deletable. Boots the local SSR preview.
└── src/
├── index.tsx # SHIPPED. Client IIFE entry: registerPlugin() + mount().
├── widget.tsx # SHIPPED. Your component (+ getInitialData in this template).
├── ssr.ts # SHIPPED. dehydrateState + getStaticAssets.
├── ssr-entry.ts # SHIPPED. Re-export barrel — the SSR bundle's entry point.
├── config.ts # SHIPPED. widgetSchema.extend() + zodToManifestSchema().
├── widget.css # SHIPPED. Tailwind import + your styles.
├── data.ts # SHIPPED. Sample public-api fetcher + a query-key factory.
├── dev/ # DEV ONLY — deletable.
│ ├── main.tsx # the entry index.html loads: mock worker → mount → panel
│ └── DevPanel.ts # the bottom control bar
└── components/ui/ # SHIPPED and yours. Vendored Button/Dialog/Carousel/Stylesheet/
# WidgetSection/…; widget.tsx imports `cn` from here.
Everything marked DEV ONLY or DEMO ONLY can be deleted without affecting what customers load. You lose the local sandbox and the SSR preview, nothing else — see Dev sandbox and mocking and Previewing SSR locally.
serve.js, vercel.json and api/widget-asset.ts in particular imitate the platform’s
own asset proxy for a self-hosted demo. The publish pipeline never touches them, because
you host nothing: you submit source, and HomeRunner builds and serves the result. See
From your laptop to a customer page.
(b) A real published suite (widget-core 0.11.0+)
# /Users/…/hr-plugins/pdp-suite — the published v1.3.3 source tree (50 zip entries, 82 KB)
pdp-suite/
├── manifest.json # root manifest: widgets[] (4) + presets[] + media refs
├── package.json # three scripts: dev, build, build:manifest
├── package-lock.json
├── tsconfig.json
├── vite.config.ts # 13 lines — identical in shape to the single-widget one
├── scripts/
│ └── inject-schema.mjs # same script, multi-widget branch
├── widgets/ # GENERATED by build:manifest, but COMMITTED and shipped
│ ├── pdp-frame.manifest.json
│ ├── stay-hero.manifest.json
│ ├── stay-facts.manifest.json
│ └── booking-cta.manifest.json
├── media/ # AUTHORED. Nothing generates this directory.
│ ├── README.md # declared as manifest.readme
│ ├── CHANGELOG.md # picked up by filename convention, declared nowhere
│ ├── pdp-frame.svg # per-widget icons — flat, one level
│ ├── stay-hero.svg
│ ├── stay-facts.svg
│ ├── booking-cta.svg
│ └── widgets/
│ ├── pdp-frame.md # per-widget readmes — one nested level is allowed
│ └── …
└── src/
├── vite-env.d.ts
└── widgets/
├── pdp-frame/ # the layout
│ ├── index.tsx # registerPluginWidget(…, { category: "layout" }) + mount
│ ├── widget.tsx # reads props.renderedSlots
│ ├── config.ts # MUST declare a `slots` zod object
│ ├── ssr-entry.ts # one line: export { default } from "./widget";
│ └── pdp-frame.css
├── stay-hero/
│ ├── … + hero-bg.png # a bundled asset, imported from the widget folder
│ └── ssr-entry.ts # present, because this widget declares ssr.url
├── stay-facts/ # "ssr": false → NO ssr-entry.ts at all
└── booking-cta/ # "ssr": false → NO ssr-entry.ts at all
No index.html, no src/dev/, no mocks/, no public/, no api/, no serve.js, no
vercel.json, no src/components/ui/, and no root README.md. A suite’s public README
is media/README.md.
npx create-hr-plugin my-suite --template suite (create-hr-plugin 0.8.0+) writes that same
shape — three widgets under src/widgets/, media/, presets[], scripts/inject-schema.mjs
— plus the dev-only files this real suite has dropped: index.html, src/dev/,
mocks/fixtures.ts, public/mockServiceWorker.js, serve.js, vercel.json, .env.example
and scripts/dev-ssr.mts (create-hr-plugin 0.8.1+, and widget-core 0.12.2+ to run it).
Every one of those is deletable on the same terms as in tree (a). It also adds src/shared/,
which is only a demonstration of relative cross-widget imports. It does not add Tailwind
or a src/components/ui/ kit: a layout’s stylesheet is unisolated light DOM on a
server-rendered page, so a framework reset would land on the customer’s whole document.
Build a suite covers both the scaffold and the hand conversion; The two manifest shapes is the decision, and it is permanent once you publish.
Who authored what
Three categories, and confusing them is the most common cause of a publish that fails after your submission was accepted.
Authored by you. Everything under src/, manifest.json, vite.config.ts,
scripts/inject-schema.mjs (it lives in your project, not in the SDK — you can edit
it), and all of media/.
Generated by the build, and committed anyway. widgets/{slug}.manifest.json
(Suite). Your build:manifest step writes them from each widget’s config.ts, and they
must be present in both zips because the dashboard fetches them from the CDN to draw the
config form.
Generated by the build, and never committed. dist/. The scaffold’s .gitignore
already excludes it, and a root-level dist/ in a source zip is rejected — the reviewer
rebuilds it from your source. See
Packaging and publishing rules.
media/ is content, not output
Nothing generates media/. You create the directory, you draw the icons, you write the
README, and you commit them. The reviewer runs your build and nothing else, so a
manifest that declares icon: "media/logo.svg" against a file that is not in your source
zip passes the upload audit, passes review, and fails at publish. If media/ is in your
.gitignore, or your icons live outside it, that is the failure you will hit.
Which paths each media field accepts is on Manifest: root fields; the commit rule and the exact publish errors are on Packaging and publishing rules.
What npm run build changes on disk
npm run build is npm run build:manifest && hr-widget-build. Between them they rewrite
your tracked manifest.json twice:
build:manifest(scripts/inject-schema.mjs) injects the schemas. (Single-widget) it writesconfigSchemaanduiSchemainto the root manifest. (Suite) it writes onewidgets/{slug}.manifest.jsonper summary and back-fills each summary’smanifestpath — the root manifest never carries a schema.- The build’s finalize pass stamps
runtime.widgetCorewith the SDK version it built with, and logsruntime.widgetCore stamped: X.Y.Z.
So manifest.json is dirty in git after every build. That is correct — commit it.
Never hand-write runtime.widgetCore; the publish audit gates on the stamped value.
The same pass walks dist/, writes a content-hashed twin next to every file, and emits
dist/manifest.json — the build manifest, {buildHash, generatedAt, files}, keyed by
dist-relative path. .map files and manifest.json itself are skipped by the hashing
pass but sourcemaps are still published.
# /Users/…/hr-plugins/pdp-suite/dist — the real output of one `npm run build`
dist/
├── manifest.json # the build manifest — hashed twins, keyed dist-relative
├── pdp-frame/
│ ├── pdp-frame.iife.js + …-be6fddd4.js # client bundle + hashed twin
│ ├── pdp-frame.css + …-9992937d.css
│ ├── pdp-frame-ssr.umd.js + …-5872d9b8.js
│ └── pdp-frame.iife.js.map # published, but absent from the build manifest
├── stay-hero/
│ └── … + hero-bg-Cr1v4Zys.png # a bundled asset (widget-core 0.12.0+)
├── stay-facts/ # iife + css only — "ssr": false
└── booking-cta/ # iife + css only — "ssr": false
File-naming rules, the {env}/{slug}/{version}/ CDN key and how a URL is resolved live on
Keywords, assets and URLs.
The MSW leak, and its fix. Both scaffolds put the mock service worker in public/, and
Vite copies publicDir into outDir on every production build — so on scaffolds older than
create-hr-plugin 0.8.0, mockServiceWorker.js and a hashed twin end up in your published
assets and in dist/manifest.json. Harmless but pointless. 0.8.0’s vite.config.ts returns
publicDir: command === "build" ? false : "public", which keeps it out of the build and
leaves npm run dev untouched; add that line yourself if your project predates it. (The real
suite has no public/, so it never had the leak.)
The artifacts, and who reads each one
After the reviewer builds and publishes, a handful of file kinds sit on the CDN under one immutable version prefix. They have almost disjoint audiences:
| Artifact | Read by |
|---|---|
Root manifest.json | Central caches it (manifest_cache) and attaches it to every plugin widget row. The renderer resolves your keyword against that copy. The dashboard reads it for picker entries, icons and presets. |
widgets/{slug}.manifest.json (Suite) | The dashboard only, fetched from the CDN on demand when it draws the config form. The renderer never fetches it. |
dist/manifest.json | The renderer, to prefer the content-hashed twin of a bundle over the version-less proxy. |
dist/…iife.js (client) | The browser, after the shared runtime IIFE has loaded. |
dist/…-ssr.umd.js (server CJS) | The renderer, loaded into a node:vm sandbox. Absent for "ssr": false widgets. |
dist/….css | Both paths — the renderer emits <link>s into the shadow root, the client adopts or loads them. |
media/… | The dashboard, for the marketplace page, the plugin readme and picker icons. |
Two consequences worth designing around:
- Only the ROOT manifest is cached and shipped per render. Keep it small. That is the whole reason a suite’s schemas live in sub-manifests instead of inline — see Config schema and UI schema.
- The sub-manifest fetch is best-effort. If it is missing or slow the dashboard degrades to a schemaless panel rather than erroring — but a sub-manifest your manifest declares and your zip does not contain is a hard publish failure.
The end-to-end path — source zip, review, built zip, CDN, install — is From your laptop to a customer page. What each render path does with these bundles is How a widget renders.
TypeScript setup
The scaffold’s tsconfig.json is byte-identical to the real suite’s, so treat it as the
platform baseline: jsx: "react-jsx", target: "ES2017", module: "ESNext",
moduleResolution: "bundler", lib: ["ES2020", "DOM", "DOM.Iterable"], strict,
noEmit, isolatedModules, and a @/* → ./src/* path alias.
include is ["src/**/*", "vite.config.ts"] only. mocks/, scripts/, serve.js and
api/ sit outside the TS project, and there is no typecheck script — nothing
type-checks your project unless you add "typecheck": "tsc --noEmit" yourself. Vite’s
build does not type-check either.
Where to go next
- The two manifest shapes — the one decision you cannot undo after publishing.
- Your first widget — edit this tree until something renders.
- Build a suite — convert the flat tree into
src/widgets/. - Packaging and publishing rules — exactly what may be in each zip.
The two manifest shapes
Every HomeRunner plugin is one of two things, and you choose which on the day you write
your first manifest.json:
- a single-widget plugin (the v1 shape) — one widget, described by root-level fields;
- a suite (the multi-widget shape) — up to 24 widgets under one version, one review
and one publish, each described by an entry in
widgets[]. (widget-core 0.11.0+)
Both shapes are supported forever, and neither is deprecated. But the shape is frozen at your first publish: the only way out of the wrong choice is a new plugin slug — a new marketplace listing your existing customers would have to install and re-configure. Most authors take the single-widget shape because it is the scaffold’s default, then discover six months later that they want a second widget, a layout, or a preset. It is one flag either way — decide on purpose. The short answer is in Which should I choose: start as a suite, even with one widget.
The discriminator is one line
// packages/homerunner-widget-core/src/manifest.ts — isMultiWidgetManifest
return Array.isArray(manifest.widgets) && manifest.widgets.length > 0;
That is the whole test. There is no version flag, no type field, no "shape" key.
A manifest with a non-empty widgets array is a suite root; anything else — widgets
absent, widgets: [], widgets: {} — is a v1 single-widget manifest.
The same predicate is re-implemented at every layer that touches your plugin, which is why one array reconfigures so much at once:
| Where | What it decides |
|---|---|
The SDK resolver (resolvePluginWidget) | Which branch a stored keyword resolves through |
| The source-zip audit | Whether widgetType/ssr.url/assets.js are required at the root, or widgets[] is validated instead |
hr-widget-build | Two Vite passes, or a (widget × pass) loop with BUILD_WIDGET set |
viteHomerunnerWidget | A bare vite build on a suite fails fast rather than emitting a flat, broken dist/ |
Your scripts/inject-schema.mjs | Schemas inlined into the root manifest, or written to per-widget sub-manifests |
| The dashboard | One picker entry, or one per non-deprecated summary |
Central applies it too, comparing your new manifest against the live one — that is the freeze, and it is the subject of The one-shape rule below.
Side by side
Full field tables live in Manifest: root fields and Manifest: widget summary. This is the difference, not the reference.
| Single-widget (v1) | Suite | |
|---|---|---|
| Discriminator | no widgets[] | non-empty widgets[] |
| Widget described by | root widgetType, ssr, assets | one widgets[] entry each |
| Keyword a customer stores | plugin:{slug} | plugin:{slug}:{widget} |
| Config schema lives in | root configSchema / uiSchema | widgets/{slug}.manifest.json, fetched by the dashboard on demand |
| Source tree | src/widget.tsx, src/config.ts, src/index.tsx, src/ssr-entry.ts | src/widgets/{slug}/{widget,config,index}.tsx per widget |
| Built files | dist/{slug}.iife.js, dist/{slug}.css, dist/{slug}-ssr.umd.js | dist/{widget}/{widget}.iife.js, … nested per widget |
| Server rendering | always required — a v1 plugin cannot be CSR-only | per widget: ssr.url, or an explicit ssr: false |
| Layout widgets | impossible | category: "layout" with slots[] and pages[] |
| Presets | not validated, not used | up to 8 one-click compositions |
Per-widget icon, readme, description, expectedHeight, deprecated, mediaSafeAuto, externalFetch, assets.fonts | root-level only, one set for the plugin | per widget |
| Widget picker | one entry, named from the root manifest | one entry per non-deprecated summary, plus preset entries |
| Plugin page | one synthetic card (plugin icon, name, content badge, keyword, description, Create button) — and nothing more: no per-widget README, no SSR/CSR badge, no instance count, no per-feed toggle, no count in the tab label | a full section per widget with its own icon, readme, badges, instance count, per-feed toggle and Create button |
| Widgets per version | 1 | up to 24 |
| Minimum SDK | any | widget-core 0.11.0+ |
A v1 root, from the scaffold:
// packages/create-hr-plugin/template/manifest.json (placeholders substituted)
{
"id": "my-widget",
"version": "1.0.0",
"name": "My Widget",
"widgetType": "my-widget", // required here; its VALUE is never read
"ssr": { "url": "dist/my-widget-ssr.umd.js" },
"assets": { "js": "dist/my-widget.iife.js", "css": "dist/my-widget.css" },
"runtime": { "react": "^19.0.0" }
// the build injects "configSchema" and "uiSchema" at this level
}
The same plugin as a suite of one — note what moved and what disappeared:
// DERIVED: the manifest above rewritten in the shape of
// pdp-suite (reference plugin): manifest.json — widgets[] with one entry
{
"id": "my-widget",
"version": "1.0.0",
"name": "My Widget",
"runtime": { "react": "^19.0.0" },
"widgets": [ // presence => suite. No widgetType,
{ // no root ssr/assets/configSchema.
"slug": "hero",
"name": "Hero",
"ssr": { "url": "dist/hero/hero-ssr.umd.js" },
"assets": { "js": "dist/hero/hero.iife.js", "css": "dist/hero/hero.css" },
"manifest": "widgets/hero.manifest.json"
}
]
}
Every customer of the first manifest stores the keyword plugin:my-widget. Every customer
of the second stores plugin:my-widget:hero. Those two strings are the whole reason the
shape can never change.
Mixing the shapes is not an error
Mutual exclusivity is convention, not enforcement. A manifest that carries both
widgets[] and the v1 root fields passes every validator: resolution takes the suite
branch, silently ignores root widgetType / ssr / assets / configSchema, and the
bare plugin:{slug} keyword stops resolving. Nothing tells you. Delete the root fields
when you convert.
Shape mismatches fail closed
Resolution never guesses. When the stored keyword and the manifest disagree, the resolver
returns null and the widget is removed — it is never rendered with defaults, and never
falls back to another widget.
| Manifest | Keyword | Result |
|---|---|---|
| v1 | plugin:{slug} | resolves |
| v1 | plugin:{slug}:{anything} | null — v1 manifests have no widget slugs |
| Suite | plugin:{slug} | null — a suite needs a widget segment |
| Suite | plugin:{slug}:{unknown} | null — no summary with that slug |
| Suite | plugin:{slug}:{widget} whose summary has no assets.js | null |
v1 with no root assets.js | plugin:{slug} | null |
| either | a malformed keyword (plugin:, plugin::hero, plugin:acme:) | null — the keyword does not even parse |
Keyword grammar is specified in Keywords, assets and URLs.
A null looks different on each of the three paths a plugin reaches a customer through:
- Server-rendered pages. The renderer replaces the widget with a hidden breadcrumb
node carrying
data-hr-widget-error="{keyword}"and an HTML comment whose reason iskeyword does not resolve against the plugin manifest. The rest of the page renders normally, and the page is flagged degraded so it caches for a much shorter window. View-source and search fordata-hr-widget-error. The full reason index is in Limits and error index. - CSR embeds. Nothing errors, because nothing runs. The copied snippet’s script tag is
derived from the keyword —
/p/{slug}/{slug}.iife.jsfor v1,/p/{slug}/{widget}/{widget}.iife.jsfor a suite — and the proxy serves only files the live manifest declares, so a stale embed gets{"error":"Asset not in plugin manifest"}and the host<div>is left untouched. - The dashboard. A keyword that no longer resolves against the manifest yields no schema, so the customer’s config form degrades to a schemaless panel rather than erroring.
Every one of those is silent from your side. Nobody is notified — not the customer, not you.
The one-shape rule
Before your first publish you can convert freely. The gate compares your new manifest against the plugin’s live one, and an unpublished plugin has none. Flip as often as you like while you are building.
After your first publish the shape is permanent. Central refuses a manifest that flips
it with 422 MANIFEST_SHAPE_CHANGED, on publish and on rollback, so you cannot even
roll back across a shape change. The dashboard mirrors the same check just before the
reviewer uploads the built zip. Both messages are quoted verbatim in
Limits and error index.
The trap is when it fires:
| Step | Shape checked? |
|---|---|
| The audit in your browser, before upload | no |
POST /api/v3/plugins/submissions (submit) | no |
| Admin review — approve / changes requested / rejected | no |
| Publish (and rollback) | yes — 422 MANIFEST_SHAPE_CHANGED |
So a shape flip passes your own audit, passes submit, and passes review. You are told the submission looks fine, and days later the publish dies. Do not read “the reviewer approved it” as “the shape is acceptable”.
The reason is a serving contract, not a policy: customers’ widget rows store the keyword,
customers’ web pages contain copied embed URLs built from that keyword, and neither
survives a shape change. Both would resolve to null on the very next render, across
every feed at once.
The only remedy is a new plugin slug. manifest.id is the slug, so a new shape means
a new id, a new plugin row, a new marketplace listing and a new CDN prefix. Your existing
customers keep running the old plugin, and reach the new one only by installing it and
re-creating their widgets from it.
Not supported yet. There is no migration path, no alias, no “this plugin replaces that plugin” pointer.
replacedByexists in the widget summary type and is read by nothing. Inside a suite you can retire a widget withdeprecated: trueand keep every placement rendering — see Manifest: widget summary. There is no equivalent for retiring a whole shape.
Which should I choose
Start as a suite, even with one widget. A suite of one is legal, publishes normally, and costs you nothing you cannot get back. Choosing v1 costs you a decision you cannot revisit.
| Single-widget (v1) | Suite of one | |
|---|---|---|
| Can add a second widget later | no — new slug | yes, in the next version |
| Can add a layout later | no | yes |
| Can add a preset later | no | yes |
| Can ship a CSR-only widget | no | yes |
npx create-hr-plugin generates it | yes — the default, --template single | yes — --template suite (0.8.0+) |
Works with npm run dev (:3001, MSW-mocked) | yes | yes — all widgets on one page, the layout composing the others |
Works with npm run dev:ssr | yes | yes — one named widget at a time (widget-core 0.12.2+, and create-hr-plugin 0.8.1+ for the script) |
npm run build / hr-widget-build | yes | yes |
| Publishes, installs, renders, SSRs | yes | yes |
A suite used to cost you the dev tooling. It no longer does: --template suite generates a
suite-aware sandbox (create-hr-plugin 0.8.0+) and a dev:ssr script (0.8.1+), and nothing
in the build was ever
single-widget-shaped — hr-widget-build branches on widgets[] itself, and the scaffold’s
vite.config.ts and scripts/inject-schema.mjs handle both shapes with no edits. Two limits
are left, and both are local-only:
Not supported yet. The dev sandbox holds one mock state for the whole page — scenario, latency, branding and the widget
settingsobject are a page singleton, so two widgets with the same field name share its value. And there is no server-side slot composition locally:npm run dev:ssrrenders the one widget you named, so a layout previews with empty slots. Details in Dev sandbox and mocking and Previewing SSR locally.
So the recommended path is now the short one:
- Scaffold the shape you actually want:
npx create-hr-plugin <slug> --template suite, even if it holds one widget. See Build a suite. - If you started single-widget and changed your mind, convert before you publish —
rewrite
manifest.jsonand movesrc/*undersrc/widgets/{slug}/. Same page. - Publish. From here the shape is fixed, but you can add widgets, a layout and presets in any later version.
Choose v1 deliberately only when all of these are true: the plugin is one widget, it will
always be one widget, it server-renders, and you want the single-widget template’s Tailwind
and src/components/ui/ kit more than you want the option to grow. A marketing badge or a
one-off embed is a fair v1. Anything you expect to iterate on is a suite.
Suites need @homerunner-next/widget-core 0.11.0+ (layouts and presets too; assets.fonts
and pages need 0.12.0+, slot prefill 0.12.1+, a suite’s npm run dev:ssr 0.12.2+), and
the suite scaffold itself needs create-hr-plugin 0.8.0+ — 0.8.1+ for the dev:ssr script it
writes. What npm currently serves, and how
to get a newer SDK, is in
Install and versions.
See also
- Manifest: root fields and Manifest: widget summary — every field, per shape.
- Layout widgets — the capability only a suite can have.
- Anatomy of a plugin — the two source trees side by side.
- Build a suite — the conversion checklist.
How a widget renders
Your plugin is React code that runs in two very different places: a server sandbox with no DOM, and a customer’s browser. Which of those actually run — and in which order — depends entirely on what your manifest declares. There are exactly three paths:
| Kind | Declared as | Server render | Browser |
|---|---|---|---|
| Content widget with SSR | ssr: { url: … } — Both shapes | Yes, in an isolated React root | Hydrates that markup |
| CSR-only content widget | ssr: false — Suite only (widget-core 0.11.0+) | No — an empty host div | Fetches its own settings and renders |
| Layout widget | category: "layout" + ssr.url — Suite only (0.11.0+) | Yes, composing its slot children first | Nothing — the mount is a no-op |
The field rules behind that table belong to Manifest: widget summary and Layout widgets. This page is about what each path does.
The page your widget lands on
A customer’s page is not “your widget”. It is a composed document that may carry a dozen widgets — first-party and plugin, in any mix — sharing one React Query cache and one runtime bundle.
customer's domain
└─ Cloudflare worker (reverse proxy in front of the customer's site)
└─ renderer GET /api/render/{feedId}/property/{slug}
├─ resolves the page schema: which widgets, in what order
├─ renders every top-level entry CONCURRENTLY
└─ returns JSON { html, assets, dehydratedState, meta }
└─ worker splices that html + assets into the site shell
→ browser
The renderer never sends a whole page. It sends a fragment plus an asset list, and the
worker composes. That is why your CSS and JS arrive as page-level <link> and <script>
tags rather than anything you control, at URLs the platform derives — see
Keywords, assets and URLs.
What reaches the browser, in document order:
<!-- Shape derived from packages/homerunner-renderer/lib/render-utils.tsx
and src/lib/homerunner/cf-worker.ts (page composition). -->
<head>
<link rel="stylesheet" href="…"> <!-- one per entry in assets.css -->
<link rel="preload" as="script" href="…"> <!-- one per entry in assets.js -->
<link rel="preconnect" href="{central api origin}" crossorigin>
</head>
<body>
<div data-hr-page="…" data-hr-widget-container>
<div data-hr-widget="plugin:pdp-suite:stay-hero" id="…" data-hr-ssr-id="…">…</div>
<div data-hr-widget="plugin:pdp-suite:stay-facts" id="…" data-hr-min-height="140"></div>
</div>
<script>window.HRWidget.__WIDGET_PROPS__ = { "<ssr id>": { props, options, css } }</script>
<script>window.HRWidget.__REACT_QUERY_STATE__ = { … }</script>
<script src="…/w/runtime.iife.js" defer></script>
<script src="…/p/pdp-suite/stay-hero/stay-hero.iife.js" defer></script>
</body>
Three page-level facts follow from that shape, and every path below depends on them:
- Props travel in a registry, not in attributes. Each host carries a small
data-hr-ssr-idkey; one inline script holds every widget’s props, per-embed options and stylesheet list. If that script is blocked — a strict CSP, an ad blocker, an HTML-rewriting CDN — the markup is there but the props are not, and your widget falls through to the client-render path instead of hydrating. - One shared React Query cache. Everything the server prefetched is serialised once and rehydrated into a single browser-side client that every widget on the page shares. Namespace your query keys or you will read someone else’s data.
- The runtime IIFE loads before yours. Since widget-core 0.10.0 your bundle contains
no React, no React DOM, no React Query and no widget-core runtime — they resolve off
window.HRWidgetRuntimeat execution time. Your IIFE then registers your component and callsmount().
The complete attribute contract, the boot order and the registry lookup rules are in Runtime, mount and the DOM contract.
Path 1 — a content widget with SSR
What runs on the server
The renderer fetches your published SSR bundle, evaluates it once in a node:vm sandbox
(cached afterwards), then, inside a single try:
# packages/homerunner-renderer/lib/render-utils.tsx (getWidgetMarkup)
getInitialData(ctx) → dehydrateState(sharedQueryClient, ctx) → getStaticAssets(settings)
→ renderToString(<WidgetHydrationTree><YourComponent {...props} /></WidgetHydrationTree>,
{ identifierPrefix: ssrId })
Two things about that sandbox shape the code you can write. It is a bare V8 realm — only a
short, fixed list of modules and globals is seeded, so crypto, TextEncoder and Buffer
simply are not there. And window/document exist as inert stubs whose property reads
return another truthy stub, so if (document) passes on the server. Only
typeof window === "undefined" guards are rewritten and tree-shaken out of the SSR bundle.
The exact module list, globals, externalFetch rules and execution bounds are in
Component and SSR module.
The props you receive are raw — plugin settings are deliberately not parsed for you, on
either side. parseWidgetConfig(configZod, props.options ?? {}) is mandatory in the
component and in every SSR export. Why, and what raw actually looks like, is
Settings and options.
What the customer’s page contains
Your rendered HTML goes inside one host <div>. When the request is Declarative-Shadow-DOM
capable, the renderer wraps it in a shadow template instead of emitting it bare:
<!-- packages/homerunner-renderer/lib/render-utils.tsx — DSD-capable request -->
<div data-hr-widget="plugin:pdp-suite:stay-hero" id="{widgetId}:{feedId}" data-hr-ssr-id="…">
<template shadowrootmode="open">
<link rel="stylesheet" href="…"> <!-- your CSS, plus any declared fonts -->
<div class="hr-react-root">…your markup…</div>
<div class="hr-portal-container"></div>
</template>
</div>
What runs in the browser
Your IIFE registers your component, then mount() finds the host, adopts the shadow root,
and hydrates the existing DOM in place. Nothing re-renders from scratch and no data is
re-fetched — the query cache already holds what the server prefetched.
Where your code can fail
Four distinct ways, and they behave very differently. A throw in any data hook or in your
component during the server render is contained the same way: your widget is replaced by a
hidden breadcrumb node, the rest of the page renders, and the page is marked degraded —
its cache lifetime collapses at every layer so it re-renders soon rather than serving a
broken version for a day. A rejected prefetch inside a queryFn is quieter still: it is
swallowed by design, logged, and your widget ships whatever it renders with no data (if that
is a spinner, crawlers see a spinner). A throw in the browser is caught by the render
boundary, which marks the host and shows a neutral error card. And output that merely
differs between the two passes does not throw at all — React silently discards your server
DOM; see Hydration.
The full failure table, the breadcrumb markup and the log lines to grep are in Component and SSR module; the numbers are in Limits and error index.
Declarative Shadow DOM, conceptually
Every content widget renders inside a shadow root: it is what stops the customer’s site CSS from repainting your widget and your CSS from leaking into their page. The only question is when that root is created.
Without DSD, the browser parses your markup into the light DOM, and JavaScript creates the shadow root, moves the markup into it, attaches the stylesheets and waits for them. Your widget is briefly visible unstyled, or briefly hidden — the mount blink.
With DSD, the server ships a <template shadowrootmode="open"> and the browser attaches
and styles the root while parsing. By the time your JavaScript runs there is nothing to
build and nothing to wait for. No blink, correct paint on the very first frame, and — with
the right CSS — correct dark mode before any script executes.
A plugin earns a DSD template only when all of these hold:
| Condition | Owned by |
|---|---|
| The renderer’s DSD emission is on (it is, by default) and the request’s browser supports it | — |
The root manifest does not set shadowDOM: false | Manifest: root fields |
The build stamped runtime.widgetCore at 0.10.0 or newer — which is also the publish floor | Manifest: root fields |
mount() then takes one of three branches, in order:
# packages/homerunner-widget-core/src/runtime/mount.tsx
1. ADOPT host.shadowRoot already exists (native DSD) → already styled, hydrate now
2. ADOPT an inert <template shadowrootmode> in light DOM → attach it, then wait for CSS
3. BUILD no template at all → create the shadow root, move the server markup into it
Branch 3 is the migrate path: it moves nodes with appendChild rather than
re-serialising through innerHTML, because hydration matches on node identity. That is one
of several reasons you must never call hydrateRoot yourself.
Two conceptual traps worth carrying with you:
- Your manifest’s
shadowDOMand what your bootstrap passes tomount()must agree. Declare shadow DOM and mount withshadowDOM: falseand the browser attaches the server’s shadow root at parse while your code renders into the light DOM, which is never displayed: visible server content that never hydrates. Nothing checks this before you ship, and it looks fine on a browser that missed the DSD bucket. The full matrix is in Runtime, mount and the DOM contract. - DSD is what makes correct dark-at-first-paint possible at all. The server cannot know
the visitor’s colour scheme (
resolvedThemeis effectively alwayslightin production), so first-paint dark has to come from CSS inside that template. The recipe is in Styling and theming.
Path 2 — a CSR-only content widget
Suite only (widget-core 0.11.0+). Declaring "ssr": false on a widget summary opts it
out of the server pipeline entirely. It has no ssr-entry.ts, no SSR bundle, and its
component is never invoked on the server. This is a suite-shape feature: a single-widget
manifest with no ssr.url is a failure, not a CSR-only widget — the renderer drops it.
What the renderer still does: emit an empty host div, add your JS and CSS to the page’s
asset list, add a registry entry carrying the per-embed options, and — when the summary
declares expectedHeight — stamp a minimum height on the host so the page does not jump
when the widget finally paints.
<!-- packages/homerunner-renderer/lib/render-utils.tsx — the ssr:false branch -->
<div data-hr-widget="plugin:pdp-suite:stay-facts" id="{widgetId}:{feedId}"
data-hr-ssr-id="…" data-hr-min-height="140"></div>
In the browser, mount() sees a childless host, takes the client-render path, and your
component is rendered by a wrapper that first resolves settings: it fetches the feed and the
widget row, merges feed branding, feed globals, the stored settings and the per-embed
override, and hands you the result. So on this path your options are resolved in the
browser from a live config fetch rather than baked into the HTML — still not schema-parsed,
so parseWidgetConfig remains mandatory — widgetType is supplied (it is not on the SSR
path), and data is undefined because no getInitialData ever ran.
On a server-rendered page that config query is pre-seeded by the renderer and travels in the dehydrated state, so it resolves from cache with no network round trip. In a hand-written CSR embed on a page the renderer never touched, it is two live API calls before anything paints.
Where your code can fail here: a render throw is caught by the render boundary and the host is marked; a failed config fetch renders the shared error state instead of your widget. Both markers, and the full vocabulary, are in Runtime, mount and the DOM contract.
Choose CSR-only deliberately. You lose server HTML (nothing for crawlers, a later first
paint, a real CLS risk unless you set expectedHeight). You gain a much simpler build, no
sandbox constraints, and live editing — the dashboard’s options-updated preview channel
works on this path and does not work on a hydrated SSR widget.
Path 3 — a layout widget
Suite only (widget-core 0.11.0+). A layout does not render content; it renders regions, and the platform fills them with other widgets bound into named slots. Its render path is inverted relative to everything above.
# packages/homerunner-renderer/lib/render-utils.tsx (getLayoutWidgetMarkup) — SSR
1. collect every binding across every slot (a binding to a missing widget is logged, skipped)
2. render ALL slot children CONCURRENTLY
each child is a complete island: own SSR id, own DSD template,
own props-registry entry, own assets
3. reassemble each slot in binding order → renderedSlots[slotName] = <>{…children}</>
4. load YOUR layout bundle LAST, run its optional data hooks, render the shell
5. unshift the layout's CSS and JS ahead of every child's assets
Your component receives the finished children as React nodes in renderedSlots and simply
places them. The shell is then emitted as static light DOM — no shadow root, no DSD
template, no SSR id, no props-registry entry.
That has three consequences that consistently read as bugs and are not:
- Your layout CSS is document-level on every server-rendered page. Namespace every
class, never write a
:host-only rule for the shell, never paint a background. Details in Layout widgets and Styling and theming. - The client mount is a deliberate no-op. Running the client path there would throw away the server’s slot children and re-fetch everything. Each child hydrates itself through its own IIFE.
- A layout that throws during render takes the whole page down. Content widgets render
inside a
try; a layout shell is returned as an un-rendered element and rendered later, in the page’s singlerenderToString, which has none. Parse defensively and default every slot.
Not supported yet. Because the shell has no SSR id, no registry entry and no mount, a plugin layout can never be interactive on a server-rendered page — no React tree is ever attached to it. Put interactivity in a content widget and bind it into a slot. See Layout widgets.
Not supported yet. Layouts cannot nest. A layout bound into another layout’s slot is refused on both render paths, before anything renders.
The same layout in a CSR embed is the mirror image
Drop a layout into a single-snippet embed and everything inverts: the shell does get a shadow root, and your layout mounts its own children — reading the stored slot bindings, fetching each child’s row, loading each child’s bundle, waiting for it to register, then mounting it into a host div after commit. It also pushes the parent colour scheme and, when it resolved one, the property filter onto every child.
So a layout stylesheet has to work document-level and inside a shadow root, and
renderedSlots means two different things on the two paths — server-rendered nodes on one,
childless host divs on the other. The shapes differ too: a slot that was never bound is
absent from the object on the server but present as an empty array on the client, so
slots.hero ?? fallback does not behave the same way in both places. The per-path prop
table is in Layout widgets.
Hydration, in one idea
Hydration is React adopting DOM it did not create. It walks your component tree and the existing markup in lockstep and attaches event handlers to the nodes it finds. It works only while the two agree.
The platform makes them agree by rendering each widget as an isolated React root on the
server, inside a fixed wrapper, with an identifier prefix derived from that widget instance —
and by having mount() mirror the wrapper shape and the prefix exactly. React derives
useId values from tree position plus that prefix, so any drift makes every useId
consumer (Radix dialog and popover aria attributes, form ids) mismatch. React’s response is
not a warning: it silently regenerates the tree and discards every byte of your server HTML.
Which gives you three rules — Both shapes, all runtime, nothing checks them before you ship:
- Mount through widget-core’s
mount(). Never callhydrateRootyourself; you cannot reproduce the wrapper and prefix from outside. - Render deterministically. No
Math.random(), noDate.now(), no readingwindow, and no readingprops.widgetType— it is absent on the SSR/hydrate path and present on the CSR path, which makes it a hydration trap disguised as a prop. - Do not fetch by suspending during render.
renderToStringdoes not await; a suspended boundary ships its fallback into the HTML. Fetch ingetInitialDataordehydrateState.
The wrapper, the prefix and the exact prop differences per path are in Component and SSR module.
Failure containment, end to end
One design rule explains most of the platform’s behaviour here: a plugin must not be able to break a customer’s page. Everything except one case is contained.
| What broke | Blast radius |
|---|---|
| Your keyword does not resolve, your SSR bundle 404s or has no default export | That widget only — replaced by a hidden breadcrumb, page marked degraded |
| A data hook or a content component throws on the server | That widget only |
| A data hook throws on a layout | That layout and its already-rendered children — the page still responds, but an assigned page layout failing this way leaves the page empty |
| A component throws in the browser | That widget only — the render boundary catches it |
One embed’s mount() throws | That embed only — every other embed on the page still mounts |
| A layout component throws during the server render | The whole page 500s |
Every hidden breadcrumb stays in the page source, so curl plus view-source tells you which
widget the platform dropped and why. Symptom-first diagnosis is in
Troubleshooting.
Related
- Exports, props, the sandbox and the hydration contract: Component and SSR module
mount(), DOM attributes, shadow-DOM lifecycle: Runtime, mount and the DOM contract- Slots,
renderedSlots, page assignment: Layout widgets - Why
optionsis raw and what merged it: Settings and options - Dark mode at first paint, shadow-safe CSS: Styling and theming
- Running the server path on your machine: Previewing SSR locally
Settings and options
Every widget you ship has a settings blob. A customer edits it in the dashboard,
central stores it, the platform merges it with the feed’s branding, and the result
lands on your component as props.options. One thing to remember from all of that:
props.optionsarrives RAW — on the server and in the browser. Nothing schema-parses it for you. CallparseWidgetConfig(configZod, props.options ?? {})yourself, in your component and ingetInitialData,dehydrateStateandgetStaticAssets. Both shapes, widget-core 0.9.0+.
The rest of this page is the chain that makes that rule necessary — read it once and
the storage artifacts, stringified numbers and colorScheme surprises stop surprising.
The chain
config.ts (zod) --zodToManifestSchema--> manifest.configSchema build time, yours
|
v "Create widget": schema defaults + feed branding (no colorScheme) + filter.feed_id
v
widget.settings <--- the form autosaves the raw value on every change
| (opaque JSON on central: nullable|array, replaced wholesale)
v
getFinalSettings(widget, feed, optionsOverride?)
| feed branding -> feed globals -> widget.settings -> per-embed override
v
props.options -- RAW, on the server and in the browser
v
parseWidgetConfig(configZod, props.options) <-- your job, at every entry point
The chain runs in two places: server-side in the renderer for an SSR widget, client-side in the runtime for a CSR embed. Both call the same merge function and hand you the same unparsed object — see How a widget renders.
One setting, end to end
Take the simplest field in the reference suite — a heading with a default.
// pdp-suite (reference plugin): src/widgets/stay-facts/config.ts
export const configZod = widgetSchema.extend({
heading: z.string().default("Key facts").describe("Section heading"),
facts: z
.array(z.string())
.default(["Sleeps 6 in 3 bedrooms", "Fast wifi + workspace", "5 min to the beach"])
.describe("Facts, one per line"),
});
zodToManifestSchema(configZod) turns that into JSON Schema and your build writes it
into the manifest (single-widget shape) or the widget’s sub-manifest (suite shape); the
dashboard reads it back and builds the form. Which zod constructs survive that round trip
is normative in Config schema and UI schema.
1. Creation: your defaults become stored values
When a customer creates a widget from your plugin, the dashboard seeds
widget.settings with your schema’s default values, layers the feed’s branding over
them (minus colorScheme, so the widget keeps inheriting live), and forces
filter.feed_id. The exact composition is in
Config schema and UI schema. Both shapes.
- Branding wins over your default for the branding keys. If your schema defaults
font.family, the feed’s value overwrites it at creation. Pick defaults for your own fields; treat the base theme fields as the customer’s. - A default stops being a default the moment the row is written. Changing
default()in a later version never reaches widgets that already exist. Migrate in your component (read the old shape, fall back) instead.
2. The dashboard form: live validation is cosmetic
The plugin config form validates on every keystroke and shows errors as
path.to.field: message. It also autosaves on every change — including while the
form is invalid.
// src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
const handleChange = async (data: any) => {
const cleaned = stripEmptySlotEntries(data); // the ONLY transform on the plugin path
await upsertWidget({ id: widget.id, feed_id: widget.feed_id,
keyword: widget.keyword, status: widget.status, settings: cleaned });
};
// wired as: <Form liveValidate onValueChange={handleChange} ... />
Central accepts the blob as 'settings' => 'nullable|array' and replaces it wholesale
— no partial merge, no schema check, no 422 for a bad value.
Not supported yet. Nothing validates stored settings at any layer — advisory (unvalidated). Your own
parseWidgetConfigcall is the only real gate, and it runs on the customer’s page, not in the dashboard. A cross-field rule written with.refine()is erased by the manifest round trip and enforced nowhere.
The plugin save path runs stripEmptySlotEntries (blank slot bindings on a layout) and
nothing else; the blank-row strippers system widgets get never run for plugins — see the
Translations gap in Config schema and UI schema.
3. Storage: what central does to your JSON
Your blob makes two lossy trips: Laravel’s request middleware on the way in, and PHP’s array cast on the way out. Three artifacts reach production plugins.
| Artifact | Cause | What you get back |
|---|---|---|
"" becomes null | ConvertEmptyStringsToNull runs on every request, at any depth (app/Http/Kernel.php) | A cleared text field, enum or number all read as null |
An emptied map becomes [] | Widget::$casts declares 'settings' => 'array'; PHP cannot tell {} from [], so an empty object re-encodes as an empty list | A record field the customer emptied arrives as [], not {} |
| Scalars arrive stringified | Older blobs and feed-level writes survive a JSON round trip as text | font.size: "16" — fine in the form, a zod failure at render |
You repair none of these by hand: parseWidgetConfig absorbs all three. See
Config schema and UI schema for what it does to
each, and in what order.
One consequence is worth designing around. A cleared text field is stored as null,
and a null your schema rejects is stripped — so clearing a field that has a
default() silently restores your default on the next render: an empty input in the
dashboard, your placeholder text on the page. If “deliberately cleared” must be
distinguishable from “never touched”, declare that field .nullable() so the null
survives the strip and reaches you as a real value.
4. The merge: getFinalSettings
Stored settings are never handed to you alone. The runtime merges them first.
// packages/homerunner-widget-core/src/runtime/api.ts
getFinalSettings<T>(widget: Widget<T>, feed: Feed, optionsOverride?: Partial<T>): T
The widget comes first, then the feed. The old guide had these reversed, and so does anything copied from it. The signature is tabulated in SDK reference; the precedence below is this page’s.
Merge order, lowest priority first:
- Feed branding —
colorScheme, the light and dark accents,font.size,font.family. - Feed-wide globals — explorer and booking defaults. These never apply to a
plugin: the scope test is on the widget’s keyword and yours is
plugin:{slug}[:{widget}], so this step is always empty. Both shapes. widget.settings— the stored blob.optionsOverride— the per-embeddata-hr-optionspayload, and how a layout pushes state onto its slot children. See Runtime, mount and the DOM contract.
Three behaviours that bite:
- Arrays replace wholesale. A higher-priority array is taken atomically, never
merged by index. A stored
facts: ["one"]replaces your three-item default outright — it does not leave entries two and three behind. __parentColorSchemeis reserved. A layout pushes its own resolved scheme onto every slot child under this key. It is stripped before the merge and consulted only when the child chose no concrete scheme, so a child that pickeddarkkeepsdark. Never declare it in your schema or read it as a setting; see Layout widgets.colorSchemealways resolves to something concrete.global,nulland absent all mean “inherit”: the pushed parent scheme wins, else feed branding, else the terminal defaultlight— notauto. A visitor’s OS preference is honoured only when someone explicitly choseauto.
The merge finishes by lifting a legacy width: {maxWidth, unit} onto spacing and
normalising customCss to "". That is all of it — no zod, and none of your
configSchema defaults.
5. Your component: raw, on both sides
// pdp-suite (reference plugin): src/widgets/stay-facts/widget.tsx
export default function StayFacts(props: { options?: Record<string, unknown> }) {
const cfg = parseWidgetConfig(configZod, props.options ?? {});
return <h3 className="sfacts-heading">{cfg.heading}</h3>;
}
cfg.heading is a string. props.options.heading may be null, missing, or a value
your schema rejects. Read the parsed object, never the raw one.
That includes fields the platform injects rather than the customer: because you built
your schema with widgetSchema.extend({...}) the base filter is declared, so the
filter.property a layout pushes into a slot child survives the parse and reads as
cfg.filter?.property. Keys your schema does not declare are dropped, which is the
point rather than a loss. The same rule applies to every server-side export:
// packages/create-hr-plugin/template/src/ssr.ts — scaffold guidance, made executable
export async function dehydrateState(
queryClient: QueryClient,
ctx: { options: Record<string, unknown>; feedId: number; widgetId: string },
) {
const options = parseWidgetConfig(configZod, ctx.options); // ctx.options is RAW too
await queryClient.prefetchQuery({ /* ... uses options ... */ });
}
System widgets get a parsed object in those three hooks; plugins do not, and that is deliberate — your schema is a zod value the renderer has never seen.
A parse failure is not silent. parseWidgetConfig ends in schema.parse, so it throws:
on the client the render boundary catches it and marks the host
data-hr-error="render-failed"; on the server a content widget is dropped from the page
while a layout shell takes the whole page down. Both the export contract and those
failure semantics live in
Component and SSR module.
What the platform does not apply for you
The base schema gives every widget theme fields. Merging them is the platform’s job,
rendering them is yours — nothing in mount() or the renderer injects customCss,
sets a font, or paints an accent colour. Both shapes.
// reviews-marquee (published plugin): src/widget.tsx
const accent = isDark ? options.darkModeColors.accent : options.lightModeColors.accent;
const themeCss = `
:host {
--hr-accent: ${accent};
font-size: ${options.font.size}px;
font-family: ${options.font.family}, ui-sans-serif, system-ui, sans-serif;
}
${options.customCss ?? ""} /* after your own rules, so the customer wins */
`;
// <WebFont family={options.font.family} /> <Stylesheet css={themeCss} />
// <div style={resolveWidgetSpacingStyle(options)}> ... </div>
spacing is the one field with a helper: resolveWidgetSpacingStyle(options) returns
the style object for your outer container and absorbs legacy saves. Everything else —
section, language, filter — is data you decide what to do with, and a customer
who edits a field you never render sees nothing change with no way to know why.
Styling and theming covers the full pattern.
Live edits in the dashboard preview
While the customer edits, the dashboard pushes the in-progress settings into the preview
frame and the runtime dispatches them as an options-updated event on the mount root,
routed back through getFinalSettings so colorScheme: "global" still resolves. It is
wired on the CSR content and CSR layout paths only — a hydrated SSR widget has no
listener, which is why the preview updates instantly while the same widget on a live page
does not. See Runtime, mount and the DOM contract.
Corrections to earlier guidance
Three statements in older versions of this guide were wrong.
| Old claim | Reality |
|---|---|
“The renderer pre-validates, so by the time WidgetProps.options hits your component, it conforms.” | The renderer keeps plugin options raw on purpose, on both server and client. |
| “Required fields without defaults reject the save with a 422. Unknown fields are stripped silently.” | Central stores the blob as opaque JSON and replaces it wholesale. Nothing is rejected, nothing is stripped. |
getFinalSettings(feed, widget, override?) | The widget is the first argument. |
See also
- Config schema and UI schema — base
widgetSchemafields, round-trip rules,parseWidgetConfigstep by step. - Component and SSR module —
WidgetProps, the SSR exports, and what a parse throw does on each path. - SDK reference — signatures for
parseWidgetConfig,getFinalSettingsand/spacing. - Layout widgets —
settings.slotsand the pushed payload. - Limits and error index — the enforcement-level legend used above, every hard number, and every message.
- Troubleshooting — “my widget renders blank”.
From your laptop to a customer page
You never publish anything. You submit a source zip; a HomeRunner administrator reads it, builds it on their machine, and publishes the result to the plugin CDN. Everything a customer loads was produced by that person, from your source, after a human decided to allow it.
That one fact shapes the whole pipeline and explains its two most surprising properties: your build output is audited after you have been told your submission was accepted, and nothing anywhere sends you a notification. This page is the map; every rule it mentions is stated normatively on another page, and linked.
The pipeline at a glance
ACTOR STEP ARTEFACT / STATE
──────────────────────────────────────────────────────────────────────────────────
you npm run build dist/ + a stamped manifest.json
you zip the project folder source zip
│
your browser ├─ source-zip audit a failure sends nothing at all
your browser ├─ presigned PUT ────▶ PRIVATE BUCKET submissions/{slug}/….zip
│ (bytes never transit a server)
dashboard └─ metadata leg ─────▶ CENTRAL submission = pending
│
admin ├─ downloads the zip, reads your source
│ changes_requested / rejected ────────▶ back to you, review notes only
▼ approved visibility fixed here
admin ├─ runs YOUR build, zips the result built zip
admin's browser ├─ built-zip audit fails = no publish, silently
admin's browser ├─ presigned PUTs ───▶ PLUGIN CDN {env}/{slug}/{version}/…
│ the root manifest.json uploads LAST (the version prefix is immutable)
dashboard ├─ fetches that manifest back proves the CDN really serves it
dashboard └─ publish call ─────▶ CENTRAL one txn: manifest_url,
current_version, manifest_cache,
status = approved
│
dashboard └─ warm the renderer, purge every feed live in seconds, not cache TTLs
│
customer ├─ feed ▸ Plugins ▸ Marketplace ▸ Install
customer └─ Create widget from your plugin the widget row stores the keyword
plugin:{slug}[:{widget}]
│
renderer resolves that keyword against the your component renders on a
cached ROOT manifest customer's page
Who acts, and where you watch it
| # | Step | Who acts | Where you see it |
|---|---|---|---|
| 1 | Build and zip | You | Your terminal |
| 2 | Upload | You | The uploader in Dashboard ▸ Plugins ▸ My plugins |
| 3 | Record the submission | Central | Your plugin row flips to a pending badge |
| 4 | Review | An administrator | Badge + review notes on the same row |
| 5 | Build your source, publish the built zip | The same administrator | Nothing until it lands |
| 6 | Verify and repoint | Dashboard, then central | The row goes live; a “Published manifest” link appears |
| 7 | Warm and purge | Dashboard | Nothing — it is invisible and best-effort |
| 8 | Install and configure | Your customer | Nothing you can see |
Steps 3 through 7 are one machine sequence with exactly one human decision in it — step 4. After step 2 you cannot influence anything except by submitting again.
The eight steps, in words
1. You build
npm run build produces dist/ and stamps runtime.widgetCore into your tracked
manifest.json. That stamp is the platform’s evergreen-runtime gate and the reason a
build older than widget-core 0.10.0 cannot be published; it is written by the build and
must never be hand-edited. See
Install and versions.
2. You zip and upload
(Both shapes.) You zip the project without dist/ and node_modules/ — the
reviewer rebuilds those. The uploader audits the archive in your browser first: if it
fails, nothing is transmitted at all. On success your browser PUTs the bytes straight into
a private bucket using a short-lived presigned URL, so the zip never passes through a
dashboard server, and a second call registers the metadata with central.
Two consequences. First, media/ and (for suites) widgets/*.manifest.json must be
committed — nothing but your build runs later, and nothing generates them. Second, the
browser audit is convenience, not authority: central re-validates everything server-side
and adds rules the browser cannot check. Both rule sets, with every limit and every
verbatim message, live in
Packaging and publishing rules.
3. Central records the submission
The submission lands on your plugin row: status pending, your version, your parsed manifest, your notes. A new slug creates the row and you own it; a slug owned by someone else, or a legacy admin-registered row with no owner, is refused — see the API codes in Packaging and publishing rules.
4. A human reviews it
An administrator downloads your zip, verifies its checksum, and reads your source; for an update they also see a diff of the live manifest against yours. Three outcomes:
| Outcome | Notes | What happens |
|---|---|---|
| approved | optional | Your submission waits for a publish. Public-or-private visibility is fixed here, in the approve dialog. |
| changes_requested | required | The notes appear on your row. Fix and re-submit. |
| rejected | required | The notes appear on your row, and your uploaded zip is deleted from the bucket. |
Nothing is live yet. Approval is a decision, not a release.
5. The same administrator builds your source
They run your build however they like, zip the built project folder, and drop it into the publish card. A second, different audit now inspects what your build actually produced: declared assets and their hashed twins, the registration marker in every client bundle, sub-manifests, icons, screenshots and READMEs. This is the first moment anyone checks your build output, and it happens after you were told your submission was fine. A failure here does not reopen the review — it costs you a resubmission with a bumped version. Both audits are listed in full, with their exact messages, in Packaging and publishing rules.
6. The version prefix, verification, and the repoint
Files upload one by one into the immutable CDN key prefix {env}/{slug}/{version}/, root
manifest.json last. That ordering makes a half-finished publish safe to retry: until the
root manifest exists, nothing is live and the sentinel that would refuse a re-publish is
absent.
The dashboard then fetches the published manifest back from its public URL and refuses to
continue unless it serves and matches the submission — that is what “published” means
here, not “we assume the bucket worked”. Only then does central repoint manifest_url,
current_version and its cached copy of your root manifest, set the plugin to approved
and clear the submission, in one transaction. Publishing therefore also un-suspends a
suspended plugin.
Where those URLs resolve to is Keywords, assets and URLs; what a version buys you, and how to undo one, is Publishing, versions and rollback.
7. Warm and purge
Composed customer pages are cached aggressively, so publishing does not wait for TTLs to expire: the dashboard asks the renderer to load your new SSR bundle and purges the page caches of every feed that has your plugin installed. Both are best-effort and neither can fail the publish. Typical propagation is seconds; the documented worst case is the composed-page cache TTL for feeds the fan-out did not reach in its budget. The numbers are in Publishing, versions and rollback.
8. A customer installs it, per feed
Nothing is global. A customer opens a feed ▸ Plugins ▸ Marketplace, installs your plugin, activates it, and only then can create a widget from it. The widget row stores your keyword; the renderer resolves it against the root manifest central cached at publish and loads the bundles it names. Install, per-widget toggles, uninstall and the kill switches that can stop your plugin serving are in Install, customers and kill switches; the keyword and manifest side is Manifest: root fields.
The five states you can be in
Your row in Dashboard ▸ Plugins ▸ My plugins renders one of exactly five timelines.
| State | Stepper | Who is blocked | What to do |
|---|---|---|---|
| pending | Submitted ✓ · In review · Approved · Published | An administrator | Wait |
| changes_requested | Submitted ✓ · Changes requested ⚠ · Approved · Published | You | Read the notes, fix, re-submit |
| rejected | Submitted ✓ · Rejected ✕ · Approved · Published | You | Read the notes, fix, re-submit |
| approved | Submitted ✓ · Reviewed ✓ · Approved ✓ · Publish pending | An administrator | Wait |
| published (nothing in flight) | Submitted · Reviewed · Approved · Published ✓ | Nobody | Ship the next version |
Review notes are shown inline for the two failure states, followed by “— fix and re-submit the zip above.” A plugin that is live and has an update in review shows both at once: the liveness badge for what is serving, and a timeline sitting at In review.
Nobody will tell you
Not supported yet. There are no notifications of any kind. No email, no in-app alert, no webhook fires when your plugin is approved, rejected, published or suspended. Nothing in the pipeline sends one. The only channel is opening Dashboard ▸ Plugins ▸ My plugins and reading the badge, the timeline and the review notes yourself. Poll it.
Not supported yet. The built-zip audit’s warnings are visible only to the reviewer. A config schema that does not extend the base, an oversized bundle, a filename that cannot be published — none of them block the publish, and all of them are recorded in a browser you will never look at. Unless the reviewer copies them into review notes, you will never learn that your plugin shipped with an empty settings form. Run those checks yourself: Preflight and submit.
A third asymmetry to plan around: the one-shape rule is not enforced before upload. Flipping a live plugin between the single-widget and suite shapes passes the source audit and passes review, and only dies at publish. See The two manifest shapes for the decision and Packaging and publishing rules for the gate.
Re-submitting replaces whatever is in flight
(Both shapes.) Uploading a new zip for the same slug overwrites the in-flight submission from any state — pending, changes_requested, rejected, even approved-but-unpublished. The status returns to pending, the reviewer, notes and review timestamp are cleared, and the previously uploaded zip is deleted.
Your published version is never touched by this. A plugin that is live keeps serving its current version throughout review, rejection and resubmission — the only things that change what customers load are a publish and a rollback. So if an approved submission has not been published yet and you spot a bug, just submit the fix: you are not queue-jumping, you are replacing the artefact the reviewer was about to build.
You host nothing
Third-party plugins cannot be self-hosted. Registering a plugin by pointing central at a manifest URL you control still exists, but it is an administrator-only escape hatch for pre-pipeline plugins: it has no dashboard UI at all, and it refuses a slug that already exists. The zip pipeline is the only path open to you.
This matters because the scaffold looks like it disagrees. serve.js, vercel.json and
api/ in a freshly generated project are local preview scaffolding — they serve
dist/ on your own machine. They are not a deployment target, nothing on the platform
consults them, and you can delete them.
Where each step’s rules live
| You want | Page |
|---|---|
| Zip rules, audit gates, size limits, verbatim messages | Packaging and publishing rules |
| Preflight checklist, the upload UI, review states in detail | Preflight and submit |
| Version rules, propagation, rollback | Publishing, versions and rollback |
| Install, per-feed config, suspension, kill switches | Install, customers and kill switches |
| Which URL a bundle, image or font resolves to | Keywords, assets and URLs |
| The shape decision you can never reverse | The two manifest shapes |
| Every number and message in one index | Limits and error index |
Manifest: root fields
manifest.json sits at the root of your project, next to package.json. It is the only
file the platform reads to learn that your plugin exists, what it ships, and how to serve
it. It travels in both zips, is cached by the platform, and is handed to the renderer on
every server render.
There are two shapes. A manifest is the suite (multi-widget) root if and only if
widgets is a non-empty array; otherwise it is the single-widget (v1) shape. Nothing
else distinguishes them — there is no version flag and no type field. See
The two manifest shapes for how to choose, and
Manifest: widget summary for every field inside a widgets[] entry.
This page is normative for every ROOT-level field. Per-widget fields live in widget-summary.md; layout semantics in layouts.md; every hard number and the full error index in limits-and-errors.md.
Where root fields are enforced
Three checkpoints read your root manifest. This page tags each rule with the one that catches it, because they happen days apart.
| Tag | Checkpoint | Who sees it |
|---|---|---|
| publish ERROR (audit) | The dashboard audits your source zip in your browser, before a byte is uploaded. | You, immediately. |
| publish ERROR (submit) | POST /api/v3/plugins/submissions re-validates the manifest server-side. | You, as a 422 on Upload. |
| publish ERROR (build) | The built-zip audit plus central’s publish/rollback gates, run by the reviewer after approval. | The reviewer. You learn only that publishing failed. |
publish warning never blocks anything; warnings raised on the built zip are visible only
to the reviewer. Enforcement-level vocabulary is defined once in
limits-and-errors.md.
Always required (both shapes)
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
id | string | Both | The plugin slug. /^[a-z0-9][a-z0-9-]*$/, ≤ 255 chars. Used verbatim — it is not slugified for you. | publish ERROR (audit): manifest "id" ("My_Plugin") must be lowercase alphanumeric with hyphens — it is the plugin's slug. · missing → manifest.json is missing the required "id" field. |
version | string | Both | Three-part MAJOR.MINOR.PATCH, ≤ 64 chars. Strictly greater than your published version. | publish ERROR (audit): manifest "version" ("1.2") is not valid semver (e.g. 1.2.0). · publish ERROR (submit): 422 INVALID_VERSION, 422 VERSION_NOT_GREATER |
name | string | Both | Non-empty, ≤ 255 chars. Display name in the marketplace and the widget picker. | publish ERROR (audit): manifest.json is missing the required "name" field. |
runtime.react | string | Both | A semver range, e.g. "^19.0.0". Required by the PluginManifest type, and every real manifest carries it — but it is declarative metadata only (see below). | advisory (unvalidated): no checkpoint reads or requires it. Ship it anyway; a reviewer looks at it. |
Required in the single-widget shape only
Present these three when widgets is absent or empty. A suite root must not carry
them.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
widgetType | string | Single-widget | Non-empty string. Its value is never read by anything — routing uses plugin:{id}. The scaffold sets it to your id; keep doing that. | publish ERROR (audit): manifest.json is missing the required "widgetType" field. |
ssr.url | string | Single-widget | Path to the SSR bundle, e.g. dist/my-widget-ssr.umd.js. A v1 plugin has no CSR-only mode — there is no ssr: false at the root. | publish ERROR (audit): manifest.json is missing "ssr.url" (the SSR bundle path). · at publish: manifest is missing "ssr.url". |
assets.js | string | Single-widget | Path to the client IIFE. A v1 manifest with no assets.js resolves to null and the widget never renders. | publish ERROR (audit): manifest.json is missing "assets.js" (the client bundle path). · at publish: manifest is missing "assets.js". |
assets.css | string | No | Optional in the type and in both audits. | — |
assets.fonts | string[] | No | Max 8; absolute http(s) URLs or paths confined to the version prefix. (widget-core 0.12.0+) Honoured at resolve for v1 roots and validated by central, but not by the browser audit. See assets-and-urls.md. | publish ERROR (submit) only |
configSchema | JSON Schema | No | v1 only. Generated by your build from zodToManifestSchema(configZod) — never hand-edited. In a suite these move to per-widget sub-manifests. | publish warning (build): manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty. |
uiSchema | object | No | v1 only, same as above. See config-and-ui-schema.md. | — |
Two more root fields are read for v1 manifests but not declared in the
PluginManifest TypeScript type, so your editor will not offer them: root-level
mediaSafeAuto (strict true) and root-level externalFetch (string[]). Both resolve
exactly like their widgets[] counterparts and neither is validated at publish. In a
suite, declare them per widget.
Not supported yet. A v1 root has no
category, noslots, nopagesand noexpectedHeight. A single-widget plugin can never be a layout and can never be CSR-only. Both need the suite shape.
Optional everywhere
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
description | string | No | Optional. Shown on the marketplace card. String, ≤ 4000 chars if present. | publish ERROR (submit): 422 INVALID_MANIFEST Manifest "description" must be a string of at most 4000 characters. |
author | {name, url?} | No | Both members are strings ≤ 255 chars. author.url renders only when it matches ^https?://. | publish ERROR (submit): Manifest "author.name" must be a string of at most 255 characters. |
homepage | string | No | Rendered on the plugin page only when it matches /^https?:\/\//i. Anything else is silently dropped. | advisory (unvalidated) |
support | string | No | Same rule as homepage. | advisory (unvalidated) |
docs | string | No | Same rule as homepage. | advisory (unvalidated) |
license | string | No | Free text (e.g. "MIT"). Nothing parses it. | advisory (unvalidated) |
tags | string[] | No | Non-string entries are dropped; the list is truncated to 12 when displayed. | silent runtime truncation |
screenshots | string[] | No | media/<file> paths only — an absolute URL is rejected. Each file must ship in the built zip. No count cap. | publish ERROR (submit + build), see Media references |
icon | string | No | media/<file> (flat) or an absolute http(s) URL, ≤ 2048 chars. (widget-core 0.12.0+) | publish ERROR (submit + build) |
cover | string | No | Same value rules as icon. (widget-core 0.12.0+) | publish ERROR (submit + build) |
readme | string | No | media/<file>.md or media/<dir>/<file>.md, ≤ 255 chars, file ≤ 64 KB. (widget-core 0.12.1+) | publish ERROR (submit + build) |
widgets | PluginWidgetSummary[] | No | Presence flips the manifest to the suite shape. Max 24 entries. (widget-core 0.11.0+) See widget-summary.md. | publish ERROR (audit): manifest declares 26 widgets — the limit is 24 per plugin. |
presets | Preset[] | No | Max 8. Suite shape only. (widget-core 0.11.0+) See Presets. | publish ERROR (audit + submit) |
shadowDOM | boolean | No | Whole-plugin flag. (widget-core 0.10.1+) See shadowDOM. | advisory (unvalidated), but see the stranding failure below |
runtime.widgetCore | string | No in the type, required to publish | Stamped by the build. Never hand-write it. Must be ≥ 0.10.0. | publish ERROR (build), see runtime |
A media/CHANGELOG.md in your zip becomes the plugin page’s Changelog tab by
convention — it is not declared anywhere in the manifest.
Not supported yet. There is no root-level
keywords,minPlatformVersion,permissionsordependenciesfield. Unknown root keys are ignored everywhere, so a typo like"screenshot"fails silently rather than erroring.
id
manifest.id is the plugin slug. Central stores it verbatim after checking
/^[a-z0-9][a-z0-9-]*$/; there is no Str::slug normalisation, so My_Plugin is
rejected rather than repaired.
The slug is permanent and globally unique across all authors:
- An existing row owned by someone else → 409
SLUG_TAKEN,The plugin id "acme-suite" already belongs to another author. - An existing row with no owner (a legacy admin-registered plugin) → 409
SLUG_RESERVED,The plugin id "acme-suite" is reserved. Contact an administrator to claim it.
It also constrains your upload key: the submission object must live under
submissions/{id}/…, else 422 INVALID_ZIP_KEY. Full submit flow in
ship/preflight-and-submit.md.
Your id is the first half of every keyword the platform stores against a customer’s
widget row (plugin:{id} or plugin:{id}:{widget}), which is why it can never change.
version
Three hard rules, in the order you will hit them:
- Shape, in the browser.
/^\d+\.\d+\.\d+(-…)?(\+…)?$/.1.2fails withmanifest "version" ("1.2") is not valid semver (e.g. 1.2.0). - Three parts, at central. Composer’s parser would accept
1.0; central re-checks with its own regex and returns 422INVALID_VERSION,Manifest "version" must be MAJOR.MINOR.PATCH semver: 1.0. - Strictly greater, at central. 422
VERSION_NOT_GREATER,Version 1.2.0 must be greater than the published 1.3.0.
One version number covers the whole suite. There is no per-widget version.
Published version prefixes on the CDN are immutable — re-publishing the same version is refused. See ship/publish-versions-and-rollback.md.
Not supported yet. Pre-release suffixes (
1.0.0-rc.1) pass every validator, but each immutability detector in the fleet requires a bareX.Y.Zpath segment. A pre-release publish silently loses direct content-addressed asset URLs, the life-of-instance build-manifest cache and the slug-keyed SSR bundle cache, falling back to short-TTL proxied serving everywhere. Ship plainX.Y.Zversions.
runtime
// packages/create-hr-plugin/template/manifest.json — the build adds `widgetCore`
{
"runtime": { "react": "^19.0.0" }
}
After a build:
// /Users/.../hr-plugins/pdp-suite/manifest.json (real, published 1.3.3)
{
"runtime": { "react": "^19.0.0", "widgetCore": "0.12.0" }
}
runtime.react is declarative metadata. Nothing in the renderer, the dashboard, the
asset proxy or central reads its value, and no checkpoint requires it to be present — the
build’s stamp pass creates the runtime object for you if it is missing. It documents
intent for a reviewer; it does not gate anything. (The old guide claimed “the renderer
rejects plugins whose runtime.react doesn’t match”. There is no such code.)
What actually controls which React you run against is the exact pin set in your
package.json, because the platform externalises React and hands you its own copy at
runtime. Patch drift trips react-dom’s own Incompatible React versions assertion. The
canonical pins live in
get-started/01-install-and-versions.md.
runtime.widgetCore is the enforced gate. It records the exact
@homerunner-next/widget-core version your build ran with, and it is written for you by
the build’s finalize pass — treat it as generated output, not a field you own. Expect
manifest.json to come back dirty in git after npm run build; commit it.
| Rule | Enforcement | Message |
|---|---|---|
| Present and semver | publish ERROR (build) | manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild. |
≥ 0.10.0 | publish ERROR (build) | Built against widget-core 0.9.0 — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild. |
Bare X.Y.Z at central | publish ERROR (build) | 422 MANIFEST_WIDGET_CORE_MISSING — central’s regex is /^\d+\.\d+\.\d+$/, so a pre-release stamp is rejected even though the dashboard audit accepts one. |
The same gate runs on rollback, so you cannot roll back to a version built with a pre-0.10.0 SDK.
shadowDOM
// /Users/.../hr-plugins/pdp-suite/manifest.json
{ "shadowDOM": true }
A root-level flag governing the whole plugin — there is no per-widget override. Omit
it (or set true) unless every one of your client bootstraps passes
shadowDOM: false to mount().
It buys one thing: Declarative Shadow DOM for your server-rendered markup, which means your widget is styled at parse time with no mount blink. The renderer grants DSD only when both hold:
manifest.shadowDOM !== false, andruntime.widgetCoreparses to ≥0.10.0.
Nothing validates that the flag agrees with your bootstrap. When they disagree — the
manifest says shadow, mount() says none — the server attaches a shadow root that your
mount never adopts, and the SSR content is stranded: visible in view-source, invisible on
screen. See runtime-and-mount.md for the mount side and
recipes/styling-and-theming.md for the styling
consequences.
Media references
Four fields point at files under media/. Their value rules are not the same, and
the differences are load-bearing.
| Field | Accepts a media/ path | Accepts an absolute URL | Nesting | Length cap |
|---|---|---|---|---|
icon | yes, flat media/<file> | yes | none | ≤ 2048 chars |
cover | yes, flat media/<file> | yes | none | ≤ 2048 chars |
widgets[].icon | yes, flat media/<file> | yes | none | ≤ 2048 chars |
screenshots[] | yes, flat media/<file> | no — rejected | none | — |
readme, widgets[].readme | yes, .md only | no | one directory level | ≤ 255 chars path, ≤ 64 KB file |
Exact patterns, from the SDK:
// packages/homerunner-widget-core/src/manifest.ts (isPluginMediaRef, isPluginReadmeRef)
/^media\/[^/\\]+$/ // icon, cover, widgets[].icon — flat, no subdirectory
/^https?:\/\/[^\s]+$/i // …or an absolute URL, for those three only
/^media\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?\.md$/ // readme — one optional subdir
Violations:
| Condition | Enforcement | Message |
|---|---|---|
icon/cover is neither shape | publish ERROR (build) | manifest "icon" must be a "media/<file>" path or an absolute http(s) URL. |
A declared media/ icon is absent from the zip | publish ERROR (build) | Declared icon media/logo.svg is missing from the zip. |
A screenshot is not media/<file> | publish ERROR (build) | manifest screenshot "https://cdn.example.com/1.png" must be a "media/<file>" path. |
| A declared screenshot is absent | publish ERROR (build) | Declared screenshot media/1.png is missing from the zip. |
readme is the wrong shape | publish ERROR (build) | manifest "readme" must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. |
| A declared README is absent | publish ERROR (build) | Declared manifest "readme" media/README.md is missing from the zip. |
| A README exceeds 64 KB | publish ERROR (build) | manifest "readme" media/README.md is 71 KB — the limit is 64 KB. |
Central re-checks the same shapes at submit and returns 422 INVALID_MANIFEST with its
own wording, e.g. Manifest "icon" must be a "media/<file>" path or an absolute http(s) URL (max 2048 characters).
Three consequences worth internalising:
media/is authored content. Nothing generates it, and the reviewer only runs your build. A declared icon, screenshot or README that is not committed to your source zip makes the publish fail after you were told the submission looked fine. See packaging-and-publishing.md.- A
media/reference resolves only after your first publish. It is resolved withnew URL(ref, manifest_url), and an unpublished plugin has nomanifest_url— so the resolver returnsnulland the icon falls back to a glyph. An absolute URL renders immediately, including in the review console. If you want your icon visible during first review, use a URL; if you want it version-immutable, usemedia/. - Screenshots asymmetry is deliberate. Screenshot files publish under the same
immutable version prefix as your bundles, so they may not point off-platform. The
plugin page additionally drops any screenshot that is not
media/…, and renders none at all before the first publish.
READMEs are rendered through a markdown sanitizer — no raw HTML, no scripts.
Presets
// /Users/.../hr-plugins/pdp-suite/manifest.json (root level, beside "widgets")
"presets": [
{
"name": "PDP Starter",
"layout": "pdp-frame",
"slots": {
"hero": ["stay-hero", "property-title", "gallery"],
"main": ["stay-facts", "description", "amenities", "calendar"],
"sidebar": ["booking", "booking-cta"],
"bottom": ["reviews"]
}
}
]
presets?: Array<{ name: string; layout: string; slots: Record<string, string[]> }> —
a root field, suite shape only (widget-core 0.11.0+). Presets on a v1 manifest are
neither validated nor used, because a preset must reference a declared layout widget and
only suite roots declare widgets.
Referential integrity is checked at publish, in both the browser audit and central:
| Rule | Enforcement | Message (browser audit) |
|---|---|---|
| At most 8 entries, and a list | publish ERROR (audit) | manifest "presets" must be a list of at most 8 entries. |
Every preset has a name | publish ERROR (audit) | each manifest preset needs a "name". |
layout names a declared widget with category: "layout" | publish ERROR (audit) | preset "PDP Starter" must reference a declared layout widget. |
slots is an object of arrays of strings | publish ERROR (audit) | preset "PDP Starter" needs a "slots" object. / preset "PDP Starter" has an invalid slot binding shape. |
Every slot child is one of this plugin’s own non-layout widget slugs, or a bare system widget keyword /^[a-z][a-z0-9-]{0,63}$/ | publish ERROR (audit) | preset "PDP Starter" references "plugin:acme:hero" which is neither a declared content widget nor a system widget keyword. |
A plugin: reference is never accepted as a slot child — name your own widget by its
bare slug, and a system widget by its bare keyword.
Not supported yet. Preset slot keys are never checked against the layout’s declared slot names. A typo (
"sidebar"vs"side-bar") passes every validator and creates a binding the layout never renders. Copy the slot names from your ownslots[]and diff them by eye.
What one click on a preset actually creates — which children are created versus reused, the naming, the rollback on failure — is in layouts.md and recipes/composing-a-page.md.
Suite roots and v1 roots are not mixable
Mutual exclusivity is convention, not enforcement. If a manifest carries both
widgets[] and the v1 root fields, resolution takes the multi branch, silently ignores
root widgetType / ssr / assets / configSchema, and the bare plugin:{id} keyword
stops resolving — every existing v1 placement breaks. Conversely, a widget slug resolved
against a v1 manifest returns null.
Both failures are fail-closed: the widget is removed from the page and the renderer
records the reason keyword does not resolve against the plugin manifest. The resolution
table and the failure node it produces are in
concepts/manifest-shapes.md.
The shape is frozen at first publish. Central refuses a manifest that flips shape with
422 MANIFEST_SHAPE_CHANGED:
This manifest changes the plugin between single-widget and multi-widget shapes. Existing
widget placements and embed URLs would stop resolving — publish the new shape under a new
plugin slug instead.
Note when this fires: submit and review do not check the shape. The gate runs at publish and at rollback only, and the dashboard mirrors it just before the reviewer uploads your built zip with:
This build changes {slug} between single-widget and multi-widget shapes. Existing
placements and embed URLs would stop resolving — publish the new shape under a new plugin
slug.
So a shape flip will pass your upload and pass review, and die at publish. The only remedy is a new plugin slug. If you might ever ship a second widget, start as a suite — a suite of one is legal.
How the manifest reaches the renderer
- At publish, central stores your root manifest in
manifest_cacheand recordsmanifest_url(the CDN URL of that exactmanifest.json) andcurrent_version. - The renderer asks central for a feed’s widgets. For every widget row whose keyword
starts with
plugin:, the join attaches two fields:
// what the renderer receives per widget row (suite keyword form)
{
"id": "…",
"keyword": "plugin:pdp-suite:stay-hero",
"plugin_manifest": { /* your ROOT manifest.json, verbatim */ },
"plugin_manifest_url": "https://plugins.homerunner.io/prod/pdp-suite/1.3.3/manifest.json"
}
The v1 keyword form is plugin:{id} with no widget segment.
- The renderer parses the keyword, resolves it against
plugin_manifest, and reads the resolved widget’sssr.urlandassets.*.
Four properties of that pipeline you should design around:
- Only the ROOT manifest is cached and shipped. A suite root deliberately carries no
configSchema/uiSchema— those live in per-widget sub-manifests fetched on demand by the dashboard alone. Keep the root small; it rides along with every render. - The manifest attached is the kill-switch-filtered one. Suspended plugins get no manifest at all, and widget summaries switched off platform-wide or on that feed are stripped before the renderer ever sees them. See ship/install-and-kill-switches.md.
- Path-only asset values resolve against
plugin_manifest_url, preserving subpaths.dist/stay-hero/stay-hero.iife.jsagainsthttps://…/prod/pdp-suite/1.3.3/manifest.jsonbecomeshttps://…/prod/pdp-suite/1.3.3/dist/stay-hero/stay-hero.iife.js. Ship path-only values; absolute URLs bypass the whole mechanism and are a legacy shape. - The hashed build manifest is preferred over the proxy. For a pipeline-published
plugin the renderer reads your
dist/manifest.jsonand emits the content-addressed file directly, so a cached page always loads the exact bundle its HTML was rendered with. The version-less/p/{slug}/{file}proxy is the fallback. Both are documented in assets-and-urls.md.
Full example — a real suite root
// /Users/.../hr-plugins/pdp-suite/manifest.json (published v1.3.3), widgets[] trimmed
{
"id": "pdp-suite", // the slug; permanent, globally unique
"version": "1.3.3", // MAJOR.MINOR.PATCH, strictly increasing
"name": "PDP Suite",
"description": "A remix of the platform's property-details layout…",
"readme": "media/README.md", // one nesting level allowed; <= 64 KB
"author": { "name": "HomeRunner QA", "url": "https://homerunner.io" },
"license": "MIT",
"tags": ["pdp", "layout", "property"], // first 12 shown
"runtime": {
"react": "^19.0.0", // metadata only — nothing reads it
"widgetCore": "0.12.0" // STAMPED BY THE BUILD. Never hand-write.
},
"shadowDOM": true, // whole-plugin; must match every mount() call
"widgets": [ // presence => suite shape. Max 24.
{ "slug": "pdp-frame", "name": "PDP Frame", "category": "layout", "…": "…" },
{ "slug": "stay-hero", "name": "Stay Hero", "…": "…" },
{ "slug": "stay-facts", "name": "Stay Facts", "ssr": false, "…": "…" },
{ "slug": "booking-cta","name": "Booking CTA","ssr": false, "…": "…" }
],
// Absolute URLs: these render in the marketplace and the review console BEFORE the
// first publish. `media/…` values would resolve to null until manifest_url exists.
"icon": "https://picsum.photos/seed/pdp-suite-icon/256/256",
"cover": "https://picsum.photos/seed/pdp-suite-cover/1200/400",
"presets": [ // max 8; slot children are own slugs or
{ // bare system keywords, never plugin: refs
"name": "PDP Starter",
"layout": "pdp-frame",
"slots": {
"hero": ["stay-hero", "property-title", "gallery"],
"main": ["stay-facts", "description", "amenities", "calendar"],
"sidebar": ["booking", "booking-cta"],
"bottom": ["reviews"]
}
}
]
}
Every field inside widgets[] is specified in widget-summary.md.
Full example — a v1 root
// packages/create-hr-plugin/template/manifest.json (placeholders substituted)
{
"id": "my-widget",
"version": "1.0.0",
"name": "My Widget",
"description": "A HomeRunner widget plugin",
"author": { "name": "Plugin Author" },
"widgetType": "my-widget", // required; its VALUE is never read
"ssr": { "url": "dist/my-widget-ssr.umd.js" },
"assets": {
"js": "dist/my-widget.iife.js",
"css": "dist/my-widget.css" // optional
},
"runtime": { "react": "^19.0.0" } // build adds "widgetCore"
// build also injects "configSchema" and "uiSchema" here
}
See also
- Manifest: widget summary — every
widgets[]field. - Layout widgets —
category,slots,prefill,pages, presets at creation time. - Config schema and UI schema — what goes in
configSchema. - Keywords, assets and URLs — how
assets.*values become URLs. - Packaging and publishing rules — what must be in each zip.
- Limits and error index — every number, every message.
Manifest: widget summary
A widget summary is one object in the root manifest’s widgets[] array. It is the entire
per-widget contract the platform stores: the slug that forms the keyword, the bundle paths,
whether the widget server-renders, where its config schema lives, and how the dashboard
presents it. Central caches the ROOT manifest and ships it with every render — your summary is
the only per-widget record the renderer ever sees.
Shape: Suite only. widgets[] exists in the multi-widget (suite) manifest shape and
nowhere else. In the single-widget (v1) shape the same information lives on root fields; see
The two manifest shapes for the discriminator and
Manifest: root fields for the root table. Requires
@homerunner-next/widget-core 0.11.0+ (see Install and versions).
| Suite summary field | v1 root equivalent |
|---|---|
widgets[].slug | widgetType (and the plugin id) |
widgets[].ssr | ssr (required; never false) |
widgets[].assets | assets |
widgets[].manifest → sub-manifest | inline root configSchema / uiSchema |
widgets[].category / slots / pages | no equivalent — v1 plugins are always content |
A summary’s keyword is plugin:{pluginSlug}:{widgetSlug}. Slugs only need to be unique inside
your plugin. Keyword grammar and asset URL resolution live in
Keywords, assets and URLs.
Enforcement levels used below — publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated) — are defined once in Limits and error index.
Field index
Error strings are quoted verbatim from the dashboard’s submission audit
(src/lib/plugin-zip.ts); {slug} is your widget slug and widgets[N] the array index.
| Field | Type | Required (suite entry) | Rule | On violation |
|---|---|---|---|---|
slug | string | Required | /^[a-z0-9][a-z0-9-]*$/, unique in the plugin, not dist / widgets / media | publish ERROR — manifest widgets[N] needs a lowercase-alphanumeric-with-hyphens "slug". · manifest widget slug "{slug}" is reserved. · manifest declares widget slug "{slug}" more than once. |
name | string | Required | non-empty; shown in the picker and every admin surface | publish ERROR — manifest widget "{slug}" is missing "name". |
description | string | Optional | free text, shown under the name in the picker | advisory (unvalidated) — no length cap, no format check |
category | "content" | "layout" | Optional (default content) | exactly one of the two literals | publish ERROR — manifest widget "{slug}" has an unknown "category" (content | layout). |
ssr | { url: string } | false | Required | ssr.url a confined relative path, or the literal false for a CSR-only widget | publish ERROR — manifest widget "{slug}" needs "ssr.url" (relative path) or an explicit "ssr": false for CSR-only widgets. |
assets.js | string | Required | confined relative path to the client IIFE | publish ERROR — manifest widget "{slug}" needs a relative "assets.js" path inside the version prefix. |
assets.css | string | Optional | confined relative path | publish ERROR — manifest widget "{slug}" has an invalid "assets.css" path. |
assets.fonts | string[] | Optional (0.12.0+) | ≤ 8 entries, each an absolute http(s) URL or a confined relative path | publish ERROR — manifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets. · … entries must be absolute http(s) URLs or relative paths inside the version prefix. Extra/invalid entries are also dropped by silent runtime truncation |
manifest | string | Optional, in practice required | confined relative path to widgets/{slug}.manifest.json; the file must ship in the built zip | publish ERROR (shape) — manifest widget "{slug}" has an invalid sub-manifest path. · publish ERROR (missing at publish) — widgets/{slug}.manifest.json (widget "{slug}"'s sub-manifest) is missing from the zip or invalid JSON. |
icon | string | Optional (0.12.0+) | media/<file> (FLAT, no subdirectory) or an absolute http(s) URL, ≤ 2048 chars; a media/ file must ship in the built zip | publish ERROR — widget "{slug}" icon must be a "media/<file>" path or an absolute http(s) URL. · Declared widget "{slug}" icon {ref} is missing from the zip. |
readme | string | Optional (0.12.0+) | media/<file>.md or media/<dir>/<file>.md (exactly one optional directory), ≤ 255 chars, file ≤ 64 KB, must ship in the built zip | publish ERROR — widget "{slug}" readme must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. · Declared widget "{slug}" readme {ref} is missing from the zip. · widget "{slug}" readme {ref} is {n} KB — the limit is 64 KB. |
expectedHeight | number | Optional | 0 <= n <= 10000 (px) | publish ERROR — manifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px). |
mediaSafeAuto | boolean | Optional | strict true only; opts into parse-time auto scheme stamping and carries a CSS obligation | advisory (unvalidated) at publish; anything other than true is read as false |
externalFetch | string[] | Optional | hosts the SSR sandbox will allow; non-string entries are dropped at resolve | advisory (unvalidated) at publish — enforced only at SSR runtime |
slots | {name, accepts?, prefill?}[] | Layouts only | requires category: "layout"; inner shape rules in Layout widgets | publish ERROR — manifest widget "{slug}" declares "slots" but is not a layout. · manifest widget "{slug}" needs a "slots" list. |
pages | PluginPageKey[] | Layouts only (0.12.0+) | requires category: "layout", non-empty, values from the six page keys; see Layout widgets | publish ERROR — manifest widget "{slug}" declares "pages" but is not a layout. · manifest widget "{slug}" needs a non-empty "pages" list. · manifest widget "{slug}" has unknown page(s) in "pages": … (pdp | listings | checkout | confirmation | collection | misc). |
deprecated | boolean | Optional | truthy hides the widget from the picker; existing placements keep rendering | advisory (unvalidated) at publish |
replacedBy | string | Optional | declared in the SDK type only | advisory (unvalidated) — read by nothing (see Known gaps) |
Maximum 24 widgets per plugin: manifest declares {n} widgets — the limit is 24 per plugin.
(publish ERROR). Every hard number in the book is collected in
Limits and error index.
Confined relative paths
assets.js, assets.css, relative assets.fonts entries, ssr.url and manifest must all be
confined relative paths — paths that cannot escape the published version prefix
{env}/{slug}/{version}/.
// src/lib/plugin-zip.ts — isConfinedRelativePath (the submission audit's rule)
function isConfinedRelativePath(value: unknown): value is string {
if (typeof value !== "string" || value === "") return false;
if (value.startsWith("/") || /^[a-z]+:/i.test(value)) return false;
// WHATWG URL parsing treats "\\" as "/" for http(s) — a backslash segment
// would otherwise slip past the "/"-only traversal check.
if (value.includes("\\")) return false;
return !value.split("/").some((segment) => segment === "..");
}
Four rejections, in order: an absolute path (/dist/hero.js), any scheme: prefix
(https://cdn.example/hero.js, file:hero.js), any backslash, and any .. segment. An absolute
http(s) URL is therefore not a legal value for these fields — only assets.fonts, icon
and cover accept absolute URLs. Central applies the identical rule server-side.
Field notes
slug
The slug is a serving contract: it forms the keyword customers store on their widget rows, the
dist/{slug}/ directory the build emits, and the /p/{plugin}/{slug}/{slug}.iife.js proxy path.
dist, widgets and media are reserved because they are the published directory names under
the version prefix.
When a summary’s slug fails the regex the audit stops checking that entry and moves to the
next one — fix the slug first, then re-run to see the rest of that widget’s errors.
hr-widget-build enforces the same three rules before it runs a single Vite pass, so you hit
them at build time, not at submission; the build’s own strings are in
Build a suite.
name and description
name is required and non-empty at publish. Everything downstream still defends against an
absent value: resolvePluginWidget falls back to the slug, and the picker falls back to the
plugin name.
description is validated by nothing. When you omit it the widget picker substitutes the literal
string Third-party plugin, and a layout gets (layout — hosts other widgets in its slots)
appended to whatever description it shows.
// src/components/widget/CreateWidgetDialog.tsx — picker entry for a plugin widget
description:
(entry.description ?? 'Third-party plugin') +
(entry.category === 'layout' ? ' (layout — hosts other widgets in its slots)' : ''),
icon: entry.category === 'layout' ? Layers : Puzzle,
category
Only the two literals content and layout pass publish. At resolve time the check is
one-sided: summary.category === "layout" ? "layout" : "content" — so an absent value, and any
value that somehow reached the runtime without passing publish, is treated as content.
Choosing layout changes almost everything about how the widget renders and adds a mandatory
slots field to your zod config. Read Layout widgets before you set it.
ssr
Three legal states, and the third one is a publish error:
| Value | Meaning |
|---|---|
{ "url": "dist/hero/hero-ssr.umd.js" } | Server-rendered. You must ship an ssr-entry.ts for this widget. |
false | CSR-only. The renderer emits an empty host div; your client IIFE renders it. No ssr-entry.ts exists for this widget at all. |
| absent / anything else | publish ERROR. Omission is not the same as false — say false explicitly. |
A category: "layout" widget must declare ssr.url. A layout with ssr: false is accepted
by the publish audit but fails at render time:
// packages/homerunner-renderer/lib/render-utils.tsx — layout SSR path
plugin layout widgets require an SSR bundle (`ssr: false` is content-only)
See Layout widgets.
For a v1 (single-widget) plugin ssr.url is unconditionally required — a v1 manifest that
resolves to ssr: false produces the renderer failure reason
no plugin SSR URL in manifest. Only suite summaries may opt out.
What the SSR bundle must export, and the sandbox it runs in, are in Component and SSR module.
assets
assets.js is the only required member. Point each path at the build’s logical filename —
dist/{widget}/{widget}.iife.js — not at a content-hashed twin; the platform resolves the hashed
file itself from dist/manifest.json. See Keywords, assets and URLs for
the derived filenames and the two serving paths.
assets.fonts (widget-core 0.12.0+) is the only supported way to load web fonts, because
@font-face declared inside a shadow root is ignored by the browser. Up to 8 stylesheets per
widget; the client bundle injects them into document.head and the renderer appends them to the
server-rendered page’s stylesheet list. At publish, more than 8 or a malformed entry is an ERROR;
at resolve time the platform silently filters invalid entries and truncates to 8 (silent runtime
truncation), so a manifest that reached the fleet another way degrades rather than breaking.
Not validated before upload. The dashboard’s browser audit checks
assets.fontsonly insidewidgets[]. A v1 root-levelassets.fontsis honoured by the resolver and validated by central on submit, but nothing checks it in your browser first.
The publish audit also emits size warnings (never blocking) per widget: over 1.5 MB for the
client bundle, 512 KB for the stylesheet, and 1 MB for the SSR bundle — the last reading
dist/{name} is {n} MB — over the renderer's 1 MB cache threshold (slower cold SSR).
manifest — the sub-manifest path
A suite root manifest carries no schemas. Each widget’s configSchema and uiSchema live in a
separate file that the summary points at, conventionally widgets/{slug}.manifest.json:
// widgets/pdp-frame.manifest.json — written by your build:manifest step
{ "slug": "pdp-frame", "configSchema": { /* … */ }, "uiSchema": { /* … */ } }
Only the dashboard ever fetches this file, and only when it needs to draw a config form. The renderer never reads it. Consequences you should design around:
- Referenced but absent from the built zip → publish ERROR (see the table above).
- Present but with no
configSchema→ publish warning:widgets/{slug}.manifest.json has no configSchema — widget "{slug}"'s dashboard form will be empty. - Omit the
manifestfield entirely and the widget is legal, installable and renderable — the customer just gets a schemaless config panel. That is a supported (if unhelpful) state, not an error.
Generate this file; never hand-write it. The generator, the round-trip rules and the publish
warnings about extending widgetSchema are in
Config schema and UI schema.
icon and readme
Both are marketplace surface, both (widget-core 0.12.0+), and their path rules differ:
| Field | Accepted forms | Length cap |
|---|---|---|
icon | media/<file> — flat, no subdirectory — or an absolute http(s) URL | 2048 chars |
readme | media/<file>.md or media/<dir>/<file>.md — exactly one optional directory level; no absolute URL | 255 chars, file ≤ 64 KB |
media/ is authored content that nothing generates. It must be committed and present in your
SOURCE zip, because the reviewer only runs your build — a declared icon missing from your source
fails the PUBLISH, long after you were told the submission looked fine. See
Packaging and publishing rules.
Two traps:
- A
media/reference resolves only after the first publish. It is resolved against the plugin’s publishedmanifest_url; an unpublished submission has none, somedia/icons render as a fallback glyph in the review console while absolute-URL icons render immediately. - Filename charset. The publishable surface allows only
[A-Za-z0-9._-]per path segment.media/my icon.svgpasses the icon-shape check but is dropped from the upload with the warning"media/my icon.svg" won't be published — file names may only use letters, digits, ".", "_" and "-".and then fails withDeclared widget "{slug}" icon media/my icon.svg is missing from the zip.
When no per-widget icon resolves, the surface falls back to the plugin’s root icon, and then to
a glyph — Layers for a layout, Puzzle for content.
expectedHeight
An anti-CLS hint in CSS pixels, 0 <= n <= 10000. It must be a JSON number — "140" as a
string fails the browser audit even though central’s numeric check would accept it. It is used in
exactly two places:
- CSR-only widgets on server-rendered pages. The renderer emits
data-hr-min-height="{n}"on the empty host div, and only whenn > 0. A widget withssr.urlnever gets the attribute from the renderer — its server markup is the reservation. - The embed snippet. The dashboard’s Embed Code dialog bakes the same value into the copied HTML for any plugin widget, SSR or not, and it takes precedence over the platform’s own keyword-based hint.
<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx -->
<div data-hr-widget-container>
<div id="{widgetId}" data-hr-widget="plugin:pdp-suite:stay-facts" data-hr-min-height="140"></div>
</div>
Set it on every ssr: false widget. Omitting it on a CSR-only widget means the page reserves
nothing and shifts when your bundle paints. What consumes the attribute is described in
Runtime, mount and the DOM contract.
mediaSafeAuto
Strict true only — the resolver reads summary.mediaSafeAuto === true, so "true", 1 and
any other truthy value mean false. Declaring it opts the widget into parse-time auto
colour-scheme resolution: on a Declarative-Shadow-DOM-capable request the renderer stamps
data-hr-scheme="auto" on your host, so dark mode resolves at first paint with no JavaScript.
The stamp is applied only when all three hold: the widget’s resolved colorScheme is auto, the
request emitted DSD, and this flag is true.
This is a promise about your CSS, and nothing validates it. The stamp assumes your stylesheet dual-emits every dark rule under both standard prefixes. Declaring it without the dual emission paints a dark host with light chrome. The exact selectors, and the two footguns in writing them, are in Styling and theming.
externalFetch
A list of hosts your SSR bundle is allowed to fetch from. The resolver keeps only the string entries and hands the list to the SSR bundle loader; the sandbox then permits HomeRunner’s public API plus these hosts and blocks everything else.
Nothing validates this field at publish — neither the browser audit nor central. A typo is
not an error you will see at submission; it is a blocked fetch at render time. Host-vs-host:port
matching, the redirect cap, the private-address denylist and the exact block messages are in
Component and SSR module.
Client-side fetches are unaffected: externalFetch constrains the server sandbox only.
slots, accepts, pages
Layout-only fields. Declaring either slots or pages on a widget whose category is not
layout is a publish ERROR with the strings in the field index. Everything about their meaning —
slot-name rules, accepts filtering, prefill, page assignment, the mandatory slots field in
your zod config — belongs to Layout widgets.
deprecated and replacedBy
deprecated: true is a soft retirement, and it is how you remove a widget from a suite. It:
- removes the widget from the dashboard’s widget picker for every feed,
- disables Create widget for it on the plugin page and shows a
deprecatedbadge, - leaves every existing placement rendering exactly as before, and
- is diffed at review, so the reviewer sees
deprecated/un-deprecatedon the summary.
Deleting a slug instead breaks every customer widget that stores that keyword. Prefer
deprecated. This is not the same as a kill switch, which stops the assets serving — see
Install, customers and kill switches.
Not supported yet.
replacedByis declared in the SDK type and read by nothing. Setting it is harmless and invisible — see Known gaps.
What the platform fills in for you
Every consumer normalizes a summary through one function before using it. These defaults apply at runtime whatever the manifest says:
| Summary value | Resolved value |
|---|---|
name absent | the widget slug |
category anything but "layout" | "content" |
ssr absent, or {} with no url | false (CSR-only) |
mediaSafeAuto anything but strict true | false |
icon failing the media-ref check | dropped (falls back to the plugin icon, then a glyph) |
readme failing the readme-ref check | dropped |
pages on a content widget | undefined — content widgets can never serve a page |
pages empty or all-unknown on a layout | ["misc"] |
assets.fonts with invalid entries or more than 8 | filtered, then truncated to 8 |
externalFetch with non-string entries | filtered to strings |
assets.js absent | the whole widget fails to resolve (see below) |
// packages/homerunner-widget-core/src/manifest.ts — resolvePluginWidget, suite branch
const summary = manifest.widgets.find((w) => w.slug === widgetSlug);
if (!summary || !summary.assets?.js) return null;
return {
pluginSlug: manifest.id,
widgetSlug,
name: summary.name ?? widgetSlug,
category: summary.category === "layout" ? "layout" : "content",
ssr: summary.ssr === false ? false : summary.ssr?.url ? { url: summary.ssr.url } : false,
// …
mediaSafeAuto: summary.mediaSafeAuto === true,
icon: isPluginMediaRef(summary.icon) ? summary.icon : undefined,
readme: isPluginReadmeRef(summary.readme) ? summary.readme : undefined,
};
Resolution fails closed. A null result — unknown slug, missing assets.js, or a keyword whose
shape disagrees with the manifest’s — makes the renderer emit a hidden failure breadcrumb with
the reason keyword does not resolve against the plugin manifest instead of rendering anything.
The shape-mismatch table is in The two manifest shapes.
Where each rule is checked
Your summary passes three gates at three different moments. Knowing which is which saves a review cycle.
| Gate | When | Scope | Failure looks like |
|---|---|---|---|
| Dashboard source-zip audit | in your browser, before a single byte uploads | every rule in the field index above, plus the 24-widget cap and preset integrity | a list of messages in the upload dialog; all errors at once |
| Central’s manifest validator | on submit, after the upload | the same rules, re-checked server-side, plus widgets[].icon and widgets[].readme shape | HTTP 422 INVALID_MANIFEST with one message — the first failure only |
| Built-zip audit | at publish, in the reviewer’s browser | file presence: every declared bundle, hashed twin, sub-manifest, icon and readme; sizes; the HRPlugins registration marker | the publish is blocked; you are asked to resubmit with a bumped version |
Two consequences worth internalising:
widgets[].iconandwidgets[].readmeshapes are checked by central but not by the browser audit. A malformed per-widget media ref sails through the local audit and comes back as422 INVALID_MANIFESTwith, e.g.,Manifest widget "{slug}" has an invalid "icon" (a "media/<file>" path or an absolute http(s) URL, max 2048 characters).- File presence is only checked at publish. Everything in the field index that says “must
ship in the built zip” is invisible until an admin builds your source. Commit
media/andwidgets/*.manifest.json. See Preflight and submit.
Central also applies its own root-level caps that the browser audit does not: name and id
≤ 255 chars, version ≤ 64, description ≤ 4000, author.name / author.url ≤ 255. Those are
root fields — see Manifest: root fields.
A complete summary, both kinds
// hr-plugins/pdp-suite/manifest.json — the shipped v1.3.3 manifest, widgets[] excerpt
"widgets": [
{
"slug": "pdp-frame",
"name": "PDP Frame",
"category": "layout",
"slots": [
{ "name": "hero", "prefill": ["property-title", "gallery"] },
{ "name": "main", "prefill": ["property-meta", "bednbath", "description", "amenities", "calendar"] },
{ "name": "sidebar", "prefill": ["booking"] },
{ "name": "bottom", "prefill": ["reviews"] }
],
"ssr": { "url": "dist/pdp-frame/pdp-frame-ssr.umd.js" },
"assets": {
"js": "dist/pdp-frame/pdp-frame.iife.js",
"css": "dist/pdp-frame/pdp-frame.css",
"fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
},
"manifest": "widgets/pdp-frame.manifest.json",
"pages": ["pdp", "misc"],
"icon": "media/pdp-frame.svg",
"readme": "media/widgets/pdp-frame.md"
},
{
"slug": "stay-facts",
"name": "Stay Facts",
"ssr": false,
"assets": {
"js": "dist/stay-facts/stay-facts.iife.js",
"css": "dist/stay-facts/stay-facts.css"
},
"expectedHeight": 140,
"manifest": "widgets/stay-facts.manifest.json"
}
]
Read the two entries against the rules: the layout declares ssr.url because it must,
pages because it wants to be assignable, and no expectedHeight because it server-renders.
The CSR-only widget declares ssr: false explicitly, ships no ssr-entry.ts, and reserves
140 px.
Known gaps
One field of a summary does nothing at all:
Not supported yet.
replacedByis stored in the manifest, accepted by every validator and read by no runtime, UI or review code — not the renderer, not the dashboard, not the review diff. It surfaces nowhere. Name the successor in the widget’sreadmeinstead.
Three more are honoured at runtime but never checked before you ship, so a mistake in them costs you a released version rather than a failed upload:
Not validated.
externalFetchis checked by neither audit nor by central. A host you typo’d is not an error at submission; it is a blocked fetch at render time.
Not validated.
mediaSafeAutois a promise about your CSS that nothing can verify. Nothing inspects your stylesheet for the dual-emitted dark rules the flag requires, so a wrong declaration ships silently and paints a dark host with light chrome.
Not validated before publish.
widgets[].iconandwidgets[].readmeshapes are checked by central on submit but not by the browser audit, and whether the declaredmedia/file actually exists is checked only by the built-zip audit at publish. A submission can be accepted and then fail to publish on media alone.
Layout widgets
A layout widget renders no content of its own. It declares named slots, the customer
binds other widgets into them, and the platform hands your component the already-rendered
children as renderedSlots. A layout can also replace the platform’s own page layout on a
server-rendered page.
Layouts exist only in the suite (multi-widget) manifest shape. resolvePluginWidget
hardcodes category: "content" for every v1 single-widget manifest
(packages/homerunner-widget-core/src/manifest.ts), so a single-widget plugin can never
ship a layout — and the shape is frozen at first publish. See
The two manifest shapes.
Everything on this page needs widget-core 0.11.0+, except pages (0.12.0+) and
slots[].prefill (0.12.1+).
Enforcement levels used below — publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated) — are defined once in Limits and error index.
Requirements at a glance
| Requirement | Shape | Rule | On violation |
|---|---|---|---|
category: "layout" on the widget summary | Suite | Exactly the string layout. Any other value normalizes to content at resolve time | Unknown string → publish ERROR manifest widget "{slug}" has an unknown "category" (content | layout). |
ssr: { "url": … } on the widget summary | Suite (layout) | An SSR bundle is mandatory. A layout composes its children server-side | "ssr": false passes the publish audit (advisory), then fails at render — see SSR |
slots[] on the widget summary | Suite (layout) | Declares the named regions | See slots[] |
A slots object in your zod config | Suite (layout) | The dashboard has nowhere to store bindings without it | Silent: the slot editor renders, edits are dropped on save. advisory |
default export in the SSR bundle | Suite (layout) | The only required SSR export | Runtime failure node, page degraded |
registerPluginWidget(…, { category: "layout" }) | Suite (layout) | Records the category for the client path | Silent: the shell renders with no renderedSlots, and on a server-rendered page the client render replaces the server’s slot children. advisory |
A layout is not allowed to nest inside another layout, and its shell can never be interactive on a server-rendered page. Both are covered below.
Manifest: category
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
category | "content" | "layout" | Optional (Suite) — defaults to content | Exactly one of the two literals | publish ERROR: manifest widget "{slug}" has an unknown "category" (content | layout). |
At resolve time the rule is summary.category === "layout" ? "layout" : "content" —
anything that is not the literal layout silently becomes a content widget, taking
slots and pages with it (both resolve to undefined on content widgets).
Rendering a layout through the content path is refused:
// packages/homerunner-renderer/lib/render-utils.tsx — console.warn, then a failure node
[plugin] "plugin:my-suite:frame" is a layout widget — it must render as a layout entry (with slots), not content. Skipping.
// failure-node reason:
plugin layout widget rendered as content — needs a layout entry with slots
Manifest: slots[]
slots is layouts-only. Declare one entry per region your component renders.
// pdp-suite 1.3.3 (the reference suite) — manifest.json, the pdp-frame summary
{
"slug": "pdp-frame",
"name": "PDP Frame",
"category": "layout",
"slots": [
{ "name": "hero", "prefill": ["property-title", "gallery"] },
{ "name": "main", "prefill": ["property-meta", "bednbath", "description", "amenities", "calendar"] },
{ "name": "sidebar", "prefill": ["booking"] },
{ "name": "bottom", "prefill": ["reviews"] }
],
"ssr": { "url": "dist/pdp-frame/pdp-frame-ssr.umd.js" },
"assets": {
"js": "dist/pdp-frame/pdp-frame.iife.js",
"css": "dist/pdp-frame/pdp-frame.css",
"fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
},
"manifest": "widgets/pdp-frame.manifest.json",
"pages": ["pdp", "misc"],
"icon": "media/pdp-frame.svg",
"readme": "media/widgets/pdp-frame.md"
}
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
slots | Array<{name, accepts?, prefill?}> | Optional (Suite, layouts only) | Must be an array; only on category: "layout" | publish ERROR: manifest widget "{slug}" declares "slots" but is not a layout. / manifest widget "{slug}" needs a "slots" list. |
slots[].name | string | Required when the entry exists | Non-empty, ≤ 64 characters, unique within the widget | publish ERROR: manifest widget "{slug}" has an invalid or duplicate slot name. |
slots[].accepts | string[] | Optional | Filters the dashboard slot picker only. Omitted = ["content"] | Never validated — advisory (unvalidated) |
slots[].prefill | string[] | Optional (0.12.1+) | System widget keywords, ≤ 8 per slot | publish ERROR — see prefill |
The name check breaks out of the slot loop on the first bad entry, so a manifest with
two problems reports one error per audit run — fix and re-run.
A slot the manifest declares is not automatically a slot that renders. What renders is
derived from the customer’s stored bindings; see
settings.slots and
Page assignment.
slots[].accepts
accepts is read by exactly one consumer: the playground’s slot editor. A candidate
widget is offered when accepts contains its category (content / layout) or its
exact keyword (gallery, plugin:acme:hero). The layout’s own keyword is always
excluded, so a layout can never bind another instance of itself.
// src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
const accepts = slot.accepts?.length ? slot.accepts : ["content"];
const filterFn = (keyword: string) =>
typeof keyword !== "string" ||
(keyword !== widget.keyword &&
(accepts.includes(categoryOf(keyword)) || accepts.includes(keyword)));
Not supported yet.
accepts: ["layout"]can never admit anything. The same picker is always rendered withexcludeLayouts, which drops every layout-category row before theacceptspredicate runs — and even a hand-crafted binding is skipped by both render paths, because layouts cannot nest.
accepts is not validated at publish and is not read by the renderer. Treat it as a hint
to the customer, never as a guarantee about what your slot will contain.
slots[].prefill
(widget-core 0.12.1+) prefill lists the system widget keywords a slot expects.
When a customer creates the layout, the dashboard binds the feed’s existing widget for
each keyword so the layout opens populated instead of empty.
| Rule | Enforcement |
|---|---|
Each entry matches /^[a-z][a-z0-9-]{0,63}$/ | publish ERROR: manifest widget "{slug}" slot "{name}" has invalid prefill keyword(s): {list} (system widget keywords only, e.g. gallery). |
| At most 8 entries per slot | publish ERROR: manifest widget "{slug}" slot "{name}" needs a "prefill" list of at most 8 keywords. |
plugin: references are rejected by the same regex | publish ERROR (same message). Compose your own widgets with presets instead |
| At resolve time: malformed entries dropped, duplicates removed, list sliced to 8 | silent runtime truncation (normalizeLayoutSlots) |
What the dashboard does at creation time, in order:
- Union every slot’s
prefillkeywords, in declared order. - Create the ones the feed has no active widget for, with the platform’s own defaults. A
keyword this platform cannot create is reported in a toast as
{keyword}: not a widget this platform can createand skipped. - Bind the feed’s oldest active widget per keyword (
status === falsewidgets are skipped), in declared order, deduped within a slot. - Merge the result into the new widget’s
settings.slots. Slots that resolved to no ids are omitted entirely — the key never appears.
Prefill is creation-time only. It is never re-applied to an existing layout, and the renderer never reads it.
Not supported yet.
prefillkeywords are shape-validated only. Nothing checks them against the real system-widget keyword list, so a typo publishes cleanly and silently prefills nothing.
The mandatory slots config field
The dashboard’s slot editor is injected into your own config form. For each manifest
slot it writes a SlotWidgetArrayContainer of WidgetSelect rows under the form’s
slots object field. If your zod config does not declare that field, there is nowhere to
store bindings and the customer’s edits are dropped on save.
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/config.ts
export const configZod = widgetSchema.extend({
showSlotLabels: z.boolean().default(false).describe("Show slot region labels"),
stickyRail: z.boolean().default(true).describe("Keep the sidebar rail pinned"),
// Keys MUST match the manifest summary's slot names, exactly.
slots: z
.object({
hero: z.array(z.string()).default([]),
main: z.array(z.string()).default([]),
sidebar: z.array(z.string()).default([]),
bottom: z.array(z.string()).default([]),
})
.default({})
.describe("Widgets bound into each region"),
});
export const configSchema = zodToManifestSchema(configZod);
Rules:
- The object keys must equal the manifest slot names, character for character. A key with no matching manifest slot gets no editor; a manifest slot with no key has nowhere to save.
- Use
z.array(z.string()).default([])per slot and.default({})on the object. Both defaults matter: the field must surviveconfigZod.parse({})at creation time. - Do not add a
uiSchemaentry forslots. The dashboard replaces the wholeslotskey with its own panel: label Slot Configuration, description “Assign the widgets that render inside this layout’s slots”,order: -50(it sorts to the top). Each row is titled{slot} slotwith the descriptionaccepts: {list}. See Config schema and UI schema for why a plugin entry replaces a top-level key wholesale.
settings.slots: the stored bindings
Bindings are stored on the layout widget instance, not in the manifest:
// Read by packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx (CSR)
// and packages/homerunner-renderer/lib/render-utils.tsx (SSR), out of widget.settings
"slots": {
"hero": ["cuid-of-a-widget", "cuid-of-another"],
"sidebar": ["cuid-of-a-third"]
}
Record<slotName, widgetId[]>. Array order is render order.
- CSR reads it structurally out of the widget’s settings and resolves each id to its keyword.
- SSR converts the ids to slot bindings and drops ids whose widget no longer exists on the feed (nothing prunes a slot when a widget is deleted).
- On an assigned page, the slot names the PDP renders are the keys present in the
stored
settings.slots— a slot the customer never bound simply does not appear. See Page assignment.
Settings arrive at your component raw — never zod-parsed for plugins, on either path. Parse them yourself; see Settings and options.
The component contract
Import the real type rather than hand-rolling it:
// packages/homerunner-widget-core/src/contracts.ts
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 type { LayoutWidgetProps } from "@homerunner-next/widget-core/contracts";
What each path actually passes:
| Prop | SSR (server-rendered page) | CSR (single-snippet embed) |
|---|---|---|
options | RAW merged settings | RAW merged settings |
renderedSlots | Record<slotName, ReactNode> | Record<slotName, ReactNode> |
feedId | yes | yes |
widgetId | yes | yes |
resolvedTheme | cookie-derived, effectively always "light" in production | resolved on the client |
data | result of getInitialData, else undefined | always undefined |
widgetType | absent | present |
target | absent | absent |
LayoutWidgetProps declares widgetType as required, but the SSR path does not supply
it. Reading it during render is a hydration-mismatch trap — treat both widgetType and
target as optional in a layout.
renderedSlots differs between the paths for an empty slot:
- SSR: the key is present only when at least one child actually rendered. An unbound
slot, or one whose every binding was dropped, is
undefined. - CSR: every key of the stored
settings.slotsis present; an empty slot is[]— truthy, and renders nothing.
Write renderedSlots?.[name] ?? null and never assume a key exists.
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/widget.tsx
import React, { type ReactNode } from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";
import "./pdp-frame.css";
export default function PdpFrame(props: {
options?: Record<string, unknown>;
renderedSlots?: Record<string, ReactNode>;
}) {
const cfg = parseWidgetConfig(configZod, props.options ?? {});
const slots = props.renderedSlots ?? {};
const region = (name: string, node: ReactNode) => (
<section className={`pdpf-region pdpf-${name}`} data-testid={`pdpf-${name}`}>
{cfg.showSlotLabels ? <span className="pdpf-label">{name}</span> : null}
{node ?? (cfg.showSlotLabels ? <div className="pdpf-empty">empty “{name}” slot</div> : null)}
</section>
);
return (
<div className={`pdpf${cfg.stickyRail ? " pdpf-sticky-rail" : ""}`} data-testid="pdp-frame">
{region("hero", slots.hero)}
{region("main", slots.main)}
{region("sidebar", slots.sidebar)}
{region("bottom", slots.bottom)}
</div>
);
}
The SSR entry is one line — a layout needs no getInitialData, dehydrateState or
getStaticAssets:
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/ssr-entry.ts
// SSR entry — the renderer hands `renderedSlots` to the default export.
export { default } from "./widget";
Those three hooks are still supported on a layout and run before the shell renders, inside the same try/catch. A throw in any of them replaces the whole layout with a failure node. See Component and SSR module.
Registration and mount
// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import PdpFrame from "./widget";
import "./pdp-frame.css";
registerPluginWidget("pdp-suite", "pdp-frame", PdpFrame, { category: "layout" });
function doMount() {
document
.querySelectorAll<HTMLElement>("[data-hr-widget-container]")
.forEach((container) => {
if (container.querySelector('[data-hr-widget="plugin:pdp-suite:pdp-frame"]')) {
mount(container, {
widget: PdpFrame,
widgetType: "plugin:pdp-suite:pdp-frame",
category: "layout",
});
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", doMount);
} else {
doMount();
}
registerPluginWidget(plugin, widget, Component, { category: "layout" }) writes
// packages/homerunner-widget-core/src/globals.ts
window.HRPlugins["{pluginSlug}:{widgetSlug}"] = { component, category: "layout" };
category is omitted from the registry entry when you do not pass meta.category.
mount derives an effective category for plugin keywords as
config.category ?? getRegisteredWidget(widgetType)?.category. The registry write sits in
the same IIFE and therefore always precedes the mount, so a bootstrap that forgets
category on the mount() call still routes correctly. Pass it anyway — it is the
documented contract and it makes the file readable. See
Runtime, mount and the DOM contract for the full MountConfig.
Omitting it in both places is the one genuinely destructive mistake here. Without an
effective category, mount treats the shell as a content widget: the SSR no-op guard never
fires, and because a layout shell carries no data-hr-ssr-id and no props-registry entry
there is nothing to hydrate — so it takes the client-render path and replaces the server’s
shell, slot children and all, with an empty client render.
How a layout renders
SSR: the server composes your children
On a server-rendered page the renderer:
- Collects every binding across every slot. A binding whose widget is missing from the
feed is logged
[layout] Slot "{name}" references missing widget {id}and skipped. - Renders all slot children concurrently. Each child is a full island with its own SSR id, its own Declarative Shadow DOM template, its own props-registry entry and its own assets.
- Reassembles each slot in binding order into
renderedSlots[slotName] = <>{nodes}</>. - Loads your layout’s SSR bundle last, runs the optional data hooks, renders the shell, and unshifts the layout’s CSS and JS in front of every child’s assets.
The shell is emitted as static light DOM:
// packages/homerunner-renderer/lib/render-utils.tsx
<div
data-hr-widget={widgetConfig.type}
data-hr-widget-category="layout"
id={`${widgetConfig.id}:${widgetConfig.feed.id}`}
{...layoutHostThemeAttrs(/* data-hr-scheme="auto" or data-hr-theme="light|dark" */)}
>
<LayoutComponent {...props} /> {/* no <template shadowrootmode>, no shadow root */}
</div>
Three consequences that look like bugs and are not:
- No shadow root, no DSD template. Your layout stylesheet is loaded document-level, at page scope. See Layout CSS.
- No
data-hr-ssr-idand no props-registry entry. Only slot children get those. - The client mount is a deliberate no-op.
mountreturns early when the effective category islayoutand the root already has server-rendered children. Running the CSR path there would discard the server’s slots and re-fetch everything client-side.
Not supported yet. Because of the three points above, a plugin layout shell can never be interactive on a server-rendered page: no React tree is ever attached to it. Its slot children each hydrate themselves normally. Put interactivity in a content widget and bind it into a slot.
CSR: the layout composes its own children
In a single-snippet embed the shell does get a shadow root, and LayoutCsrRenderer
rebuilds the slot tree at runtime:
- Fetch the layout widget + feed, read
settings.slots. - Per child id: fetch its widget row to learn its keyword, skip nested layouts, load the
child’s IIFE so it self-registers, then wait for registration (hard 10 s timeout, per
child:
[widget-core] widget "{keyword}" did not register within 10000ms→ that slot child stays empty, the rest render). - Render your component with
renderedSlots= arrays of childless host divs ([data-hr-widget-container] > [data-hr-widget={keyword}][data-hr-options]). - After commit, imperatively
mounteach child into its host.
Child script URLs come from config.resolveChildJsUrl or, by default,
window.HRWidgetRuntime.resolveChildJsUrl — supplied by the host runtime bundle. Plugin
builds carry no URL rules and you never script-tag children yourself.
The layout pushes two things onto every child: the reserved __parentColorScheme key
(which only affects children whose own colorScheme is global) and, when the layout
resolves one, filter.property. Reading the pushed property is covered in
Composing a page.
Failure semantics
| What throws | Result |
|---|---|
A data hook (getInitialData / dehydrateState / getStaticAssets) on SSR | The layout is replaced by a hidden <div data-hr-widget-error="{keyword}" hidden> breadcrumb with reason layout SSR data prep failed: {message}; the page still responds — an assigned page layout failing this way leaves the page empty — and is marked degraded (60 s TTL). Already-rendered slot children are discarded |
| The layout component during SSR render | The whole page render 500s. The shell element is returned as a React node and rendered later in the page-level renderToString, which has no try/catch |
| The layout component on CSR | Caught by WidgetRenderBoundary: data-hr-error="render-failed" on the host plus the shared error state |
Parse defensively and default every slot. A content widget that throws costs one widget; a layout that throws costs the customer’s whole page.
Every failure reason a layout can produce, verbatim:
// packages/homerunner-renderer/lib/render-utils.tsx
// Content path — a layout keyword rendered as a plain content entry.
keyword does not resolve against the plugin manifest
plugin layout widget rendered as content — needs a layout entry with slots
// Layout path — every throw is wrapped as `layout SSR data prep failed: {message}`.
layout SSR data prep failed: keyword does not resolve against the plugin manifest
layout SSR data prep failed: "plugin:{slug}:{widget}" is not a layout widget (category: content)
layout SSR data prep failed: plugin layout widgets require an SSR bundle (`ssr: false` is content-only)
layout SSR data prep failed: SSR bundle failed to load from {url}
// A slot child that is itself a SYSTEM layout.
layout-in-layout nesting not allowed
A slot child that is a plugin layout produces no breadcrumb at all — it is dropped with a console line and the rest of the slot renders.
Layouts cannot nest
Refused on both paths, before anything renders:
// packages/homerunner-renderer/lib/render-utils.tsx — SSR, checked against the summary's category
[layout] Plugin layout widgets are not supported in slots yet. Slot "{name}" references "{keyword}". Skipping.
// packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx — CSR, checked against the registration
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest.
System layouts are caught separately by their -layout keyword suffix. Nothing in the
manifest expresses “this layout may nest”.
Layout CSS
A layout stylesheet must work in two environments:
- Document level, unisolated, on every server-rendered page (the shell is light DOM and
your CSS is appended to the page
<head>). - Inside a shadow root, on a CSR embed.
The rules that follow from that:
| Rule | Why |
|---|---|
Namespace every selector with a class prefix (.pdpf, .pdpf-region) | On an SSR page your rules apply to the customer’s whole document |
Never write a :host-only rule for the shell | :host matches nothing in light DOM, so the rule is inert on every server-rendered page |
| Never paint a background on the shell | Product rule: the host page owns the page canvas. The platform’s own stylesheet excludes [data-hr-widget-category="layout"] from its background paint on both paths |
Your layout’s CSS is unshifted ahead of the slot children’s stylesheets in the page’s CSS list, so a child’s rules win on source order at equal specificity.
/* pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/pdp-frame.css */
/* Light-DOM-safe shell: class-based rules only, no :host-only styles, no
painted background (the same CSS also runs inside a shadow root on CSR
embeds). */
.pdpf {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-areas:
"hero sidebar"
"main sidebar"
"bottom bottom";
gap: 28px 32px;
align-items: start;
}
.pdpf-hero { grid-area: hero; }
.pdpf-main { grid-area: main; }
.pdpf-sidebar { grid-area: sidebar; }
.pdpf-bottom { grid-area: bottom; }
.pdpf-sticky-rail .pdpf-sidebar { position: sticky; top: 24px; }
@media (max-width: 860px) {
.pdpf {
grid-template-columns: 1fr;
grid-template-areas: "hero" "sidebar" "main" "bottom";
}
.pdpf-sticky-rail .pdpf-sidebar { position: static; }
}
Plugin layouts do not inherit the platform’s slotSpacing / gap settings — declare your
own spacing fields. See Styling and theming.
pages and page assignment
(widget-core 0.12.0+) pages declares the server-rendered pages a layout may be
assigned to. Being assignable is an explicit declaration, never an accident.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
pages | PluginPageKey[] | Optional (Suite, layouts only) | Non-empty; every entry one of the six keys below; only on category: "layout" | publish ERROR: manifest widget "{slug}" declares "pages" but is not a layout. · manifest widget "{slug}" needs a non-empty "pages" list. · manifest widget "{slug}" has unknown page(s) in "pages": {list} (pdp | listings | checkout | confirmation | collection | misc). |
// packages/homerunner-widget-core/src/manifest.ts
export const PLUGIN_PAGE_KEYS = ["pdp", "listings", "checkout", "confirmation", "collection", "misc"] as const;
| Key | Meaning | Assignable today |
|---|---|---|
pdp | Property detail page | Yes |
listings | Listings page | Yes |
checkout | Checkout page | Yes |
confirmation | Confirmation page | Yes |
collection | A collection route’s layout | No — reserved vocabulary |
misc | Standalone embeds only | No, by definition |
At resolve time unknown keys are dropped and an absent or empty list becomes ["misc"] —
silent, not an error. Content widgets always resolve pages: undefined.
Not supported yet.
collectionis declared vocabulary with no assignment surface. The collection-route picker only offers instances of the systemlistings-layoutkeyword, so a plugin layout declaringcollectioncannot be chosen anywhere.
What the customer does
Feed → Page URLs → Page layouts: one row per server-rendered page, each a picker of Automatic plus every eligible layout on the feed. Your layout appears there when its plugin is approved and enabled on the feed, the widget is not kill-switched, and its summary declares that page. The assignment is stored on the feed at:
// src/lib/homerunner/reverse-proxy-routes.ts + packages/homerunner-renderer/lib/page-layouts.ts
feeds.additional_info.reverseProxy.pageLayouts = { pdp?, listings?, checkout?, confirmation? }
Absent or null means Automatic (the feed’s base system layout for that page).
What the renderer does with an assignment
An assigned plugin layout renders with backfill: false — stored bindings only.
Unlike a system layout, nothing is backfilled from the platform’s default slot map, so a
slot the customer never bound simply does not appear. Bindings pointing at deleted widgets
are dropped first.
The slot names the page renders differ by page, and this is the sharpest edge in the whole contract:
| Page | Slot names rendered | Extra rule |
|---|---|---|
pdp | The keys of the stored settings.slots — your own vocabulary | Renders every binding in every slot |
listings, checkout, confirmation | Fixed to ["content"] | Only slots.content is passed to your layout, and the page 404s when it resolves to no live widget |
So a layout that declares listings, checkout or confirmation must name one of its
slots exactly content and the customer must bind at least one widget into it. Every
other slot you declare receives nothing on those three pages. Their handlers return
{"error":"Explorer widget not found for this feed"},
{"error":"Checkout widget not found for this feed"} and
{"error":"Confirmation widget not found for this feed"} respectively, with HTTP 404 —
the page does not degrade, it fails.
For the property page, mirror the system layout’s slot vocabulary — hero, main,
sidebar, bottom — so the platform’s own widgets drop straight in.
Degrading to Automatic
Anything the renderer cannot honour falls back to Automatic and never 404s the page. Each reason is logged once:
// packages/homerunner-renderer/lib/page-layouts.ts
[page-layouts] feed {feedId} {page}: {reason} — using Automatic
// reasons:
assigned layout {id} is not on the feed (deleted or paused)
assigned widget {id} ({keyword}) is not a layout for "{page}"
assigned plugin layout {id} ({keyword}) is not live (plugin suspended, widget disabled, or not enabled on the feed)
assigned plugin widget {id} ({keyword}) does not declare the "{page}" page
Not supported yet. The dashboard and the renderer disagree about
deprecated. The dashboard drops a deprecated layout from the picker and flags a stored assignment as an error (“{display name}” cannot render the property page.), but the renderer does not checkdeprecatedat all — an already-assigned deprecated layout keeps being server-rendered. Deprecating a layout does not take it off a page; ask the customer to switch back to Automatic.
Presets
(widget-core 0.11.0+) A preset is a root-level, one-click composition: the widget picker offers it as its own entry that creates the layout, its children, and the slot bindings — atomically.
// pdp-suite 1.3.3 (the reference suite) — manifest.json, root level
"presets": [
{
"name": "PDP Starter",
"layout": "pdp-frame",
"slots": {
"hero": ["stay-hero", "property-title", "gallery"],
"main": ["stay-facts", "description", "amenities", "calendar"],
"sidebar": ["booking", "booking-cta"],
"bottom": ["reviews"]
}
}
]
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
presets | Array<{name, layout, slots}> | Optional (Suite) | Array, at most 8 entries | publish ERROR: manifest "presets" must be a list of at most 8 entries. |
presets[].name | string | Required | Non-empty | publish ERROR: each manifest preset needs a "name". |
presets[].layout | string | Required | A declared widget slug of this plugin with category: "layout" | publish ERROR: preset "{name}" must reference a declared layout widget. |
presets[].slots | Record<string, string[]> | Required | An object of arrays | publish ERROR: preset "{name}" needs a "slots" object. / preset "{name}" has an invalid slot binding shape. |
presets[].slots[*][] | string | — | Either a declared non-layout widget slug of this plugin, or a system widget keyword (/^[a-z][a-z0-9-]{0,63}$/) | publish ERROR: preset "{name}" references "{child}" which is neither a declared content widget nor a system widget keyword. |
A single slot array may freely mix your own slugs with system keywords, as PDP Starter
does.
What one click creates, in order:
- System-keyword children: the feed’s oldest active widget per keyword, creating the missing ones with platform defaults first. Never duplicated per preset.
- One instance of each referenced own content slug, display-named
{typed name} — {widget name}(the name the customer entered for the preset, then the widget’s own name), with settings = schema defaults + the feed’s branding +filter.feed_id. - The layout, with
settings = { ...layoutDefaults, slots }mapping each preset slot name to the created/ensured ids. - A redirect to the new layout’s playground.
A failure part-way through best-effort deletes the children it just created, then surfaces the original error.
In the picker the entry is labelled {plugin name}: {preset name}, described “One click
creates the “{layout}” layout with its slot widgets bound.“, and carries a chip listing
the layout and its children. See Composing a page.
Not supported yet. Preset slot keys are never validated against the layout’s declared slot names. A typo (
sidbar) publishes cleanly and creates a binding your layout never renders — the widget is created, bound, and invisible. Check your keys by hand againstslots[].name.
The customer-facing surface
- The widget picker lists every non-deprecated widget of an enabled plugin, layouts
included. A layout’s description gets
(layout — hosts other widgets in its slots)appended and it takes a Layers glyph instead of the Puzzle glyph. Per-widgeticonfalls back to the pluginicon, then to the glyph. deprecated: trueremoves the widget from the picker and disables Create widget on the plugin page. Existing placements keep rendering — see Manifest: widget summary.- Slot bindings are edited in the layout’s playground under Slot Configuration.
Known gaps, collected
Not supported yet. Each of these is repeated in context above.
accepts: ["layout"]can never match: the slot picker excludes layouts first, and both render paths refuse a nested layout.- Preset slot keys are not validated against the layout’s declared slot names.
prefillkeywords are shape-validated only, never checked against the real system-widget keyword list.- A plugin layout on a server-rendered page can never be interactive — no
data-hr-ssr-id, no registry entry, and the client mount is a no-op.collectionis reserved vocabulary with no assignment surface.- The renderer keeps server-rendering an assigned layout that is
deprecated, which the dashboard reports as an error.- No local harness composes a layout’s slots.
dev:ssr -- --widget {layout}previews the shell with every slot empty, becauserenderPluginSSRbuilds the props itself and passes norenderedSlots(widget-core 0.12.2). See Previewing SSR locally.
Checklist
- Suite manifest (
widgets[]), not the v1 shape. -
"category": "layout"on the summary. -
"ssr": { "url": … }— neverfalse. -
slots[]with unique names ≤ 64 chars;prefill≤ 8 system keywords per slot. -
pagesonly if you want the layout assignable; include a slot namedcontentif you declarelistings,checkoutorconfirmation. - A
slotszod object inconfig.tswhose keys match the manifest slot names exactly. -
ssr-entry.ts=export { default } from "./widget";. -
registerPluginWidget(…, { category: "layout" })andmount(…, { category: "layout" }). -
parseWidgetConfig(configZod, props.options ?? {})in the component. -
renderedSlots?.[name] ?? nulleverywhere — never assume a key exists. - Class-namespaced CSS, no
:host, no background on the shell. - The component cannot throw. A layout throw 500s the customer’s page.
A step-by-step build of exactly this is in Add a layout widget.
Config schema and UI schema
Two JSON artifacts drive the settings form a customer sees in the dashboard:
configSchema— a JSON Schema object describing every setting your widget accepts.uiSchema— an optional symbolic map naming which form control renders each field.
You never hand-write either. You write one zod schema that extends the shared base
widgetSchema, and export zodToManifestSchema(configZod). Everything on this page is a
consequence of that round-trip.
Enforcement levels used below (publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated)) are defined once in limits-and-errors.md. Every rule on this page applies to both manifest shapes unless a row or heading says Single-widget or Suite.
Where the two artifacts live
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
configSchema (manifest root) | JSON Schema object | Single-widget: expected | The dashboard reads it directly off the cached root manifest. | publish warning: manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty. |
uiSchema (manifest root) | object | Single-widget: optional | Symbolic UI for your own fields only. | advisory (unvalidated) |
widgets[].manifest | confined relative path | Suite: expected | Points at widgets/{slug}.manifest.json, which carries that widget’s configSchema + uiSchema. Field rule lives in widget-summary.md. | publish ERROR if declared and absent from the built zip |
configSchema / uiSchema on a suite ROOT | — | Suite: never read | Nothing reads them once widgets[] is non-empty. Ship them per widget. | silently ignored |
In a suite, every widget owns its own src/widgets/{slug}/config.ts. See
manifest-shapes.md for how the platform tells the shapes
apart.
The pipeline
# traced through packages/homerunner-widget-core/src/plugin-schema.ts,
# packages/create-hr-plugin/template/scripts/inject-schema.mjs,
# src/lib/homerunner/plugin-widgets.ts and src/lib/plugin-ui-adapter.tsx
src/config.ts
widgetSchema.extend({ ...your fields }) zod, in your project
│
├─ zodToManifestSchema(configZod) widget-core /plugin-schema
│ → JSON Schema (refs inlined)
│
└─ uiSchema: PluginUiSchema string component names
│
scripts/inject-schema.mjs (yours, not the SDK's)
→ manifest.configSchema / manifest.uiSchema (single-widget)
→ widgets/{slug}.manifest.json (suite)
│
dashboard: @n8n/json-schema-to-zod JSON Schema → zod, in the browser
+ resolvePluginUiSchema() names → React components
→ the same <Form> system widgets use
│
autosaved to central as opaque JSON
│
props.options at runtime — RAW, unparsed, on BOTH server and client
→ parseWidgetConfig(configZod, props.options)
The round trip through JSON Schema is lossy. zodToManifestSchema runs
zodToJsonSchema(schema, { $refStrategy: "none" }) and then strips $schema, $defs,
definitions, $ref and the boolean additionalProperties: false marker, while
preserving schema-valued additionalProperties so z.record(...) survives the trip back
to zod. It recurses only into properties, items and additionalProperties — anyOf /
allOf / oneOf subtrees pass through untouched. What survives is tabulated below.
The base schema: widgetSchema
Every widget, first-party or plugin, extends the same base. It has exactly nine top-level
fields, in this order. All are defaulted, so the emitted JSON Schema carries no root
required array.
| Field | Shape | Default | Who renders it |
|---|---|---|---|
spacing | {margin, padding, width, maxWidth}, each side {value, unit} | margin 0 auto (top/bottom 0px, left/right auto), padding all 0px, width {0,"auto"}, maxWidth {100,"%"} | Dashboard: one compound Spacing control. You: apply resolveWidgetSpacingStyle(options) to your outer element. |
colorScheme | "light" | "dark" | "auto" | "global" | "global" | Dashboard: labelled select (Global (inherit from feed)). Resolved before you see it. |
filter | {feed_id?: int, platform?: string|null, property?: string} — .optional() | none | Dashboard: Filter Settings panel; feed_id is rendered but hidden. |
lightModeColors | {accent: string} | #f59e0c | Dashboard: colour picker, hidden when colorScheme === "dark". You: apply the colour. |
darkModeColors | {accent: string} | #f59e0c | Dashboard: colour picker, hidden when colorScheme === "light". You: apply the colour. |
font | {size: number, family: string} | {size: 16, family: "Inter"} | Dashboard: size input + Google-font picker. You: apply both. |
language | {useAutoDetectedLocale: boolean, locale: string} | {true, "en_US"} | Dashboard: toggle + locale picker (picker hidden while auto-detect is on). |
section | {show: boolean, title: string|null} | {show: false, title: ""} | Dashboard: Section Header panel. You: render the heading. |
customCss | string | null | "" | Dashboard: CSS editor. You: render it. |
There is no width field. spacing replaced the old width: {maxWidth, unit} pair
(widget-core 0.9.0+ — see
../get-started/01-install-and-versions.md for
which versions you can actually install). Old saved values are lifted onto spacing
automatically by parseWidgetConfig. A plugin whose own schema still declares width keeps a
compatibility Width Settings panel; a plugin that does not declare width never renders
that entry.
Not supported yet. The platform applies none of
customCss,font.size,font.familyor the accent colours for you — not on the server and not inmount(). They arrive inoptionsand your component must render them. See ../recipes/styling-and-theming.md.
Skipping the base is a publish warning, checked on the built zip:
# src/lib/plugin-zip.ts — warnIfSchemaSkipsBase()
{label}'s configSchema doesn't extend the base widgetSchema (no colorScheme/spacing) —
standard theme + spacing controls won't appear. Build config.ts as widgetSchema.extend({...})
and export zodToManifestSchema(configSchema).
{label} is the plugin for a single-widget manifest and widget "{slug}" for each suite
widget. The check is literally “does properties contain both colorScheme and spacing”.
Writing config.ts
// packages/create-hr-plugin/template/src/config.ts — the scaffold, verbatim
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
zodToManifestSchema,
type PluginUiSchema,
} from "@homerunner-next/widget-core/plugin-schema";
export const configZod = widgetSchema.extend({
title: z.string().default("My Widget").describe("Widget title"),
});
/** Symbolic UI for plugin-specific fields only. */
export const uiSchema: PluginUiSchema = {
title: { component: "TextInput" },
};
/** JSON Schema shipped in `manifest.configSchema`. */
export const configSchema = zodToManifestSchema(configZod);
export type PluginConfig = z.infer<typeof configZod>;
Rules that are not enforced anywhere but that you will regret ignoring — all advisory (unvalidated):
.default(...)every field. A field with no default has no seed value, and the form live-validates on mount, so the customer opens the panel to a red error they did not cause..describe(...)every field. The string becomes JSON Schemadescriptionand renders as the field’s help text. On an object it also becomes the auto-generated panel’s description.- Use the narrowest zod type.
z.number().int().min(1).max(20)emits{"type":"integer","minimum":1,"maximum":20}, which the number input honours. - Group with
z.object({...}).default({...}). Object fields become panels automatically.
Reading options back: parseWidgetConfig
props.options is the raw merged settings blob on both the server and the client. Nothing
schema-parses it for you — plugins are handed the unparsed object in the component, in
getInitialData, in dehydrateState and in getStaticAssets. Parsing is your job, in every
one of those places:
// packages/create-hr-plugin/template/src/widget.tsx — the scaffold, verbatim
const options = React.useMemo(
() => parseWidgetConfig(configZod, props.options) as PluginConfig,
[props.options],
);
parseWidgetConfig(schema, options, pre = liftLegacySpacing) does four things in order:
stripNullSettingLeaves— drops anullor""leaf only when that leaf’s own schema rejects it. Central runs Laravel’sConvertEmptyStringsToNull, so any untouched optional field can come backnull; older blobs carry""on enum and number fields. A leaf that legitimately acceptsnull(.nullable()/.nullish()) keeps it, and""stays on a plainz.string(). An empty array on a record- or object-typed field reads as{}(PHP serialises a cleared map as[]). Anything else invalid —"abc"on a number — is left in place to fail loudly.coerceScalarSettingLeaves— a string leaf whose schema isz.number()orz.boolean()is coerced when it parses unambiguously (font.size: "16"→16).pre— by defaultliftLegacySpacing, which folds a legacywidth: {maxWidth, unit}(and top-levelmargin/padding) ontospacingwithout overwriting an existing value.schema.parse(...)— applies your defaults and throws on anything still invalid.
A throw here blanks the widget: on the client it is caught by the render boundary and the host
gets data-hr-error="render-failed"; on the server a content widget is dropped from the page
while a layout shell takes the whole page down. See
component-and-ssr-module.md for the failure semantics and
../concepts/settings-and-options.md for where the raw
blob comes from.
Not supported yet. There is no server-side validation of stored settings. The dashboard validates live for display only and autosaves the raw form value on every keystroke, even while invalid; central stores
settingsas opaque JSON and replaces it wholesale. YourparseWidgetConfigcall is the only real gate.
What survives the manifest round-trip
Verified by running zodToManifestSchema → @n8n/json-schema-to-zod (the exact pair the
dashboard uses) over each construct. Enforcement for this whole section is advisory
(unvalidated) — no audit inspects which zod constructs you used. Everything below fails in
the customer’s browser, after publish, or not at all.
Safe
| zod construct | Emitted JSON Schema | Comes back as |
|---|---|---|
z.string() | {"type":"string"} | ZodString |
z.number(), z.number().int().min(1).max(10) | {"type":"integer","minimum":1,"maximum":10} | ZodNumber |
z.coerce.number() | {"type":"number"} | ZodNumber (the coercion is dropped, which is fine — see step 2 of parseWidgetConfig) |
z.boolean() | {"type":"boolean"} | ZodBoolean |
z.enum([...]) | {"type":"string","enum":[...]} | ZodEnum |
z.object({...}), nested, .default({...}) | {"type":"object","properties":{...},"default":{...}} | ZodDefault<ZodObject> |
z.array(z.string()), z.array(z.object({...})) | {"type":"array","items":{...}} | ZodArray |
z.array(z.enum([...])) | {"type":"array","items":{"type":"string","enum":[...]}} | ZodArray<ZodEnum> → multi-select |
z.record(z.string(), z.string()) | {"type":"object","additionalProperties":{"type":"string"}} | ZodRecord |
.optional() / .nullable() / .nullish() | {"type":["string","null"]} for nullish | renders correctly |
.default(v) at any depth | default | ZodDefault |
.describe(s) | description | field help text |
.url() / .email() | format: "uri" / "email" | ZodString (no client-side format check) |
.regex(re) | pattern | ZodString, and the pattern still rejects on validate |
z.string().min(3).max(9) | minLength / maxLength | ZodString, still enforced |
Degrades silently
Nothing warns. The form renders, the customer edits, and the result is wrong.
| zod construct | What actually happens |
|---|---|
z.union([A, B]) | Emits anyOf. Both the default-seeder and the renderer take the first non-null branch, so only branch A is ever editable — while the schema still validates every branch, so a value shaped like branch B saves fine and then shows the wrong controls. |
.refine() / .superRefine() | Erased by zodToJsonSchema. Cross-field rules are not enforced in the dashboard at all — only in your own parseWidgetConfig call at runtime, where a failure blanks the widget instead of showing a form error. |
z.record(z.enum([...]), z.string()) | Emits propertyNames, which the browser-side converter drops. The key constraint is lost; values are still typed. |
z.record(z.string(), z.number()) | Round-trips as a record, but the key/value editor writes strings, so every value the customer types fails your runtime parse. Keep record values z.string(). |
z.date() | Emits {"type":"string","format":"date-time"} and returns as ZodString — a plain text input, and your runtime parse then rejects a string where it expects a Date. |
.catchall(...) / .passthrough() | The open-key behaviour is lost on the way back; extra keys are dropped by the reconstructed closed object. |
Breaks the whole panel
These make the dashboard’s default-value walker throw
Unsupported schema type: [object Object]. That call is unguarded in three places in the
plugin playground’s render, so the entire config page crashes — you do not get a broken
field, you get no page.
| zod construct | Round-trips to | Why it throws |
|---|---|---|
z.any() | {} → ZodOptional<ZodAny> | ZodAny is not in the walker’s type list. |
z.unknown() | {} → ZodOptional<ZodAny> | same |
z.literal("x") | {"type":"string","const":"x"} → ZodLiteral | same |
z.tuple([...]) | {"type":"array","minItems":n,"maxItems":n,"items":{...}} → ZodArray<ZodAny> | the element type walks into ZodAny |
z.intersection(A, B) | allOf → ZodIntersection | same |
z.discriminatedUnion("t", [...]) | anyOf, first branch carrying a ZodLiteral discriminator | the discriminator is a literal, so the first branch throws |
Not supported yet. There is no guard around the walker and no error message — the panel simply does not render. Never ship any of the six constructs above. Model a variant with a
z.enum()discriminator plus flat optional fields and ashowIfper group.
The uiSchema
A manifest is JSON, so it cannot carry React components. You name them:
// property-picks/src/config.ts — a published plugin
export const uiSchema: PluginUiSchema = {
title: { component: "TextInput", label: "Title" },
limit: { component: "NumberInput" },
showReviewQuote: { component: "Checkbox" },
translations: {
ui: { layout: "panel", label: "Translations" },
viewDetails: { component: "Translations", label: "View details" },
perNight: { component: "Translations", label: "/ night" },
},
};
The types, from @homerunner-next/widget-core/plugin-schema:
// packages/homerunner-widget-core/src/plugin-schema.ts
export type PluginUiField =
| {
label?: string;
description?: string;
component?: /* one of the twelve tokens below */ string;
optionLabels?: Record<string, string>;
showIf?: { field: string; equals: unknown };
}
| PluginUiSchema;
export interface PluginUiSchema {
[key: string]: PluginUiField | PluginUiLayoutSpec | undefined;
ui?: PluginUiLayoutSpec;
}
export interface PluginUiLayoutSpec {
layout?: "panel" | "collapsible";
label?: string;
description?: string;
showIf?: { field: string; equals: unknown };
}
(component is a closed union of the twelve string literals in the real declaration; it is
widened here only so the comment fits.)
PluginUiLayoutSpec has no fields key. A panel’s children are siblings of its ui
key, mirroring the zod object’s shape. (A legacy {layout, label, fields: {...}} node is
auto-normalised to the canonical form before the tree walk, so old manifests keep working.
Do not write it in new code.)
Field keys the adapter honours
Exactly five. Everything else on a field entry is dropped or turned into an inert nested node.
| Key | Type | Effect |
|---|---|---|
label | string | Form label. |
description | string | Help text under the field; when omitted the zod .describe() text is used. |
component | one of the 12 tokens below | Substitutes the leaf renderer. An unrecognised name is silently ignored and the zod-type default renders. |
optionLabels | Record<string,string> | Value → display label. Meaningful on Select and multi-selects. |
showIf | {field, equals} | Conditional visibility, see below. |
Not supported yet.
order,ui:group,placeholderand every other key are ignored. pdp-suite’s shipped{"ui:group": "Content"}entries are no-ops — they produce an empty nested node and change nothing. Plugins cannot influence field order; see Ordering.
Component tokens
Twelve names resolve. Any other string is ignored.
| Token | Renders | Pair with |
|---|---|---|
TextInput | single-line text input | z.string() |
Textarea | multi-line text area | z.string() |
NumberInput | numeric input honouring minimum/maximum | z.number() |
Checkbox | toggle | z.boolean() |
Select | dropdown built from the enum’s values | z.enum([...]) only |
ColorPicker | hex colour picker | z.string() |
FontSelect | Google-font family picker | z.string() |
LanguageSelect | locale picker | z.string() |
PlatformSelect | the feed’s connected platforms, by UUID | z.string().nullish() |
PropertySelect | the feed’s properties, by slug | z.string() |
WidgetSelect (widget-core 0.12.0+) | the same widget combobox layout slots use; the stored value is a widget id, and a None row clears it | z.string().optional() |
Translations | locale-keyed string editor | z.record(z.string(), z.string()) |
PlatformSelect, PropertySelect and WidgetSelect fetch the feed’s own data. WidgetSelect
takes its feed from the form’s filter.feed_id, which the base filter block supplies and
widget creation seeds — if you strip filter out of your schema, the combobox has no feed and
stays empty.
Component ↔ zod pairing
The form dispatches on the zod type first and only then substitutes uiSchema.component
into that type’s renderer slot. A token therefore receives the props of the branch the zod
type routed to, not the props it was designed for.
| Mismatch | Result |
|---|---|
component: "Select" on a non-enum (z.string(), z.number()) | The enum renderer is handed options === undefined and throws on options.map. The panel crashes. Only the enum branch supplies options. |
component: "Translations" on a non-record | The locale editor is handed a non-object value, renders nonsense rows, and saves a shape your schema will reject. |
Any token on z.object({...}) | Never renders — the object branch ignores Component entirely. The only effect is that the automatic panel is skipped, so the object’s children render flat instead of behind a row. |
Any token on z.array(z.enum([...])) | Substituted into the multi-select slot and handed options plus an array value. |
| Unrecognised token | Ignored; the zod-type default renders. |
Nothing validates any of this at publish time — advisory (unvalidated). Open the widget’s config panel in the dashboard before you submit.
The control you get with no uiSchema entry at all
| zod type | Control |
|---|---|
z.string() | text input |
z.number() | number input |
z.boolean() | toggle |
z.enum([...]) | dropdown |
z.array(z.enum([...])) | multi-select |
z.array(<anything else>) | repeater rows with add / remove |
z.record(...) | key/value rows with an explicit Save button |
z.object({...}) | a nested panel (auto-generated) |
Item-level UI for an array is declared under an element key on the array’s entry — the
adapter forwards nested keys verbatim, and the array renderer reads element:
// derived from src/lib/plugin-ui-adapter.tsx + src/components/zod-form/libs/core/form.tsx
facts: { element: { component: "TextInput" } },
That is a structural passthrough rather than a declared feature — treat it as advisory (unvalidated).
Panels
Any object-typed property is wrapped in a nested panel automatically, recursively, whether or not you declare one. A panel renders as a clickable summary row; clicking it slides a stacked detail view in over the config sidebar. It is not an inline bordered fieldset and it is not a collapsible chevron.
- Panel title = your
ui.label, else the JSON Schematitle, else the raw key. zod never emitstitle, so an undeclared panel is titledtranslations,display,slots. - Panel description = your
ui.description, else the object’s.describe()text. - A field with an explicit
componentis never auto-wrapped, which is howTranslationson a record (alsotype: "object") keeps its own editor. - The
uiblock is only read as a layout spec when it contains thelayoutkey. Aui: { label: "Translations" }with nolayoutis discarded entirely — you lose the label and anyshowIfon it. Always writeui: { layout: "panel", label: "..." }.
Not supported yet.
layout: "panel"andlayout: "collapsible"are interchangeable. The value is only used as a marker that the node is a layout spec; it is never read, and both build the identical slide-in panel.
showIf
showIf: {field, equals} compiles to data => data?.[field] === equals, and that predicate
is always invoked with the whole root form value.
| Rule | Behaviour |
|---|---|
field must be a top-level key of your config. A dotted path like language.useAutoDetectedLocale is a plain property lookup, always undefined, so the field never shows. | fails silently |
Comparison is strict ===. It cannot match an object or an array. Compare against a string, number or boolean. | fails silently |
The same shape works on a panel’s ui block to hide the whole group. | works |
| Hidden values are preserved: stripped from a draft before validation (so an invisible field cannot block the form with an unfixable error), grafted back after a successful parse, and never stripped from the autosave at all. | works |
// pattern verified against src/lib/plugin-ui-adapter.tsx + resolve-ui-schema-conds.ts
export const uiSchema: PluginUiSchema = {
variant: { component: "Select", optionLabels: { grid: "Grid view", carousel: "Carousel" } },
columns: { component: "NumberInput", showIf: { field: "variant", equals: "grid" } },
advanced: {
ui: { layout: "panel", label: "Advanced", showIf: { field: "mode", equals: "expert" } },
retries: { component: "NumberInput", label: "Retries" },
},
};
Ordering
Field order is the configSchema property order, stable-sorted by a numeric order that
only the dashboard’s own base entries carry. widgetSchema.extend({...}) appends, so your
fields always land after the base chrome:
# order values from src/app/(protected)/feeds/[id]/widgets/config/common-ui-schema.tsx
Section Header (-55)
Filter Settings (-30)
spacing, colorScheme, lightModeColors, darkModeColors, font, language (0, schema order)
…your fields… (0, schema order)
Custom CSS (100)
Not supported yet. Plugins cannot reorder anything.
PluginUiFieldhas noorderkey and the adapter drops one if you add it, so uiSchema key order has no effect whatsoever. To move a field, move it in the zod schema.
The default label is the full dotted path
Every leaf renders label = uiSchema.label ?? name, where name is the full dotted form
path. A field inside a panel with no label shows translations.viewDetails, not
viewDetails. There is no humanisation. Declare label on every field you nest.
Never re-declare a base key
The adapter builds base = { ...commonWidgetUiSchema, width: <legacy panel> } and then, for
each top-level key of your uiSchema, does base[key] = convert(value) — a wholesale
replacement, not a deep merge. Re-declaring any of these destroys that panel’s layout, its
components, its conditions and its order:
spacing · colorScheme · lightModeColors · darkModeColors · font · language ·
section · customCss · filter · width
Re-declaring filter, for example, loses PlatformSelect, PropertySelect and the hidden
feed_id input. Re-declaring spacing loses the compound spacing control. If you genuinely
need a custom leaf beside the built-ins, you must re-declare the entire panel and rebuild
every entry yourself. A root ui key in a plugin uiSchema is skipped entirely.
Enforcement: advisory (unvalidated) — nothing warns, in the dashboard or at publish.
Layout widgets: the mandatory slots field
A layout widget must declare a slots object in its zod schema, with keys that match the
manifest’s declared slot names exactly:
// pdp-suite/src/widgets/pdp-frame/config.ts
export const configZod = widgetSchema.extend({
stickyRail: z.boolean().default(true).describe("Keep the sidebar rail pinned"),
slots: z
.object({
hero: z.array(z.string()).default([]),
main: z.array(z.string()).default([]),
sidebar: z.array(z.string()).default([]),
bottom: z.array(z.string()).default([]),
})
.default({})
.describe("Widgets bound into each region"),
});
Do not write a uiSchema entry for slots. When the resolved widget has
category: "layout" and a non-empty manifest slots[], the dashboard discards whatever you
declared and injects its own panel — label Slot Configuration, order: -50, one row group
per declared slot titled {name} slot and described accepts: {list}, each row a widget
combobox filtered by that slot’s accepts. Empty rows are stripped on save.
Without the zod field there is nowhere to store the bindings, so the customer’s slot picks
silently vanish. The stored shape is settings.slots: Record<slotName, widgetId[]>.
Everything else about slots, accepts, prefill, presets and page assignment lives in
layouts.md.
Not supported yet. Plugin layouts do not inherit
slotSpacingorgap— those live on a private internal schema. Declare your own if you want per-slot spacing; the builders in sdk-exports.md (marginBoxSchema,paddingBoxSchema,widthSchema,maxWidthSchema,DEFAULT_SLOT_SPACING) let you rebuild the same shape.
Translations
Declare each translatable string as z.record(z.string(), z.string()) — locale code → text —
and pair it with component: "Translations".
// property-picks/src/config.ts — a published plugin
translations: z
.object({
viewDetails: z
.record(z.string(), z.string())
.default({ en_US: "View details", fr_FR: "Voir les détails", es_ES: "Ver detalles" })
.describe("View-details CTA"),
perNight: z
.record(z.string(), z.string())
.default({ en_US: "/ night", fr_FR: "/ nuit", es_ES: "/ noche" })
.describe("Price unit"),
})
.default({
viewDetails: { en_US: "View details", fr_FR: "Voir les détails", es_ES: "Ver detalles" },
perNight: { en_US: "/ night", fr_FR: "/ nuit", es_ES: "/ noche" },
})
.describe("Translations"),
Editor behaviour the customer sees, and you must design around:
- Edits are held in local state and only reach the form when the customer presses the editor’s own Save button. A customer who edits and navigates away loses the edit.
- Removing a locale row raises a browser
confirm()dialog. - The editor derives its i18n key from the last segment of the field path, so
translations.viewDetailskeys onviewDetails.
At runtime:
// property-picks/src/widget.tsx — a published plugin
const { detectedLocale } = useLocaleDetection(
options.language.useAutoDetectedLocale,
options.language.locale,
);
const { _t } = useTranslation(options.translations ?? {}, detectedLocale);
// …
_t("viewDetails", "View details")
_t(key, defaultValue = key) resolves store[key][currentLocale] || store[key]["en_US"] || defaultValue — an empty string falls through, so a blank row is treated as missing.
useLocaleDetection seeds state with the configured locale so the server and the client’s
first render agree, then switches to the browser locale one tick after mount when auto-detect
is on. Both come from @homerunner-next/widget-core/utils.
Not supported yet. The plugin save path runs only the empty-slot stripper, not the blank-row strippers system widgets get — so a locale row the customer blanks is persisted forever as
"". It is harmless as long as you always pass adefaultValueto_tand always keep a non-emptyen_USentry in your schema default. Never rely on_t(key)alone.
Per-widget sub-manifests
(widget-core 0.11.0+, suites only.) Your project’s scripts/inject-schema.mjs writes one
JSON file per widget and stamps the reference back onto the summary:
// packages/create-hr-plugin/template/scripts/inject-schema.mjs — the multi-widget branch
const { configSchema, uiSchema } = await import(pathToFileURL(configPath).href);
const sub = { slug, configSchema };
if (uiSchema) sub.uiSchema = uiSchema;
fs.writeFileSync(subPath, JSON.stringify(sub, null, 2) + "\n");
widget.manifest = `widgets/${slug}.manifest.json`;
That script lives in your project, not in the SDK. Only configSchema and uiSchema are
ever read; slug is decorative (pdp-suite’s own copy omits it).
| Rule | Enforcement |
|---|---|
| Sub-manifests are committed to git and shipped in both zips. Nothing regenerates them for the reviewer beyond your own build. | publish ERROR if referenced and absent |
Only the dashboard fetches them, from the CDN, resolved against the plugin’s manifest_url. The renderer never does — it caches the root manifest only. | — |
The fetch is bounded by AbortSignal.timeout(5000) and cached per URL for the tab’s life; a rejected fetch is evicted so the next open retries. | silent degrade |
A fetch failure, a missing manifest reference, or a sub-manifest with no configSchema all degrade to the schemaless state — the widget still previews from its raw settings blob, but there is no form. | silent degrade |
| A declared sub-manifest missing from the built zip. | publish ERROR: {path} ({label}'s sub-manifest) is missing from the zip or invalid JSON. |
A sub-manifest present but carrying no configSchema. | publish warning: {path} has no configSchema — {label}'s dashboard form will be empty. |
Operator-visible strings, so you can recognise a screenshot:
# src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
Plugin "{slug}" not enabled for this feed.
Widget "{keyword}" is not declared by plugin "{slug}".
Loading plugin manifest...
No configSchema for this widget. Ask the plugin developer to ship one via
zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts.
If your JSON Schema is present but the browser cannot rebuild a z.ZodObject from it, the
failure is caught, logged as [plugin-playground] jsonSchemaToZod failed: and the same amber
“No configSchema” banner appears.
Publish-time schema checks
All four run against the built zip, at publish, and are quoted verbatim above:
| Check | Enforcement |
|---|---|
Single-widget manifest has no configSchema | publish warning |
Suite sub-manifest has no configSchema | publish warning |
properties lacks colorScheme or spacing | publish warning |
| Declared sub-manifest missing or invalid JSON | publish ERROR |
The audit also records each widget’s configSchema property keys so the reviewer can diff
them against your previous version.
Not supported yet. The built-zip audit runs at publish, on the reviewer’s side. Its warnings are shown to the reviewer, never to you — you can be told your submission is fine and only learn about a schema warning if the reviewer relays it. Nothing in the source-zip audit you run in your browser looks at
configSchemaat all. See packaging-and-publishing.md and ../ship/preflight-and-submit.md.
Defaults at widget creation
When a customer creates a widget from your plugin, the seed settings are:
- Every top-level
defaultin yourconfigSchema, recursing into object-typed properties that have nodefaultof their own. - The feed’s branding, minus
colorScheme— so the widget stays on theglobalinherit placeholder and follows the feed live. filter.feed_id = <feed id>, always, merged over whatever your schema’sfilterdefault held.
A sub-manifest fetch failure at this point degrades step 1 to {} — the widget is created
carrying only the branding and filter.feed_id, and your schema’s own defaults are what
parseWidgetConfig supplies at render time.
In the editor the form is seeded with your defaults merged under the stored settings. Because the whole seeded object is autosaved on the first edit, your defaults become explicitly stored values the moment a customer touches anything — a later change to a schema default will not reach widgets that already exist.
See also
- ../concepts/settings-and-options.md — where the raw options blob comes from and how it is merged.
- layouts.md — slots,
accepts,prefill, presets, page assignment. - widget-summary.md — the
manifestfield that points at a sub-manifest. - sdk-exports.md —
parseWidgetConfig,zodToManifestSchema,/spacing. - limits-and-errors.md — every hard number and the full error index.
- ../troubleshooting.md — “my config panel is empty / blank”.
Component and SSR module
Everything your widget module exports, and everything it receives — on the renderer’s server sandbox and in the browser.
Two files carry the contract:
| File | Shape | Exists when | Consumed by |
|---|---|---|---|
src/widget.tsx | Both | Always | The client IIFE (src/index.tsx registers it) and the SSR bundle |
src/ssr-entry.ts | Single-widget | The manifest declares ssr.url | The build’s SSR pass only |
src/widgets/{slug}/ssr-entry.ts | Suite | That widget’s summary declares ssr.url | The build’s SSR pass only |
ssr-entry.ts exists if and only if the widget’s summary declares ssr.url. A widget
declaring "ssr": false is CSR-only and has no SSR entry file at all — hr-widget-build
skips its SSR pass entirely, so the file would never be compiled. See
Manifest: widget summary for the ssr field.
How rules on this page are enforced. Almost all of them are enforced at runtime by the renderer, not at publish. The dashboard’s built-zip audit checks that your SSR file exists and has a hashed twin; it never opens the bundle or inspects its exports. Where a publish gate does exist it is named with the standard tags defined in Limits and error index. Runtime outcomes use these terms:
| Tag | Meaning |
|---|---|
| runtime — widget removed | Your widget’s markup is replaced by a hidden breadcrumb node and the page is marked degraded (60 s cache TTL). Every other widget still renders. |
| runtime — page 500 | The render endpoint throws. The customer’s page has no HTML at all. |
| runtime — throws into your code | You get an ordinary JS exception you can catch. |
Props
Import the prop types; do not hand-write them. The scaffold’s inline WidgetProps
interface is a simplification and omits fields.
// @homerunner-next/widget-core/src/contracts.ts — import from
// "@homerunner-next/widget-core/contracts"
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>;
}
Type your component WidgetProps<PluginConfig> (or
WidgetProps<PluginConfig, GetAwaitedFuncReturnType<typeof getInitialData>> when you
export one) and a layout LayoutWidgetProps<PluginConfig>. Both types are exported from
/contracts; WidgetSchemaType comes from /schema. See
SDK reference.
| Prop | Type | Shape | Rule | On violation |
|---|---|---|---|---|
options | T (declared) — RAW at runtime | Both | Always present. Never read a field off it directly; run parseWidgetConfig first. | A stored null/'' throws inside your render → widget removed (server) or data-hr-error="render-failed" (client) |
widgetId | string | Both | The widget instance id. Stable across server and client. | — |
feedId | number | Both | The feed this instance belongs to. | — |
widgetType | string | Both | The keyword, e.g. plugin:pdp-suite:stay-hero. Not supplied on the SSR/hydrate path. | Reading it during render produces different output on server and client → hydration mismatch |
resolvedTheme | "light" | "dark" | Both | Optional. Effectively always "light" in production. Never branch first paint on it. | Dark-mode content flashes light, or mismatches on hydrate |
data | D | Both | Whatever getInitialData returned. undefined on every client-only path. Must be JSON-serialisable. | A Date/Map/function silently becomes a string, {} or is dropped |
target | HTMLElement | Both | Declared in the type. Never supplied. | — |
renderedSlots | Record<string, ReactNode> | Suite (layouts) | Layout widgets only. Keys are the slot names present in the stored bindings. A slot that was never bound is absent, not empty — always ?? {} and default each region. | slots.hero is undefined; rendering it is fine, destructuring it is not |
Not supported yet.
targetis part of the exportedWidgetPropstype but no render path in the platform passes it — not the renderer, notmount()’s hydrate path, not its CSR path. UseuseShadowHost()from@homerunner-next/widget-core/runtimeif you need the host element. See SDK reference.
What each path actually passes
| Path | options | widgetId | feedId | widgetType | resolvedTheme | data | renderedSlots |
|---|---|---|---|---|---|---|---|
Server render (content, ssr.url) | RAW merged settings | ✔ | ✔ | absent | cookie-derived, else "light" | getInitialData result | — |
| Client hydrate of that markup | RAW, identical bytes | ✔ | ✔ | absent | replayed from the props registry | replayed from the props registry | — |
Server render (content, ssr: false) | your component is never invoked — the renderer emits an empty host div | n/a | n/a | n/a | n/a | n/a | n/a |
| Client CSR (content) | resolved settings from the config fetch | ✔ | ✔ | ✔ | live matchMedia result | undefined | — |
| Server render (layout) | RAW merged settings | ✔ | ✔ | absent | cookie-derived, else "light" | getInitialData result | server-rendered child nodes |
| Client CSR (layout) | resolved settings | ✔ | ✔ | ✔ | live | undefined | childless host divs it mounts into after commit |
Three consequences worth internalising:
widgetTypeis a hydration trap. The server props object is{resolvedTheme, options, feedId, widgetId, data}— it has nowidgetTypekey. The browser replays that same object verbatim when hydrating. Only the pure-CSR path addswidgetType. If your render output depends on it, the SSR HTML and the hydrated tree disagree and React discards the server DOM.resolvedThemeis effectively always"light"in production. It derives from anhr:widget:system_themecookie, and the Cloudflare worker in front of the renderer never forwards cookies. Dark-at-first-paint must come from CSS keyed off host attributes — see Styling and theming.- The hydrate path replays the server’s
data. It travels as JSON in a page-level inline script. Non-JSON values do not survive.
Options arrive RAW
Plugin widgets deliberately receive unparsed merged settings on both the server and the client. The renderer parses settings only for first-party widgets:
// Platform source: homerunner-renderer/lib/render-utils.tsx
const parsedSettings = widgetConfig.type.startsWith('plugin:')
? finalSettings // plugins: RAW
: await parseSettingsForSsr(widgetConfig.type, finalSettings);
Raw settings contain artifacts your schema rejects: a cleared dashboard field arrives as
null, an emptied map as [], numbers may arrive stringified, and pre-0.9.0 saves carry a
legacy width object instead of spacing. Calling .parse() on that throws.
The rule (Both): call parseWidgetConfig(configZod, props.options ?? {}) in your
component and in every SSR export before reading a single field. Enforcement: runtime —
throws into your code, which then becomes widget removed on the server or
data-hr-error="render-failed" in the browser.
// pdp-suite/src/widgets/stay-hero/widget.tsx (real, published)
import React from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";
import heroBg from "./hero-bg.png";
import "./stay-hero.css";
/** The property slug the platform injects when a parent layout resolves one. */
function injectedProperty(options?: Record<string, unknown>): string | null {
const filter = (options as { filter?: { property?: unknown } } | undefined)?.filter;
return typeof filter?.property === "string" && filter.property ? filter.property : null;
}
export default function StayHero(props: { options?: Record<string, unknown> }) {
const cfg = parseWidgetConfig(configZod, props.options ?? {});
const property = injectedProperty(props.options);
// …
}
parseWidgetConfig(schema, options, pre?) strips null/'' leaves the schema rejects,
coerces stringified scalars, lifts legacy width onto spacing, then parses. The full
merge chain that produces those raw settings is described in
Settings and options; the base schema fields are in
Config schema and UI schema.
Memoise it in a component that re-renders (useMemo on props.options) — it is a full
zod parse, not a cheap read.
The SSR module
The renderer fetches your built SSR bundle, evaluates it in a node:vm context, and reads
exactly four exports off the resulting module:
// Platform source: homerunner-renderer/lib/plugin-federation.ts:317-322
interface PluginSSRModule {
default: React.ComponentType<any>;
getInitialData?: (ctx: any) => Promise<any>;
dehydrateState?: (qc: any, ctx: any) => Promise<void>;
getStaticAssets?: (settings: any) => Promise<{ css: string[]; js: string[] }>;
}
| Export | Type | Required | Rule | On violation |
|---|---|---|---|---|
default | React.ComponentType | Yes (Both, including layouts) | The loader takes module.exports if it has .default, else exports if it has .default, else module.exports. | Logs [plugin] SSR bundle has no default export: <url>, the loader returns null → runtime — widget removed |
getInitialData | (ctx) => Promise<any> | No (Both) | Return value becomes props.data. Must be JSON-serialisable. | A throw → runtime — widget removed |
dehydrateState | (queryClient, ctx) => Promise<void> | No (Both) | Prefetch into the page’s shared React Query client. | A throw → runtime — widget removed. A throw inside a queryFn is swallowed — see below |
getStaticAssets | (settings) => Promise<{css: string[]; js: string[]}> | No (Both) | Omitting it is the recommended default. | A throw → runtime — widget removed |
Nothing about these exports is checked before publish — see
Packaging and publishing rules for what the audit does
check. A bundle with no default export publishes cleanly and fails on the first customer
page render.
The entry file
ssr-entry.ts is a re-export barrel. The build compiles exactly this path — nothing else —
into the SSR bundle:
// pdp-suite/src/widgets/pdp-frame/ssr-entry.ts (real, published — a layout)
// SSR entry — the renderer hands `renderedSlots` to the default export.
export { default } from "./widget";
// create-hr-plugin template: src/ssr-entry.ts (single-widget shape, content widget)
export { default, getInitialData } from "./widget";
export { dehydrateState, getStaticAssets } from "./ssr";
Suite widgets in practice export only default — pdp-suite’s stay-hero and pdp-frame
both do. Add the data hooks only when you need server-side data.
Call order and context
For every server-rendered widget the renderer runs, in this order, inside one try:
# Platform source: homerunner-renderer/lib/render-utils.tsx (getWidgetMarkup)
getInitialData(ctx) → dehydrateState(queryClient, ctx) → getStaticAssets(settings)
→ renderToString(<WidgetHydrationTree><Default {...props} /></WidgetHydrationTree>)
For a layout, all slot children are rendered first (concurrently, each a complete island), and only then does the layout’s own bundle load and run the same four steps. The whole pipeline, end to end, is drawn in How a widget renders; the slot rules are in Layout widgets.
ctx — for getInitialData and dehydrateState — is exactly three keys:
| Key | Type | Value |
|---|---|---|
options | Record<string, unknown> | RAW merged settings. Parse them. |
feedId | number | widget.feed_id |
widgetId | string | The widget instance id |
getStaticAssets receives only the settings object as its first positional argument —
there is no ctx, no feedId, no widgetId.
Not supported yet.
GetInitialDataCtxin@homerunner-next/widget-core/contractsdeclareswidgetType,isSSR,cookies,headersandqueryin addition to the three keys above, and typesoptionsas parsed. For plugins the renderer populates none of the extra fields and never parsesoptions. Type your ctx as the three-key object shown above; readingctx.cookiesorctx.headersgets youundefined.
The shared query client
dehydrateState receives the page’s QueryClient — one instance shared by every widget
on the page, first-party and plugin alike. The dehydrated cache is serialised into the HTML
and rehydrated into one shared browser-side client.
- Namespace your query keys. A colliding key silently serves another widget’s data.
Prefix with your plugin slug:
["pdp-suite", "stay-hero", feedId, …]. - Two keys are reserved and pre-seeded by the renderer before your hook runs:
["widget", <widgetId>]and["widget", <feedId>, <widgetId>](the widget + feed config pair thatuseFetchWidgetreads). Do not write to them. - Prefetch errors are swallowed by design; see Failure semantics.
Data-fetching patterns, the error taxonomy and retry helpers live in Fetching data; endpoint shapes in Public API.
getStaticAssets — prefer omitting it
When you do not export it, the renderer builds the asset list from your manifest instead:
assets.js, assets.css and assets.fonts. That is almost always what you want.
When you do export it, every URL you return is rewritten. The renderer reduces each
entry to its dist-relative name (https://cdn.example.com/dist/hero/hero.css →
hero/hero.css, anything else → the basename) and re-resolves it against the published
version’s build manifest, falling back to the /p/ proxy. Absolute URLs are not passed
through.
| Rule | Level | On violation |
|---|---|---|
| Returned URLs are reduced to a dist-relative name and re-resolved | runtime, always | You cannot self-host assets from getStaticAssets |
Declared assets.fonts are appended only on the manifest path | runtime, silent | Exporting getStaticAssets drops your fonts from the server-rendered <head>; the client IIFE still injects them, so they arrive after first paint instead of with it |
Multi-widget paths keep the widget directory: hero/hero.css, not hero.css | runtime | The build-manifest lookup misses and you fall back to the /p/ proxy |
The full resolution algorithm, the /p/ proxy and font rules belong to
Keywords, assets and URLs.
// create-hr-plugin template: src/ssr.ts — corrected for the suite layout.
// A suite emits dist/{widget}/{widget}.css, not dist/{slug}.css.
export async function getStaticAssets(
_options: Record<string, unknown>,
): Promise<{ css: string[]; js: string[] }> {
return {
css: ["dist/stay-hero/stay-hero.css"],
js: ["dist/stay-hero/stay-hero.iife.js"],
};
}
The server sandbox
Your SSR bundle is a CommonJS file (formats: ["cjs"], exports: "named",
inlineDynamicImports: true) named dist/{widget}/{widget}-ssr.umd.js in a suite or
dist/{slug}-ssr.umd.js in the single-widget shape, despite the .umd.js suffix. Every
dynamic import() is inlined at build time — there is no module loader in the sandbox at
runtime.
The context is a fresh V8 realm. ECMAScript intrinsics (RegExp, Function,
Reflect, parseInt, Intl, globalThis, …) exist because the realm creates them.
Every Web or Node API must be explicitly seeded, and only the following are.
Shared modules (7)
require() resolves exactly seven specifiers:
// Platform source: homerunner-renderer/lib/plugin-federation.ts:25-38
const SHARED_MODULES: Record<string, unknown> = {
react: React,
"react/jsx-runtime": ReactJsxRuntime,
"react-dom": ReactDOM,
"react-dom/client": ReactDOMClient,
"react-dom/server": ReactDOMServer,
"@tanstack/react-query": ReactQuery,
// SDK >= 0.10.0 externalizes widget-core's runtime in the SSR umd too.
"@homerunner-next/widget-core/runtime": WidgetCoreRuntime,
};
Anything else throws, verbatim:
Plugin cannot require "<mod>" (not in shared modules whitelist)
Enforcement: runtime — widget removed (the throw happens during module evaluation, so the bundle never loads).
@homerunner-next/widget-core/runtime is the evergreen entry (widget-core 0.10.0+):
the build externalises it, and the renderer supplies its own current copy. Your server
renders therefore use the platform’s live runtime helpers, not a copy frozen at your
npm install. The other widget-core subpaths — /globals, /schema, /plugin-schema,
/utils, /urls, /spacing, /contracts — stay bundled into your SSR file, so they
are whatever version you built with.
Everything else you import — React libraries, date utilities, your own code — is bundled. Keep the bundle small: it is fetched, evaluated and cached per renderer instance.
Globals
| Present | Notes |
|---|---|
React | Also reachable via require("react") |
require, module, exports | The CJS shim above |
process | Only process.env, and only three keys (below) |
__HR_PLUGIN_ASSET_BASE__ | The bundle’s own …/dist/ base — how bundled-asset imports resolve server-side |
fetch | Wrapped by the externalFetch allowlist (below) |
console | The renderer’s console. Your console.log lands in renderer stdout |
setTimeout, clearTimeout, setInterval, clearInterval | |
URL, URLSearchParams, AbortController, AbortSignal | |
Promise, Date, Math, JSON, Object, Array, String, Number, Boolean, Error, TypeError, RangeError, Symbol, Map, Set, WeakMap, WeakSet | Seeded from the host realm |
window, document, self, navigator, location, localStorage, sessionStorage | Inert Proxy stubs — see below |
Absent — any reference is a ReferenceError |
|---|
crypto (no Web Crypto, no crypto.randomUUID), TextEncoder, TextDecoder, Buffer |
Response, Request, Headers, FormData, Blob, atob, btoa |
structuredClone, queueMicrotask, performance, setImmediate |
Every Node builtin: fs, path, os, child_process, node:crypto, … |
The rest of process: no argv, cwd(), version, platform |
globalThis does exist and resolves to the sandbox object itself, not the host realm’s
global. The build’s bundled-asset banner relies on that
(globalThis.__HR_PLUGIN_ASSET_BASE__ is its server-side fallback).
fetch returns a Response created in the host realm. Its methods work; res instanceof Response is a ReferenceError because Response is not bound in the sandbox.
process.env exposes exactly three keys, and nothing else — reading any other name gives
undefined:
| Key | Value |
|---|---|
NODE_ENV | The renderer’s own |
NEXT_PUBLIC_HOMERUNNER_BASE_URL | The resolved Central base URL |
NEXT_PUBLIC_WIDGET_ASSET_BASE_URL | The asset base, when configured |
Browser-global stubs, and the guard that actually works
The seven browser globals are new Proxy(function(){}, {get, set, has, apply, construct})
where get returns another stub. That stub is an object, so it is truthy:
// Platform source: homerunner-renderer/lib/plugin-federation.ts:306-315
function makeBrowserGlobalStub(): unknown {
const handler: ProxyHandler<object> = {
get: () => makeBrowserGlobalStub(), // truthy!
set: () => true, // writes silently dropped
has: () => false, // `"x" in window` is false
apply: () => makeBrowserGlobalStub(),
construct: () => ({}),
};
return new Proxy(function () {}, handler);
}
Consequences:
| Guard | Server behaviour | Verdict |
|---|---|---|
if (document) { … } | Passes — document is a truthy stub | Broken |
if (window.matchMedia) { … } | Passes — the property read returns a stub | Broken |
if ("matchMedia" in window) | Fails — the has trap returns false | Works, but by accident |
if (typeof window === "undefined") return; | The SSR build rewrites typeof window to the literal "undefined" before bundling, so the branch is dead-code-eliminated | Correct |
The SSR build rewrites typeof on window, document, self, navigator,
localStorage and sessionStorage to "undefined". Only that form is tree-shaken; the
proxy is a safety net for whatever escapes, not a substitute.
// widget-core README.md — the canonical browser-only side effect
useEffect(() => {
if (typeof window === "undefined") return;
import("webfontloader").then(({ load }) => load(opts));
}, [opts]);
// The SSR build rewrites `typeof window` to "undefined", esbuild DCEs everything
// after the unconditional return, and the dependency never lands in the SSR umd.
useEffect never runs on the server anyway, but the import would still be bundled
without the guard.
Execution bounds
| Bound | Value | Rule | On violation |
|---|---|---|---|
| Bundle fetch redirects | redirect: "manual" | The SSR URL must serve the bytes directly | Any 3xx throws HTTP <status> redirect refused — SSR bundles must be served directly from their published URL → runtime — widget removed |
| Bundle fetch status | must be 2xx | HTTP <status> → runtime — widget removed | |
| Module-scope evaluation | 5000 ms default (PLUGIN_SSR_EXEC_TIMEOUT_MS, floor 250 ms) | Bounds synchronous top-level work only | The vm throws and the bundle never loads → runtime — widget removed |
| Async work | unbounded | Timers, microtasks and everything inside getInitialData/dehydrateState are outside the vm timeout | A hanging fetch stalls the whole page render |
| Top-level declarations | wrapped as (function(){ … })() before compiling | Prevents a minified top-level const gc colliding with the Lambda runtime’s non-configurable globals | — |
Give every outbound request in your data hooks an explicit AbortSignal.timeout(…). The
sandbox provides AbortController/AbortSignal for exactly this reason, and nothing else
will stop a slow upstream from holding the page.
The vm is not a security boundary
The context is seeded with host-realm objects, so a determined bundle can walk back into the renderer’s realm. The real gate is human source review plus an admin-side build — see From your laptop to a customer page. Write as if the sandbox were airtight anyway; a reviewer reads your source.
externalFetch
The sandbox’s fetch is wrapped in an allowlist derived from the widget’s externalFetch
manifest field.
| Rule | Level | On violation |
|---|---|---|
| Allowed hosts = HomeRunner Central’s host ∪ your declared entries | runtime | [plugin] fetch to "<host>" blocked — not declared in the widget's externalFetch manifest field. |
An entry is host (any port) or host:port (that port only) — hostname only, no scheme, no path | runtime | Same message as above; the comparison is a lowercased exact match |
| Private, loopback, link-local and metadata hosts are refused even when declared | runtime | [plugin] fetch to "<host>" blocked — private/metadata hosts are never allowed. |
| Redirects are followed manually and every hop is re-validated; max 5 | runtime | [plugin] fetch blocked — more than 5 redirects. |
| The URL must parse | runtime | [plugin] fetch blocked — unparseable URL: <raw> |
externalFetch is never validated at publish | advisory (unvalidated) | A typo’d host is discovered only when the widget renders on a real page |
The refused-host set is textual (no DNS resolution): localhost, *.localhost, *.local,
0.0.0.0, ::1, 127.*, 10.*, 192.168.*, 172.16–31.*, 169.254.* and
metadata.google.internal.
// Platform source: homerunner-renderer/lib/plugin-federation.ts:386-399
const validate = (u: URL): void => {
const host = u.hostname.toLowerCase();
const hostPort = u.port ? `${host}:${u.port}` : host;
const allowed = host === centralHost() || declared.has(host) || declared.has(hostPort);
if (!allowed) {
throw new Error(
`[plugin] fetch to "${host}" blocked — not declared in the widget's externalFetch manifest field.`,
);
}
if (!platformIsLocalRig() && host !== centralHost() && isForbiddenHost(host)) {
throw new Error(`[plugin] fetch to "${host}" blocked — private/metadata hosts are never allowed.`);
}
};
The throw surfaces inside your hook, so it is runtime — throws into your code. Let it propagate and the widget is removed; catch it and you can degrade gracefully. Catching is usually right for optional enrichment data, wrong for the widget’s primary content.
Declaration lives on the widget summary in a suite, or at the manifest root in the single-widget shape:
// manifest.json — suite shape, inside widgets[]
{ "slug": "stay-facts", "externalFetch": ["api.weather.example", "cdn.example.com:8443"] }
The root-level form is read by the resolver for single-widget manifests, but it is not
declared on the published PluginManifest type and is not covered by any publish
validator. Treat it as working but undocumented, and say so in your submission notes so
the reviewer knows to look for it.
Two behaviours that surprise people
A 60-second server-side GET cache. Outside development mode, ok GET responses are
cached for 60 s across renders (LRU, 50 entries), so dehydrateState may legitimately see
data up to a minute stale. The key is the URL plus your request headers, so two calls
that differ by a single header never share a response — and a request that carries
authorization, proxy-authorization or cookie, uses credentials: "include", sets
cache: "no-store"/"reload", sends a body, or is not a GET is never cached at all.
Null-body answers (204, 205) are passed straight through and not cached either. This exists because Central’s public API is rate-limited per IP
and a purge fan-out multiplies renders. Development renders bypass the cache but stay
enforced — you see a block locally, not in review.
The allowlist is frozen into the cached module. The evaluated bundle is cached with its
enforced-fetch closure already bound, so the first load’s allowlist is the one that
sticks for that module’s lifetime. For pipeline-published plugins this never bites: a new
version is a new URL, hence a new module. For a legacy author-hosted plugin, editing
externalFetch in a mutable manifest takes up to 5 minutes to apply.
Failure semantics
| Where it throws | Widget kind | Outcome | Breadcrumb reason |
|---|---|---|---|
| Module evaluation (top-level) | any | runtime — widget removed | SSR bundle failed to load from <url> |
No default export | any | runtime — widget removed | SSR bundle failed to load from <url> |
getInitialData | content | runtime — widget removed | SSR data prep failed: <message> |
dehydrateState | content | runtime — widget removed | SSR data prep failed: <message> |
getStaticAssets | content | runtime — widget removed | SSR data prep failed: <message> |
| Component render on the server | content | runtime — widget removed (the render is inside the same try) | SSR data prep failed: <message> |
| Any of the three hooks | layout | runtime — widget removed; the layout and every already-rendered slot child are dropped | layout SSR data prep failed: <message> |
| Layout component render | layout | runtime — page 500 | none — there is no HTML |
Inside a queryFn you prefetch | any | Swallowed by TanStack Query. Warned, page degraded, widget ships its loading markup | none |
| Component render in the browser | any | WidgetRenderBoundary catches it | data-hr-error="render-failed" on the host |
A removed widget leaves this in the page source, so you can find it with view-source or a
curl:
<!-- Emitted by widgetFailureNode — homerunner-renderer/lib/render-utils.tsx:132-145 -->
<div data-hr-widget-error="plugin:pdp-suite:stay-hero" hidden>
<!-- HR widget failed — type="plugin:pdp-suite:stay-hero" id="42": SSR data prep failed: Cannot read properties of null -->
</div>
Any failure node (or any errored prefetch) marks the whole page degraded, which drops its cache TTL from 24 hours to 60 seconds at every layer. A degraded page is not a broken page — it is a page that will re-render a minute later.
Your console.* calls and the renderer’s own lines go to renderer stdout. The ones worth
grepping when an SSR module misbehaves:
# Platform source: homerunner-renderer/lib/plugin-federation.ts + lib/render-utils.tsx
[plugin] Fetching SSR bundle: <url>
[plugin] Failed to load SSR bundle from <url>: <message>
[plugin] SSR bundle has no default export: <url>
[plugin] Loaded "<keyword>" (<id>) via SSR bundle
[plugin] No manifest entry for "<keyword>". Skipping.
[widget] Failed to prepare "<keyword>" (<id>): <message>
[layout] Failed to prepare "<keyword>" (<id>): <message>
[widget] SSR prefetch FAILED — shipping loading markup. key=<queryHash> error=<message> (caught during …)
More diagnosis routes are in Troubleshooting.
The layout asymmetry
A content widget’s server render happens inside getWidgetMarkup’s try. A layout’s
component is returned as an un-rendered React element and rendered later, in the page’s
single renderToString, which has no try. A layout component that throws during render
takes the entire customer page down with a 500.
Layout authors: parse defensively, default every slot, and never index into
renderedSlots without a fallback.
// pdp-suite/src/widgets/pdp-frame/widget.tsx (real, published)
const cfg = parseWidgetConfig(configZod, props.options ?? {});
const slots = props.renderedSlots ?? {};
const region = (name: string, node: ReactNode) => (
<section className={`pdpf-region pdpf-${name}`} data-testid={`pdpf-${name}`}>
{cfg.showSlotLabels ? <span className="pdpf-label">{name}</span> : null}
{node ?? (cfg.showSlotLabels ? <div className="pdpf-empty">empty “{name}” slot</div> : null)}
</section>
);
Prefetch errors only warn
queryClient.prefetchQuery swallows failures by design. The renderer diffs the shared
cache’s error set around your dehydrateState and logs:
[widget] SSR prefetch FAILED — shipping loading markup. key=<queryHash> error=<message>
Your widget still renders — with whatever it renders when the query has no data. If that is a spinner, crawlers see a spinner. Prefer returning a real empty state.
In the browser
Every render site in mount() wraps your component in WidgetRenderBoundary, inside
the providers. On catch it logs
[hr-widget] "<widgetType>" render failed (widget <id>, feed <id>) — rendering the error state instead of a blank node.
sets data-hr-error="render-failed" on the host element, best-effort stamps a
data-hr-theme if nothing else did, and renders the shared neutral error state. A latched
failure clears when resetKey changes — which is how a bad live-edit payload in the
dashboard preview recovers without a reload. The complete data-hr-error vocabulary is in
Runtime, mount and the DOM contract.
The hydration contract
The renderer renders each content widget as an isolated React root:
// Platform source: homerunner-renderer/lib/render-utils.tsx:715, 733-744
const ssrId = `${widgetConfig.id}:${widgetConfig.feed.id}:${widgetConfig.instanceKey ?? '0'}`;
const widgetHtml = renderToString(
<WidgetHydrationTree queryClient={queryClient}>
<WidgetComponent {...props} />
</WidgetHydrationTree>,
{ identifierPrefix: ssrId },
);
mount() hydrates with the identical wrapper shape and the identical
identifierPrefix, read back from the host’s data-hr-ssr-id attribute. React derives
useId values from a component’s position in the tree plus that prefix, so any drift —
an extra provider, a different prefix, a hand-rolled hydrateRoot — makes every
useId consumer (Radix Dialog/Popover aria attributes, form ids) mismatch. React’s
response is to regenerate the whole tree and silently discard your server DOM.
This is why you must mount through mount() from
@homerunner-next/widget-core/runtime and never call hydrateRoot yourself. The
wrapper is PortalContainerProvider → ShadowHostProvider → QueryClientProvider; provider
values do not affect useId, only the shape does, which is why the server can pass
undefined and the client the live shadow elements. See
Runtime, mount and the DOM contract.
Two more hydration rules:
- Render deterministically. No
Math.random(), noDate.now(), no readingprops.widgetType(absent on the server), no readingwindow. Anything that differs between the two passes costs you the SSR DOM. renderToStringdoes not await. A suspended boundary ships its fallback into the HTML. Fetch ingetInitialData/dehydrateState, not by suspending during render.
Layout modules
Plugin layout widgets require widget-core 0.11.0+ and the suite manifest shape.
A layout widget’s SSR module follows the same contract with these differences:
| Content | Layout | |
|---|---|---|
| SSR bundle | Optional — "ssr": false is legal | Mandatory — a layout declaring "ssr": false fails at render; the exact message is in Layout widgets |
| Extra prop | — | renderedSlots: Record<string, ReactNode> |
| Server DOM | Isolated root, usually inside a declarative shadow root | Static light DOM, no shadow root, no DSD template |
| Client mount | Hydrates | Deliberate no-op on server-rendered pages |
| Render throw | Widget removed | Page 500 |
Because the shell is light DOM on server-rendered pages, its stylesheet is loaded
document-level and applies to the whole customer page. Namespace every class (pdp-suite
uses .pdpf-…), never rely on :host, and never paint a background. The same layout in a
single-snippet CSR embed does get a shadow root, so the CSS must work both ways.
Not supported yet. A plugin layout on a server-rendered page can never be interactive: the renderer emits no
data-hr-ssr-idand no registry entry for the shell, andmount()returns early for it. Its slot children hydrate themselves normally. Put interactivity in a content widget bound into a slot, not in the shell.
Layouts may still export getInitialData, dehydrateState and getStaticAssets — the
renderer calls all three. pdp-suite’s layout exports none. Slot mechanics, slots,
accepts, prefill, presets and pages are all in Layout widgets.
Where the local SSR preview differs from production
The local harness (renderPluginSSR / createSSRDevServer, see
Previewing SSR locally) is a faithful port of the renderer’s
sandbox. The sandbox itself now matches (widget-core 0.12.2+):
@homerunner-next/widget-core/runtime resolves as a shared module, TextEncoder /
TextDecoder are absent from both, __HR_PLUGIN_ASSET_BASE__ is defined, externalFetch is
enforced from the manifest, module scope runs under the same 5000 ms budget, and the render
goes through WidgetHydrationTree with identifierPrefix set — so useId drift reproduces
locally instead of first appearing on a customer’s page. On an SDK below 0.12.2 all six of
those diverge, and code that passes locally fails on the renderer.
What still differs is the environment around the sandbox, not the sandbox. This is the summary; Previewing SSR locally works through what each row does to you.
| Local preview | Renderer | |
|---|---|---|
__HR_PLUGIN_ASSET_BASE__ | http://localhost:{port}/dist/ | the bundle’s CDN base |
Private/loopback hosts in externalFetch | refused, unless apiBaseUrl is itself loopback | always refused |
props.options | seeded from configZod.parse({}) by the scaffold — fully shaped | the customer’s raw stored settings |
| Hydration envelope | data-hr-ssr-props on the host, never a declarative shadow root | a page-level props registry, DSD |
renderedSlots on a layout | always empty | the layout’s bound children |
Server-side fetch | your msw fixtures; unmatched requests reach the real network | the live feed |
Not supported yet. No local harness composes a layout’s slots.
renderPluginSSRbuilds the props itself and passes norenderedSlots, so a layout previews as its shell with every slot empty. That still proves the shell evaluates and renders without throwing — the failure that 500s a whole customer page — but it is not a preview of the composed result.
A suite widget previews like any other: createSSRDevServer takes the widget you name and
resolves dist/{widget}/{widget}-ssr.umd.js under plugin:{slug}:{widget}
(widget-core 0.12.2+; the suite scaffold’s dev:ssr script needs create-hr-plugin 0.8.1+).
Checklist
parseWidgetConfig(configZod, props.options ?? {})inwidget.tsxand in every SSR hook.- Types imported from
@homerunner-next/widget-core/contracts, not hand-written. - No
props.widgetType,Math.random(),Date.now()orwindowread during render. - Only
typeof window === "undefined"guards for browser-only code. - Query keys prefixed with your plugin slug;
["widget", …]left alone. - Every declared
externalFetchhost spelled exactly, with an explicit request timeout. getStaticAssetsomitted unless you genuinely need to vary assets by settings.- Layout components that cannot throw: defaults for every slot and every config field.
Runtime, mount and the DOM contract
Normative reference for what mount() does, every DOM attribute it reads and writes, and how
your component reaches the browser. Almost everything here is runtime behaviour — observable
on a customer page, not checked before you ship — so the failure modes are silent by default.
Publish-time rules live in packaging-and-publishing.md; the
canonical index of every number and message is limits-and-errors.md.
Enforcement levels used below follow the legend in limits-and-errors.md. Runtime rules are additionally tagged:
- runtime throw (isolated) — throws, is caught by
mount, marks that one embed, other embeds continue. - silent runtime — no error, the widget just behaves differently than you expected.
- advisory — nothing checks it; getting it wrong is a bug in your plugin only.
Boot order
Two scripts, in this order, per page:
<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:63-86 -->
<script src="https://assets.homerunner.io/w/runtime.iife.js" crossorigin="anonymous"></script>
<!-- single-widget plugin -->
<script src="https://assets.homerunner.io/p/my-plugin/my-plugin.iife.js"></script>
<!-- one widget of a suite -->
<script src="https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js"></script>
Never add crossorigin="anonymous" to a /p/ script tag — see
assets-and-urls.md.
runtime.iife.js is the host page’s responsibility. On platform-composed pages it is emitted for
you, in this order, by getDefaultJsUrls(). In a hand-written embed you must load it yourself,
first.
Both shapes. Since widget-core 0.10.0 your client IIFE does not bundle React, React DOM, React Query or the widget-core runtime. The Vite preset externalizes them onto the runtime global:
// packages/homerunner-widget-core/src/vite/index.ts:141-153 — RUNTIME_GLOBALS
{
react: "HRWidgetRuntime.React",
"react/jsx-runtime": "HRWidgetRuntime.jsxRuntime",
"react-dom": "HRWidgetRuntime.ReactDOM",
"react-dom/client": "HRWidgetRuntime",
"@tanstack/react-query": "HRWidgetRuntime",
"@homerunner-next/widget-core/runtime": "HRWidgetRuntime.widgetCore",
}
Consequences you must design around:
| Fact | Consequence | Enforcement |
|---|---|---|
mount is HRWidgetRuntime.widgetCore.mount, not your copy | Mount fixes reach your published bundle without a rebuild. The behaviour on this page is the fleet’s, not the version in your node_modules. | silent runtime |
| The runtime script must execute first | Without it every externalized symbol is undefined. getRuntime() throws [widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry. | runtime throw |
Only /runtime is externalized | /globals, /schema, /plugin-schema, /urls, /spacing, /utils stay bundled in your IIFE. | — |
react-dom/server is not on the runtime | Server APIs exist only inside the SSR sandbox (component-and-ssr-module.md). | runtime throw |
| Externalization is a build-only transform | vite dev resolves all of it from node_modules, so dev cannot catch a runtime-version skew. | silent runtime |
Not supported yet. The
HRWidgetRuntimeTypeScript interface inpackages/homerunner-widget-core/src/globals.ts:15-69does not declarewidgetCore(noruseQueries/keepPreviousData, which the live runtime IIFE does export). The build maps them regardless — the type is behind the runtime. Cast if you must read them directly.
Registration
Registration writes a page-global registry. It is what the dashboard playground, the CSR layout
child loader and mount’s category fallback all read.
// packages/homerunner-widget-core/src/globals.ts:149-177
registerPlugin(key: string, component: ComponentType<any>, meta?: { category?: "content" | "layout" }): void
registerPluginWidget(pluginSlug: string, widgetSlug: string, component: ComponentType<any>, meta?): void
registerPluginWidget(p, w, C, meta) is exactly registerPlugin("{p}:{w}", C, meta) — same
registry, different spelling.
| Shape | Keyword | Registry key | Call |
|---|---|---|---|
| Single-widget (v1) | plugin:my-plugin | my-plugin | registerPlugin("my-plugin", W) |
| Suite (0.11.0+) | plugin:pdp-suite:stay-hero | pdp-suite:stay-hero | registerPluginWidget("pdp-suite", "stay-hero", W) |
The registry key is everything after the plugin: prefix — there is no separate namespace.
Both write window.HRPlugins[key] = { component, category? }; category is omitted entirely
when meta.category is falsy. Registration is idempotent (last write wins) and is a no-op when
window is undefined, so importing your entry in a Node test is safe.
| Rule | Shape | Enforcement |
|---|---|---|
| Every client bundle must register | Both | publish ERROR — the built-zip audit greps each assets.js bundle for the literal string HRPlugins and refuses a bundle that never registers (packaging-and-publishing.md) |
The registry key must equal the keyword minus plugin:, or nothing resolves your component | Both | silent runtime |
Layout widgets must pass { category: "layout" } | Suite | silent runtime — a layout with no recorded category still routes correctly if the bootstrap passes category to mount; get both wrong and the CSR path renders a content widget with no slots |
| One widget per entry file, per IIFE | Suite | advisory — hr-widget-build builds one entry per widgets[] slug, so a second registerPlugin in the same file has no manifest entry and no bundle of its own |
The renderer itself never reads HRPlugins — it loads your SSR bundle directly. The registry is
what the dashboard playground preview and a CSR layout’s slot-child loader read, and it is what the
publish audit proves is present. Note that the audit only checks the string: a bundle that
registers under the wrong key passes publish and fails silently at runtime.
mount(container, config)
// packages/homerunner-widget-core/src/runtime/mount.tsx:251
export function mount(container: HTMLElement, config: MountConfig): void
Returns void. There is no unmount return value — the handle is on the DOM element (see The unmount handle).
mount selects container.querySelectorAll('[data-hr-widget="${config.widgetType}"]') and
processes each match independently. container itself is never a match, so you always pass the
wrapper ([data-hr-widget-container]), not the host div.
MountConfig — every field
// packages/homerunner-widget-core/src/runtime/mount.tsx:42-88
interface MountConfig {
widget: ComponentType<any>;
widgetType: string;
category?: "content" | "layout";
resolveChildJsUrl?: (keyword: string) => string;
cssUrls?: string[];
extraCssUrls?: string[];
resolveDefaultCssUrls?: (widgetType: string) => string[];
shadowDOM?: boolean;
}
| Field | Type | Required (shape) | Rule | On violation |
|---|---|---|---|---|
widget | ComponentType<any> | Required — Both | Your default export. Receives the props described in component-and-ssr-module.md. | React throws during render → caught by the render boundary, host gets data-hr-error="render-failed" |
widgetType | string | Required — Both | The full keyword: plugin:{slug} or plugin:{slug}:{widget}. Doubles as the mount selector and the default CSS-URL seed. | A wrong value simply matches nothing — mount returns silently having done nothing (silent runtime) |
category | "content" | "layout" | Optional — Suite (0.11.0+) | "layout" routes the CSR path through LayoutCsrRenderer and makes the SSR path a no-op. Falls back to the registry’s category for plugin: keywords. | Omitted on both mount and registerPluginWidget → a layout takes the content path and renders with no renderedSlots (silent runtime) |
resolveChildJsUrl | (keyword) => string | Optional — Suite (layouts only) | How a CSR layout finds each slot child’s IIFE. Defaults to window.HRWidgetRuntime.resolveChildJsUrl. | Unresolvable → that child logs and its slot stays empty |
cssUrls | string[] | Optional — Both | Full override of the shadow-root stylesheet list. Beats the page-supplied URLs. | Overriding on an SSR page replaces the page’s content-hashed links with your guesses and forces a re-download (silent runtime) |
extraCssUrls | string[] | Optional — Both | Prepended to whichever base wins; use this instead of cssUrls for an extra sheet. Deduped against the base by stylesheet identity. | — |
resolveDefaultCssUrls | (widgetType) => string[] | Optional — Both | Consulted only when neither cssUrls nor the page provided any CSS. Defaults to the plugin resolver below. | — |
shadowDOM | boolean | Optional — Both, default true | false renders straight into the host, no shadow root, no portal container. | Must mirror the manifest’s shadowDOM — see When shadowDOM disagrees |
The bootstrap
Verbatim from the published suite. Copy it per widget; the only per-widget edits are the imports,
the two keyword strings, and category on a layout.
// pdp-suite (the published reference suite) — src/widgets/pdp-frame/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import PdpFrame from "./widget";
import "./pdp-frame.css";
registerPluginWidget("pdp-suite", "pdp-frame", PdpFrame, { category: "layout" });
function doMount() {
document
.querySelectorAll<HTMLElement>("[data-hr-widget-container]")
.forEach((container) => {
if (container.querySelector('[data-hr-widget="plugin:pdp-suite:pdp-frame"]')) {
mount(container, {
widget: PdpFrame,
widgetType: "plugin:pdp-suite:pdp-frame",
category: "layout",
});
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", doMount);
} else {
doMount();
}
A content widget of the same suite drops category from both calls:
// pdp-suite (the published reference suite) — src/widgets/stay-hero/index.tsx
registerPluginWidget("pdp-suite", "stay-hero", StayHero);
// …
mount(container, { widget: StayHero, widgetType: "plugin:pdp-suite:stay-hero" });
Scoping the scan to [data-hr-widget-container] matters. A server-rendered page emits exactly one
<div data-hr-page="…" data-hr-widget-container> wrapping every widget node
(packages/homerunner-renderer/pages/api/render/[feedId]/property/[slug].tsx:462), and a CSR
embed emits one per widget. Scanning document.body instead would work but breaks the containment
the platform relies on for slot children.
The DOM contract
The embed shape
<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:45-47 -->
<div data-hr-widget-container>
<div id="kua7xey91zqkpfjr8uveq1z1:90" data-hr-widget="plugin:pdp-suite:stay-facts"
data-hr-min-height="140"></div>
</div>
Attributes mount reads
| Attribute | On | Required (shape) | Rule | On violation |
|---|---|---|---|---|
data-hr-widget | host | Required — Both | Must equal config.widgetType exactly. It is the selector. | No match — mount does nothing, no log (silent runtime) |
id | host | Required — Both | Colon-joined "{widgetId}:{feedId}". Split by extractWidgetId; feedId goes through Number(). | Missing → throws Widget ID is required (runtime throw, isolated). Present but not colon-joined → feedId is NaN, so the CSR feed fetch 404s and the host lands on data-hr-error="widget-fetch-failed" |
data-hr-ssr-id | host | Server-written — Both | Key into window.HRWidget.__WIDGET_PROPS__, and the identifierPrefix passed to hydrateRoot. | Absent on a legacy cached page → hydration falls back to React’s default prefix |
data-hr-ssr-props | host | Legacy fallback — Both | JSON props, used only when the registry has no entry. | Malformed JSON yields {} — which is truthy, so the widget still takes the hydrate path and hydrates against empty props |
data-hr-options | host | Optional — Both | JSON per-embed options override, merged at top priority by getFinalSettings. Also how a CSR layout pushes state onto its children. | Malformed JSON yields {} (silent runtime) |
data-hr-css | host | Legacy fallback — Both | JSON array of stylesheet URLs. | Non-array or malformed → [], so the default resolver wins |
data-hr-min-height | host | Optional — Both | Anti-CLS reservation. Bare number = px; anything else = a raw CSS length; 0/off/none/false opts out. | Unparseable values are passed to CSS verbatim and simply do not apply (silent runtime) |
data-hr-mounted | host | Written by mount — Both | Presence means “already mounted”; mount returns immediately. | See Idempotency |
template[shadowrootmode] | direct child of host | Server-written — Both | The Declarative Shadow DOM payload. Adopted before anything else. | — |
Resolution order for props, options and CSS is registry first, attributes second:
<!-- packages/homerunner-renderer/lib/render-utils.tsx:334-343 — one script per page -->
<script>(window.HRWidget = window.HRWidget || {}).__WIDGET_PROPS__ =
Object.assign(window.HRWidget.__WIDGET_PROPS__ || {},
{"{widgetId}:{feedId}:{instanceKey}": {props: {…}, options: {…}, css: ["…"]}});</script>
If that inline script is blocked (CSP, an ad blocker, an HTML-rewriting CDN) the markup is present
but the props are not, so mount cannot hydrate and falls through to the CSR path — which is why
the visibility gate is cleared on both paths.
Attributes and properties mount writes
| Written | On | When |
|---|---|---|
data-hr-mounted="1" | host | Synchronously, before any async work |
data-hr-error | host | On failure — see the vocabulary |
data-hr-theme="light|dark" | shadow host, else host | Once the colour scheme is known; kept live across OS flips for auto/global |
removes data-hr-scheme | shadow host, else host | The moment JS takes over theming, so the server’s parse-time media-query fallback cannot fight the React tree |
data-hr-widget-category="layout" | shadow host, else host | CSR layout embeds only; the SSR shell already carries it from the server |
data-hr-preloading="1", data-hr-skeleton | host | While a CSR reservation is held |
inline min-height, transition | host | While a reservation is held; both restored on release |
el.__hrWidgetUnmount | host (JS property) | Always |
div.hr-react-root, div.hr-portal-container, <link rel="stylesheet"> | inside the shadow root | When shadow DOM is on |
<style id="hr-widget-preload-style"> | document.head | Once per document, when any reservation is applied |
The shadow host is normally the [data-hr-widget] element itself. If that element’s tag cannot
take a shadow root, setupShadowDOM inserts a wrapper <div> around it and the wrapper becomes
the host — so data-hr-theme lands on the wrapper, not on [data-hr-widget]. Allowed tags:
ARTICLE ASIDE BLOCKQUOTE BODY DIV FOOTER H1–H6 HEADER MAIN NAV P SECTION SPAN, plus any
hyphenated custom element.
data-hr-error(runtime, on the host) is a different attribute fromdata-hr-widget-error(server, on a hidden failure breadcrumb the renderer emits in place of a widget). Grep for both — see ../troubleshooting.md.
Idempotency and per-embed isolation
Both shapes. mount guards on the data-hr-mounted DOM attribute, not a module-level
Set. A module Set cannot work: your IIFE, a system widget’s IIFE and a layout’s IIFE each hold
their own module instances, and only a shared DOM marker dedupes across them. The attribute is set
synchronously so two near-simultaneous callers cannot both pass.
Each element is mounted inside its own try. A throw is caught, logged, and marks that element:
// packages/homerunner-widget-core/src/runtime/mount.tsx:258-271
allRootEls.forEach((rootEl) => {
try {
mountOne(rootEl, container, config, resolveDefaults);
} catch (error) {
console.error(
`[hr-widget] "${config.widgetType}" mount failed for one embed — other embeds continue.`,
error,
);
rootEl.setAttribute("data-hr-error", "mount-failed");
}
});
Nothing propagates to your caller — not even a missing id. mount never rejects and never
rethrows, so wrapping the call in your own try tells you nothing. Read the console and the
data-hr-error attribute instead.
One consequence to know: data-hr-mounted is set before the id check, so a failed embed
keeps the marker. Calling mount again after fixing the DOM will skip it. Remove
data-hr-mounted (or call el.__hrWidgetUnmount()) first.
Asynchronous failures after the CSS gate — a rejected cssReady, a throw inside hydrateRoot —
are caught separately and produce the same marker with a different line:
[hr-widget] "<widgetType>" deferred mount step failed (widget <widgetId>, feed <feedId>).
Shadow DOM lifecycle
Both shapes, when config.shadowDOM !== false and HTMLElement.prototype.attachShadow
exists. mount tries three things in order.
- Adopt a declarative shadow root. If the host already has a
shadowRoot(native DSD — the browser attached and styled it at parse), adoption returns immediately withcssPending: false: no hide, no migrate, no CSS wait, no mount blink. If instead an inerttemplate[shadowrootmode]sits in the light DOM, the ponyfill attaches the root itself and moves the template content in; its<link>s only connect now, socssPending: trueand the react-root is hidden until they apply. - Otherwise
setupShadowDOM(rootEl, cssUrls), which attaches{ mode: "open" }, appends one<link rel="stylesheet">per URL, thendiv.hr-react-rootanddiv.hr-portal-container. - Then migrate, if there was server markup:
migrateSSRContentmoves the host’s child nodes into the react-root withappendChild. It never re-serialises throughinnerHTML— that would destroy the identity hydration matches against.
Adoption looks for the class names hr-react-root and hr-portal-container, not data
attributes. If hr-react-root is absent, adoption returns null and mount falls through to
step 2. A missing hr-portal-container is created on the fly.
The visibility gate is shadowSetup.reactRoot.style.visibility = "hidden" — on the react-root,
never on the host. It is cleared by both the hydrate path and the CSR render path, so a widget
that looked server-rendered and then fell through to CSR still becomes visible.
cssReady resolves when every shadow <link> has a .sheet, on load/error, or after a hard
3000 ms timeout (it polls every 16 ms, because cached sheets often never fire load). A
failed sheet logs [HRWidget] Failed to load CSS in shadow DOM: <href> and resolves anyway — CSS
never blocks hydration.
Not supported yet.
adoptDeclarativeShadowRoot,waitForShadowLinksandhasRenderedChildrenexist in source but are not exported from@homerunner-next/widget-core/runtime. You cannot reimplement this lifecycle; usemount.setupShadowDOM,migrateSSRContent,getCSSUrlsFromElementandisShadowDOMSupportedare exported — see sdk-exports.md.
Not supported yet. A widget whose entire server output is a hoistable tag (
<style>,<link>,<script>,<meta>,<title>,<base>) reads as no server markup and client-renders instead of hydrating. Always nest server output inside a real element.
Why you must not call hydrateRoot yourself
The server renders each widget as an isolated root inside WidgetHydrationTree with
identifierPrefix set to the data-hr-ssr-id. useId values derive from tree position plus
that prefix, so any drift makes React regenerate the tree and discard every byte of your SSR DOM.
mount mirrors the wrapper shape and the prefix exactly:
// packages/homerunner-widget-core/src/runtime/mount.tsx:507-525
<WidgetHydrationTree queryClient={getSharedQueryClient()}
portalContainer={portalContainer} shadowHost={shadowHost}>
{withRenderBoundary(<Widget {...ssrProps} />)}
</WidgetHydrationTree>
// hydrateRoot(renderTarget, ssrNode, { identifierPrefix: ssrIdAttr })
When shadowDOM disagrees
Both shapes. The root manifest’s shadowDOM flag (manifest-root.md) gates
whether the renderer emits a DSD template at all. It must mirror what every one of your bootstraps
passes to mount.
| Manifest | mount() | Result |
|---|---|---|
true (default) | default / true | Correct. DSD adopted, hydrated in place. |
false | false | Correct. Light-DOM SSR, no isolation. |
true | false | Broken on any DSD-capable request. The browser attaches the server shadow root at parse; mount renders into the light DOM, which a host with a shadow root never displays. hasRenderedChildren reads false, so it takes the CSR path. Symptom: visible server content that never hydrates, sitting under an invisible second render — and it looks fine on a browser that missed the DSD bucket, which is what makes it so hard to spot. |
false | true | A shadow root is created around light-DOM SSR markup, which is then migrated into it. Works, but you lose the parse-time styling DSD buys. |
Enforcement: silent runtime. Nothing at publish compares the two.
CSS URL resolution
// packages/homerunner-widget-core/src/runtime/mount.tsx:202-213
const base = config.cssUrls ?? pageProvided; // registry css, else data-hr-css
const resolvedBase = base.length > 0 ? base : resolveDefaults(widgetType);
const extras = (config.extraCssUrls ?? []).filter((u) => !baseKeys.has(cssIdentityKey(u)));
return [...extras, ...resolvedBase];
Precedence: cssUrls → page-provided (registry css, else data-hr-css) → resolveDefaultCssUrls.
extraCssUrls are prepended to whichever base wins, so build-time extras keep their cascade
position without displacing the page’s content-hashed URLs.
Dedupe is by stylesheet identity, not string equality: the pathname with a leading /w/ or
/dist/ stripped and an 8-hex content hash removed. /w/stay-hero/stay-hero.css,
/dist/stay-hero/stay-hero-1a2b3c4d.css and /dist/stay-hero/stay-hero.css are one sheet.
The default resolver for a plugin: keyword:
| Environment | Single-widget | Suite |
|---|---|---|
Vite dev (__HR_WIDGET_DEV__ === true) | /dist/{slug}/{slug}.css | /dist/{slug}/{widget}/{widget}.css |
| Production | {proxy}/p/{slug}/{slug}.css | {proxy}/p/{slug}/{widget}/{widget}.css |
{proxy} is window.HRWidgetRuntime.proxyBaseUrl when the host page sets it, else
https://assets.homerunner.io. Non-plugin keywords default to []. Full URL rules:
assets-and-urls.md.
Layout widgets
Suite only (widget-core 0.11.0+). The complete layout contract — slots, prefill, presets,
page assignment — is layouts.md. What mount itself does:
// packages/homerunner-widget-core/src/runtime/mount.tsx:297-337
const effectiveCategory =
config.category ??
(config.widgetType.startsWith("plugin:")
? getRegisteredWidget(config.widgetType)?.category
: undefined);
// …
if (effectiveCategory === "layout" && hasSSR) {
return; // deliberate no-op
}
- Category fallback.
config.categorywins; for aplugin:keyword an omitted value falls back to whatregisterPluginWidget(..., { category: "layout" })recorded. The registry write is in the same IIFE, so it always precedes the mount. - SSR pages: the mount is a deliberate no-op. The shell is static server HTML and each slot child self-hydrates through its own IIFE. Running the CSR path here would throw away the server-rendered slots and re-fetch everything. This is correct behaviour, not a bug.
- CSR embeds:
LayoutCsrRenderer. An empty layout host (no server markup) falls through and the layout rebuilds its own slot tree: fetch the layout widget, readsettings.slotsasRecord<slotName, childWidgetId[]>, fetch each child row, load each child IIFE viaresolveChildJsUrl+loadScriptOnce, wait for registration, render childless host nodes, andmounteach child imperatively after commit.
What a CSR layout pushes onto every slot child, as a data-hr-options JSON string:
// packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx:87-102
{
__parentColorScheme: layoutScheme ?? "light",
// only when the layout resolved a property:
filter: { feed_id, property, platform: null },
}
platform: null is load-bearing, not tidiness: a child’s stored platform would AND-filter it to
zero results when the pushed property belongs to a different connection, and undefined would be
skipped by the merge.
Slot-child host nodes are keyed by ${childId}:${optionsAttr}, so a live colour-scheme edit
recreates the node — children read __parentColorScheme once, at mount, and data-hr-mounted
blocks a re-mount.
Per-child failures are contained and logged; the layout still renders:
| Console line | Cause |
|---|---|
[layout-csr] Slot "<name>" child <id> failed to resolve; skipping. | The child widget row could not be fetched |
[layout-csr] Child widget "<keyword>" failed to load/register; its slots stay empty. | IIFE load failed, or registration timed out after 10 000 ms ([widget-core] widget "<type>" did not register within 10000ms) |
[layout-csr] Skipping nested layout child "<keyword>" (<id>) in slot "<slot>". | A system layout (keyword ends -layout) bound into a slot |
[layout-csr] Skipping nested plugin layout child "<keyword>" — layouts cannot nest. | A plugin layout, caught by its registered category |
[layout-csr] Could not verify property "<slug>"; rendering the layout anyway. | The property check failed with a 5xx or a network blip — never a reason to blank a working layout |
[layout-csr] Property "<slug>" was not found in feed <n>. Check the layout's Property setting, or the data-hr-options filter.property on the embed. | The one whole-layout failure. A permanent 4xx on the property check renders data-hr-error="property-not-found" instead of the slots, so a bad slug is diagnosed once rather than 404ing in every slot at the same time |
[widget-core] failed to load script: <src> | loadScriptOnce could not fetch a child IIFE |
Not supported yet. A plugin layout on a server-rendered page can never be interactive. The renderer emits its shell with no
data-hr-ssr-idand no props-registry entry, and the client mount no-ops — so the shell is static HTML forever. Its slot children hydrate normally and are fully interactive. Put interactivity in a content widget, not the shell.
Live preview: the options-updated event
Both shapes. A CustomEvent named options-updated, dispatched on the host element, whose
detail is the complete options object:
// packages/create-hr-plugin/template/src/dev/DevPanel.ts:25-29
el.dispatchEvent(new CustomEvent("options-updated", { detail: settings }));
| Path | Listener | Effect |
|---|---|---|
| CSR content widget | ClientWrapperForWidget | Re-runs getFinalSettings with the payload as the widget’s settings, so colorScheme: "global" still resolves against feed branding. Re-themes the host. Also resets a latched render failure. |
| CSR layout | LayoutCsrRenderer | Re-resolves slot bindings and layout settings live |
| Hydrated SSR widget | none | Nothing happens |
Not supported yet. The hydrated SSR path installs no
options-updatedlistener. A widget on a server-rendered page cannot be live-edited through this channel.
This is also the reason the unmount handle clears leftover hoistable tags: React does not claim them during hydration, so an unmount → re-mount cycle would otherwise still read as “has server markup”, re-hydrate, and silently have no live-preview listener.
The unmount handle
mount sets rootEl.__hrWidgetUnmount — a plain JS property, because mount may be a different
module instance in each IIFE and a DOM property is the only shared channel. Calling it:
- cancels any pending CSS-deferred first render,
- disposes the preload reservation and the theme controllers,
root.unmount(),- empties the shadow react-root (removing React’s unclaimed hoistables),
- resets
visibility— mandatory, or a mount torn down during the CSS window leaves a permanently hidden root, - removes
data-hr-mountedand deletes itself.
LayoutCsrRenderer calls it on slot children it discards; if a child has no handle (a stale bundle
or a hand-rolled mount) it warns:
[HRWidget] discarded slot child "<type>" (id <id>) has no unmount handle
(stale bundle or custom mount) — its widget tree may stay orphaned.
The type UnmountableEl is not exported from /runtime; declare it yourself if you need it.
Anti-CLS preload reservation
Both shapes. Applied only when the embed has no server content and is not a layout —
i.e. CSR embeds and ssr: false widgets. Layout shells defer to their children’s own
reservations.
Height source, in order:
data-hr-min-heighton the host. Bare number → px; anything else → raw CSS length;0/off/none/false→ opt out.preloadMinHeightHint(widgetType, options).
// packages/homerunner-widget-core/src/utils/preload-hints.ts:65-89
const DEFAULT_MIN_HEIGHTS = { explorer: …, "search-bar": …, booking: …, calendar: …,
"multi-calendar": …, "property-card": …, "related-properties": …, gallery: … };
Plugin widget types have no entry in that table. preloadMinHeightHint returns null for
every plugin:* keyword, so a plugin reserves nothing unless you supply a height. Two channels:
| Channel | Shape | Effect | Enforcement |
|---|---|---|---|
expectedHeight on the widget summary (widget-summary.md) | Suite | The renderer stamps data-hr-min-height="{n}" on a CSR-only host, and the dashboard bakes it into the generated embed snippet | advisory — nothing warns if you omit it |
data-hr-min-height on the embed | Both | Direct, wins over everything | advisory |
While reserved, the host carries data-hr-preloading="1" (and data-hr-skeleton when a shaped
silhouette exists) and an inline min-height. It is released the moment real content lands in the
render target, via a MutationObserver, with a 250 ms min-height ease; a hard 10 000 ms
max-hold collapses it honestly if the widget never paints. A host that already carries its own
inline min-height keeps it — the reservation manages the attribute only.
The data-hr-error vocabulary
Two places carry this attribute: the host element (a QA/support marker) and the rendered
div.hr-widget-error-state (the visible fallback).
| Value | On | Meaning | Cleared when |
|---|---|---|---|
mount-failed | host | A throw inside mountOne (classically a missing id), or a rejection in the CSS-deferred hydrate/render step | Never automatically |
render-failed | host + error element | WidgetRenderBoundary caught a synchronous render throw — most often configZod.parse() on a stored null/'' because the component skipped parseWidgetConfig | A changed resetKey (the next options-updated payload) |
rate-limited | host + error element | The widget-config fetch returned 429 | A successful retry |
widget-fetch-failed | host + error element | Any other widget-config fetch failure | A successful retry |
property-not-found | error element only | A CSR layout was pointed at a property slug that does not exist in the feed | — |
WidgetRenderBoundary also logs, and best-effort stamps data-hr-theme from
prefers-color-scheme when nothing else has, so the error card is not a glaring white box on a
dark page:
[hr-widget] "<widgetType>" render failed (widget <widgetId>, feed <feedId>)
— rendering the error state instead of a blank node.
The error element also carries role="alert", class="hr-widget-error-state",
data-hr-feed-id and data-hr-widget-id.
Runtime bounds this page depends on
Canonical index: limits-and-errors.md.
| Bound | Value | What it governs |
|---|---|---|
| Shadow CSS gate | 3000 ms | cssReady resolves regardless; CSS never blocks hydration |
| Slot-child registration | 10 000 ms | whenWidgetRegistered rejects; that slot child is skipped |
| Preload max hold | 10 000 ms | The reservation collapses even if the widget never paints |
| Preload release ease | 250 ms | min-height transition, skipped under prefers-reduced-motion |
Related
- What each render path passes your component: component-and-ssr-module.md
- Why
props.optionsis raw andparseWidgetConfigis mandatory: ../concepts/settings-and-options.md - The whole picture, server to screen: ../concepts/how-a-widget-renders.md
- Theming off
data-hr-theme/data-hr-scheme: ../recipes/styling-and-theming.md hr-react-root/hr-portal-containerand dialogs: ../recipes/portals-and-dialogs.md- Bundle and stylesheet URLs: assets-and-urls.md
- Slots,
prefill, presets and page assignment: layouts.md - Every export named here: sdk-exports.md
- Which SDK version has which of these features, and what npm actually serves today: ../get-started/01-install-and-versions.md
Keywords, assets and URLs
Everything your plugin ships — bundles, stylesheets, images, fonts — is addressed twice: once by the keyword a customer’s widget row stores, and once by a URL the platform derives from your manifest. This page is the normative reference for both derivations, on every path.
Enforcement levels used below are defined once in limits-and-errors.md: publish ERROR (the upload or the publish is refused), publish warning (the reviewer sees it, you do not), silent runtime truncation (the value is dropped or capped with no message) and advisory (unvalidated) (nothing checks it; you find out in production).
1. The keyword grammar
A plugin widget is addressed everywhere — stored settings, embed snippets, the renderer, the
registry on window.HRPlugins — by a single string.
| Shape | Keyword | Registry key |
|---|---|---|
| Single-widget (v1) | plugin:{pluginSlug} | {pluginSlug} |
| Suite | plugin:{pluginSlug}:{widgetSlug} | {pluginSlug}:{widgetSlug} |
registryKey is everything after the plugin: prefix, unchanged. It is the key your client
bundle registers under and the key mount() looks up — see
runtime-and-mount.md.
// Source: packages/homerunner-widget-core/src/urls.ts:34-46
export function parsePluginKeyword(keyword: string): PluginKeywordParts | null {
if (!keyword.startsWith(PLUGIN_WIDGET_PREFIX)) return null; // "plugin:"
const registryKey = keyword.slice(PLUGIN_WIDGET_PREFIX.length);
if (!registryKey) return null;
const sep = registryKey.indexOf(":");
if (sep === -1) {
return { pluginSlug: registryKey, widgetSlug: null, registryKey };
}
const pluginSlug = registryKey.slice(0, sep);
const widgetSlug = registryKey.slice(sep + 1);
if (!pluginSlug || !widgetSlug) return null;
return { pluginSlug, widgetSlug, registryKey };
}
Parsing rules — Both shapes:
| Input | Result | Why |
|---|---|---|
plugin:acme | {acme, null, "acme"} | No separator → single-widget form |
plugin:acme:hero | {acme, hero, "acme:hero"} | Split at the first colon |
gallery | null | Not a plugin keyword — a system widget |
plugin: | null | Empty registry key |
plugin::hero | null | Empty plugin slug |
plugin:acme: | null | Empty widget slug |
A null parse is fail-closed: nothing resolves, nothing mounts, and on a server-rendered page
the renderer logs [plugin] No manifest entry for "<keyword>". Skipping. and replaces the widget
with a hidden error breadcrumb. Both slugs are constrained to ^[a-z0-9][a-z0-9-]*$ at publish
(publish ERROR), so a published keyword can never contain a third colon — see
manifest-root.md and widget-summary.md.
2. Derived file names
You never invent bundle names. The SDK’s Vite preset emits fixed names, and every consumer —
the renderer, the /p/ proxy, the client runtime — recomputes the same names from the keyword.
// Source: packages/homerunner-widget-core/src/urls.ts:55-64
export function pluginWidgetDistFile(parts, kind: "iife" | "css" | "ssr"): string {
const base = parts.widgetSlug ?? parts.pluginSlug;
const dir = parts.widgetSlug ? `${parts.widgetSlug}/` : "";
const file =
kind === "iife" ? `${base}.iife.js` : kind === "css" ? `${base}.css` : `${base}-ssr.umd.js`;
return `${dir}${file}`;
}
| Artifact | Single-widget (v1) | Suite |
|---|---|---|
| Client IIFE | dist/{slug}.iife.js | dist/{widget}/{widget}.iife.js |
| Stylesheet | dist/{slug}.css | dist/{widget}/{widget}.css |
| SSR bundle (CJS) | dist/{slug}-ssr.umd.js | dist/{widget}/{widget}-ssr.umd.js |
| Bundled asset | dist/{name}-{vitehash}.{ext} | dist/{widget}/{name}-{vitehash}.{ext} |
| Source map | …{file}.map | …{file}.map |
The -ssr.umd.js suffix is historical: the file is CommonJS, not UMD.
Rule (Both, publish ERROR). Your manifest’s assets.js, assets.css and ssr.url must
point at files the build actually emitted. The built-zip audit checks each one and fails with
dist/<name> (<label>'s <kind>) is missing from the zip. Do not rename outputs.
dist/manifest.json — the build manifest
After the last build pass the SDK walks dist/ recursively, writes a content-hashed twin
next to every file, and records the mapping.
// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/dist/manifest.json (real, v1.3.3)
{
"buildHash": "77328e0a",
"generatedAt": "2026-09-07T06:25:25.567Z",
"files": {
"booking-cta/booking-cta.css": "booking-cta/booking-cta-a830c68f.css",
"booking-cta/booking-cta.iife.js": "booking-cta/booking-cta.iife-ca74009c.js",
"pdp-frame/pdp-frame-ssr.umd.js": "pdp-frame/pdp-frame-ssr.umd-5872d9b8.js",
"pdp-frame/pdp-frame.css": "pdp-frame/pdp-frame-9992937d.css",
"pdp-frame/pdp-frame.iife.js": "pdp-frame/pdp-frame.iife-be6fddd4.js",
"stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png",
"stay-hero/stay-hero-ssr.umd.js": "stay-hero/stay-hero-ssr.umd-0b04d970.js",
"stay-hero/stay-hero.css": "stay-hero/stay-hero-f4a2cffb.css",
"stay-hero/stay-hero.iife.js": "stay-hero/stay-hero.iife-f2a3fe21.js"
}
}
| Property | Rule | Enforcement |
|---|---|---|
| Keys | dist-relative path — hero/hero.iife.js for a suite, a bare filename for v1 | generated |
| Values | {base}-{sha8}{ext}, first 8 hex of the file’s SHA-256 | generated |
manifest.json itself | excluded from files | generated |
*.map | excluded from files — no hashed twin, not /p/-servable, but still uploaded to the CDN and reachable directly | generated |
buildHash | SHA-256 (8 hex) of the sorted files map | publish forensics only — no serving path reads it |
| Presence in the built zip | required | publish ERROR — dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build. |
| Hashed twin for every declared asset | required | publish ERROR — The build manifest has no hashed twin for <name>. / Hashed twin dist/<hashed> is missing from the zip. |
Three consumers read this file: the /p/ proxy (to pick a 302 target), the renderer (to emit a
direct content-addressed URL) and the built-zip audit. See
packaging-and-publishing.md.
Path normalisation: dist-relative name
Every consumer reduces a manifest asset reference — absolute URL or path — the same way before looking it up.
// Source: packages/homerunner-widget-core/src/urls.ts:107-118 (mirrored verbatim in
// packages/homerunner-embeddables/api/plugin-asset.ts:194-204 and
// packages/homerunner-renderer/lib/render-utils.tsx:177-188)
export function distRelativeAssetName(urlOrPath: string): string {
const clean = urlOrPath.split("#")[0]!.split("?")[0]!;
const marker = clean.lastIndexOf("dist/");
if (marker !== -1 && (marker === 0 || clean[marker - 1] === "/")) {
const rel = clean.slice(marker + "dist/".length);
if (!rel.split("/").some((segment) => /^\.+$/.test(segment))) return rel;
}
return extractAssetFilename(clean); // last path segment
}
Consequences you can rely on (Both): everything after the last dist/ survives,
including the widget directory; a path with no dist/ segment collapses to its basename; a
path containing a dot-only segment (dist/../evil.js) also collapses to its basename, so
traversal can never escape the version prefix.
3. Where published files live
Published objects are keyed {env}/{slug}/{version}/{path} in one shared bucket, served from
the plugin CDN (R2_PUBLIC_BASE_URL; https://plugins.homerunner.io in production).
// Source: src/lib/r2.ts:64-80 + src/lib/plugin-zip.ts:532-533,904-911
{env}/{slug}/{version}/manifest.json public, max-age=300
{env}/{slug}/{version}/dist/manifest.json public, max-age=300
{env}/{slug}/{version}/dist/<file> public, max-age=31536000, immutable
{env}/{slug}/{version}/dist/{widget}/<file> immutable (bundles, css, images, fonts, .map)
{env}/{slug}/{version}/widgets/{widget}.manifest.json immutable
{env}/{slug}/{version}/media/<file> immutable (icon, cover, screenshots, README)
{env}/{slug}/{version}/media/{dir}/<file>.md immutable (per-widget READMEs)
# {env} = prod (production dashboard) | dev (preview deployments) | local (a laptop)
Exactly two objects are not immutable: the root manifest.json and dist/manifest.json,
both public, max-age=300. Everything else — including media/ and .map files — is
public, max-age=31536000, immutable.
The {env} segment is mandatory in every URL. Verifying a publish:
# Source: src/lib/r2.ts:70-80 (cdnPluginUrl) — the {env} prefix is never optional
BASE=https://plugins.homerunner.io/prod/pdp-suite/1.3.3
curl -s $BASE/manifest.json | jq .version
curl -s $BASE/dist/manifest.json | jq .buildHash
curl -sI https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js | head -1 # 302
Version bumping, immutability and rollback are owned by publish-versions-and-rollback.md.
4. Two serving paths
There is no single origin and no single resolution rule. Which URL a browser sees depends on how the page was built.
| Path | Who uses it | URL emitted | Redirect | Cached |
|---|---|---|---|---|
| Direct content-addressed CDN | Server-rendered / composed customer pages, for pipeline-published plugins | {cdn}/{env}/{slug}/{version}/dist/[{widget}/]{file}-{sha8}.{ext} | none | 1 year, immutable |
/p/ proxy | CSR embed snippets, the playground, the client runtime’s default CSS resolution, legacy author-hosted plugins, and any lookup failure | {proxy}/p/{slug}/{distRelativeFile} | one 302 to the CDN | 60 s + SWR 30 s on the 302; the target is immutable |
Because the direct path has no pointer indirection, a cached page always loads the exact bundle its HTML was rendered with — there is no window in which new HTML meets an old bundle.
How the renderer resolves an asset
// Source: packages/homerunner-renderer/lib/render-utils.tsx:196-273
1. file = distRelativeName(url) // "hero/hero.iife.js"
2. anchor = resolvePluginAssetUrl(manifest_url, widget.assets.js)
3. if anchor does NOT match /\/\d+\.\d+\.\d+\/dist\/(?:[^/]+\/)?[^/]+$/
or its host is localhost/127.0.0.1 -> go to step 7
4. distBase = anchor up to and including "/dist"
5. GET {distBase}/manifest.json (3 s timeout; cached for the life of the
process, failures negative-cached for the life of the process)
6. if files[file] exists -> {distBase}/{files[file]} DONE
7. fallback: {proxyBase}/p/{slug}/{file}
proxyBase = NEXT_PUBLIC_WIDGET_ASSET_BASE_URL
?? NEXT_PUBLIC_PROXY_BASE_URL
?? "https://assets.homerunner.io"
Two rules follow, and both surprise authors:
-
Anything
getStaticAssets()returns is rewritten (Both). Absolute URLs are not passed through — they are reduced to a dist-relative name by step 1 and re-resolved. You cannot self-host an asset fromgetStaticAssets. The only pass-through channel for a third-party stylesheet URL isassets.fonts(§9). See component-and-ssr-module.md. -
Pre-release versions lose the direct path.
1.0.0-rc.1publishes, but every immutability detector requires a bareX.Y.Zpath segment, so a pre-release prefix falls back to the/p/proxy and to TTL caching everywhere.Not supported yet. Nothing warns you about this. Ship plain
MAJOR.MINOR.PATCHversions — enforcement level today is advisory (unvalidated).
Neither the worker nor the dashboard puts crossorigin on a plugin script tag on either path
(buildScriptsHtml, src/lib/homerunner/cf-worker.ts:2485-2493). See §6.
5. The /p/ plugin asset proxy
GET https://assets.homerunner.io/p/{slug}/{file}
There is no version segment and there never was one. {file} is the dist-relative name:
flat (acme.iife.js) for a v1 plugin, widget-nested (stay-hero/stay-hero.iife.js) for a
suite. Bumping your version does not change this URL — it changes the 302 target.
| Condition | Status | Body / headers |
|---|---|---|
No / after the slug | 400 | {"error":"Missing path: /p/{slug}/{file}"} |
Slug unknown, not approved, or suspended | 404 | {"error":"Plugin not found"} |
Manifest has no usable assets.js to anchor dist/ | 500 | {"error":"Could not resolve plugin dist location"} |
| File not on the allow-list | 404 | {"error":"Asset not in plugin manifest"} |
| Served | 302 | Location: {distOrigin}{distBase}/{hashedFile}, Cache-Control: public, max-age=60, stale-while-revalidate=30, Access-Control-Allow-Origin: * |
The allow-list
A file serves only if either condition holds (Both):
- it is a manifest-declared asset — root
assets.js/assets.css/ssr.url, or any widget summary’sassets.js/assets.css/ssr.url— compared after dist-relative normalisation; or - it is a key of your own
dist/manifest.jsonfilesmap. This is how SDK-emitted images, font files and?urlimports reach the browser.
Hashed twins are map values, never keys, so a hashed filename is only reachable through the
302 — not by requesting it from /p/ directly.
assets.fonts entries are not in the declared list (manifestKnownFilename does not read
them). A dist-relative font file therefore serves only because rule 2 covers it — which it does,
because everything under dist/ except manifest.json and *.map lands in the files map.
Suite-only rule. For a manifest with widgets[], a file admitted by rule 2 must sit under a
first-level directory whose name equals a widget slug still present in the manifest central
serves:
// Source: packages/homerunner-embeddables/api/plugin-asset.ts:276-287
const slash = filePath.indexOf('/');
if (slash <= 0) return false;
const dir = filePath.slice(0, slash);
return widgets.some((w) => w?.slug === dir);
Two consequences:
- A suite asset emitted at the top of
dist/(no widget directory) can never serve through/p/. Keep bundled assets inside the widget’s own folder — which the SDK’sassetFileNamesdoes for you (§8). - Disabling a suite widget with the platform kill switch 404s that widget’s entire
dist/{widget}/directory — bundles, images and fonts — not just its picker entry, within the proxy’s 60 s TTL. See install-and-kill-switches.md.
Proxy caching internals
| Cache | TTL | Notes |
|---|---|---|
Plugin lookup (central /public-api/v1/plugins/{slug}) | 60 s per edge instance | Only 403/404/410 are negative-cached; 429, 5xx and timeouts keep serving the last good entry |
Build manifest, immutable prefix (/{X.Y.Z}/dist on a non-loopback host) | life of the edge instance | The prefix can never change, so neither can the map |
| Build manifest, legacy/author-hosted | 60 s | Files can be overwritten in place |
| Upstream fetches | 3 s timeout | A failed refresh serves the last good map (stale-on-error) |
| No build manifest at all | — | The 302 falls back to the logical filename, so a plugin that shipped none still serves |
Legacy alias
{dashboard}/api/plugin-assets/{slug}[@{version}]/{file} still 302s onto /p/{slug}/{file},
stripping any @version. It exists only for IIFEs cached at customer edges before the proxy
moved, it sends no Access-Control-Allow-Origin, and you must never emit it.
6. The crossorigin trap
Never put
crossorigin="anonymous"on a/p/script tag.
/p/ answers with ACAO: *, but it 302s off-host to the plugin CDN, and the redirect
target sends no Access-Control-Allow-Origin. In CORS mode the browser therefore rejects the
final response, the script never executes, and your plugin never registers on
window.HRPlugins — with no error you can attribute to the cause.
<!-- Source: src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:63-85 -->
<!-- runtime: same-host redirect chain, ACAO on every hop → crossorigin is safe -->
<script src="https://assets.homerunner.io/w/runtime.iife.js" crossorigin="anonymous"></script>
<!-- plugin bundle: NO crossorigin attribute, deliberately -->
<script src="https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js"></script>
<div data-hr-widget-container>
<div id="{widgetId}" data-hr-widget="plugin:pdp-suite:stay-hero" data-hr-min-height="160"></div>
</div>
The cost of leaving it off is that cross-origin script errors from your bundle reach the host
page’s window.onerror as the opaque "Script error.". That is the accepted trade.
CSP
Advisory (unvalidated) — nothing on the platform inspects a customer’s CSP.
A customer page that enforces Content-Security-Policy needs both origins, because the two serving paths use different ones:
| Directive | Needs | Why |
|---|---|---|
script-src | the asset origin (assets.homerunner.io) | runtime IIFE, /p/ entry point |
script-src | the plugin CDN origin (plugins.homerunner.io) | direct URLs on composed pages, and the /p/ 302 target |
style-src | both of the above | widget CSS follows the same two paths |
style-src / font-src | every absolute host in your assets.fonts (e.g. fonts.googleapis.com, fonts.gstatic.com) | declared fonts are injected as <link rel="stylesheet"> |
img-src / font-src | data: | assets ≤ 4096 bytes are inlined as data URLs (§8) |
Both origins are deployment-configurable (NEXT_PUBLIC_WIDGET_ASSET_BASE_URL,
R2_PUBLIC_BASE_URL); confirm the live values with a curl -sI on one of your own bundles
before handing a customer a CSP snippet.
7. SDK URL helpers
These live on the @homerunner-next/widget-core/urls subpath and are re-exported from the root
barrel. Full export inventory: sdk-exports.md.
| Export | Signature | Behaviour |
|---|---|---|
PLUGIN_WIDGET_PREFIX | "plugin:" | — |
parsePluginKeyword | (keyword: string) => PluginKeywordParts | null | §1 |
pluginWidgetDistFile | (parts, kind: "iife" | "css" | "ssr") => string | §2 |
DEFAULT_PROXY_BASE_URL | "https://assets.homerunner.io" | The asset origin, not the dashboard |
isAbsoluteUrl | (url: string) => boolean | Matches ^https?:// only. Protocol-relative //host/x and data: count as RELATIVE |
resolvePluginAssetUrl | (manifestUrl: string | undefined, urlOrPath: string) => string | Manifest URL first. Absolute input passes through; relative input resolves via new URL(urlOrPath, manifestUrl); throws when relative and manifestUrl is undefined |
distRelativeAssetName | (urlOrPath: string) => string | §2 |
extractAssetFilename | (urlOrPath: string) => string | Last path segment, query and hash stripped |
getPluginAssetUrl | (slug, file, proxyBaseUrl = DEFAULT_PROXY_BASE_URL) => string | {base}/p/{slug}/{file}, trailing slash stripped |
The throw, verbatim:
// Source: packages/homerunner-widget-core/src/urls.ts:93-96
Cannot resolve relative plugin asset "<x>": manifest URL not known. Ship absolute URLs in the
manifest, or pass the manifest URL when fetching.
On a server-rendered page that throw is caught and becomes a per-widget failure with the reason
bad plugin asset URL: … — the widget disappears, the page is marked degraded, the rest of the
page still renders.
Because path-only references resolve against the manifest’s own URL, a leading slash escapes
your version prefix entirely (/shared/foo.js → {cdn}/shared/foo.js). Never start a manifest
asset path with /. In a Suite manifest the audit rejects it for you (publish ERROR —
the confined-relative-path rule, see widget-summary.md); in the
Single-widget shape the root assets/ssr paths are checked only for presence, so a leading
slash is advisory (unvalidated) and 404s in production.
8. Bundled assets
Requires widget-core 0.12.0+.
Import images, icons and font files from your widget folder like any Vite project. The SDK rewrites the reference so it resolves against wherever the bundle was actually loaded from.
// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/widget.tsx:4,19
import heroBg from "./hero-bg.png";
// …
<img className="shero-bg" src={heroBg} alt="" data-testid="stay-hero-bg" />
/* Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/stay-hero.css:7 */
background: linear-gradient(120deg, #4f46e5, #9333ea), url(./hero-bg.png);
| Rule | Value | Shape | Enforcement |
|---|---|---|---|
| Inline threshold | a file of a listed extension ≤ 4096 bytes becomes a data: URL; above it, a real file is emitted | Both | build behaviour |
| Emitted name (JS-referenced) | dist/{widget}/{name}-{vitehash}{ext} (suite) · dist/{name}-{vitehash}{ext} (v1) | Both | build behaviour |
Emitted name (CSS url()) | same; the reference stays relative (url(./hero-bg-Cr1v4Zys.png)) and the browser resolves it against the stylesheet’s response URL | Both | build behaviour |
| Listed extensions | png jpg jpeg gif webp avif svg ico bmp woff woff2 ttf otf eot mp3 mp4 webm ogg wav pdf | Both | build behaviour |
Must live under dist/{widget}/ to serve via /p/ | yes | Suite | see §5 — silent 404 otherwise |
Vite’s library mode inlines everything unless a reference carries ?no-inline; the preset
appends that suffix for you above the threshold, which is why the limit works here at all. The
consequence: an asset import whose extension is not on that list is inlined as a data: URL
at any size, because nothing tags it ?no-inline. Keep unusual asset types off the import
graph and fetch them instead.
How the URL is computed at runtime
The build prepends a plain-ES5 banner to both the client IIFE and the SSR bundle:
// Source: packages/homerunner-widget-core/src/vite/plugin-assets.ts:57-69 (formatted;
// see the same code minified at the head of pdp-suite/dist/stay-hero/stay-hero.iife.js)
var __hrAssetBase_pdp_suite_stay_hero = (function () {
try {
if (typeof document !== "undefined" && document.currentScript && document.currentScript.src) {
var u = new URL(document.currentScript.src);
var segs = u.pathname.split("/");
segs.splice(-2); // 2 for a suite bundle, 1 for a v1 bundle
return u.origin + segs.join("/") + "/";
}
} catch (e) {}
return (typeof globalThis !== "undefined" && globalThis.__HR_PLUGIN_ASSET_BASE__) || "";
})();
function __hrAsset_pdp_suite_stay_hero(f) {
try { return new URL(f, __hrAssetBase_pdp_suite_stay_hero).href; } catch (e) { return f; }
}
The import site becomes __hrAsset_…("stay-hero/hero-bg-Cr1v4Zys.png") in both builds. The base
resolves to the plugin’s …/dist/ directory:
| Context | Base comes from | Value |
|---|---|---|
| Browser | document.currentScript.src minus 2 path segments (suite) or 1 (v1) | …/{env}/{slug}/{version}/dist/ |
| SSR sandbox | globalThis.__HR_PLUGIN_ASSET_BASE__, set by the renderer from the SSR bundle’s own URL | …/{env}/{slug}/{version}/dist/ |
npm run dev | document.currentScript.src | the Vite dev origin |
document.currentScript is only valid during the bundle’s synchronous top-level execution — the
banner runs there and captures the base once, so a later dynamic import cannot break it.
Not supported yet. Nothing verifies that a
dist/-relative reference actually shipped. Asset presence is advisory (unvalidated) at publish: only manifest-declared assets (assets.js,assets.css,ssr.url, sub-manifests,media/refs) are checked. A bundled asset lost by a bad build 404s in production with no earlier signal.
9. Web fonts — assets.fonts
Requires widget-core 0.12.0+.
@font-face is ignored inside a shadow root. A stylesheet loaded into your widget’s shadow
root can reference a font family but can never define one. That is the entire reason this field
exists: declared fonts are injected at document level, where the browser honours them.
// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/manifest.json (real, v1.3.3)
{
"slug": "pdp-frame",
"assets": {
"js": "dist/pdp-frame/pdp-frame.iife.js",
"css": "dist/pdp-frame/pdp-frame.css",
"fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
}
}
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
widgets[].assets.fonts | string[] | Optional (Suite) | Max 8 entries | publish ERROR — manifest widget "<slug>" "assets.fonts" must be a list of at most 8 stylesheets. |
widgets[].assets.fonts[] | string | — | Absolute http(s)://…, or a relative path with no leading /, no scheme: prefix, no \, no .. segment, ≤ 2048 chars | publish ERROR — manifest widget "<slug>" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix. |
root assets.fonts | string[] | Optional (Single-widget) | Same value rules | silent runtime truncation — the v1 root is not audited at publish; normalizeFonts drops invalid entries and slices to 8 at resolve time |
| File presence for a relative entry | — | — | must exist under dist/ | advisory (unvalidated) — a missing file 404s at runtime |
normalizeFonts also applies on the suite path: anything that slips past the audit is dropped,
and an over-long list is capped at 8 with no message.
The two injection paths
| Path | Who injects | Where | Dedupe key |
|---|---|---|---|
| Client (CSR embeds, hydration) | the ES5 banner compiled into your client IIFE | document.head | link[data-hr-font="{href}"], shared across every widget and plugin on the page |
| Server (composed pages) | the renderer appends resolved fonts to the widget’s css array; the worker turns that array into <link rel="stylesheet"> in <head> | <head> | none — the renderer owns the list |
The SSR bundle carries no font loader (loadFonts: false); the renderer does that job. This
is why you must declare fonts in the manifest rather than injecting them yourself: on a
server-rendered page your client code has not run at first paint.
Resolution differs per entry kind:
- Absolute
http(s)URLs pass through unchanged on both paths. This is the only channel in the whole plugin system that lets you reference a third-party stylesheet URL verbatim. - Relative entries go through §4’s hashed-CDN-first resolution, exactly like your bundles.
The client banner strips a leading
dist/and resolves the rest against the asset base.
Two behaviours worth knowing:
- Declared fonts share the same
cssarray as your widget stylesheet, so they also appear as<link>tags inside the Declarative Shadow DOM template — harmless duplication, but you will see them there when reading page source. (HR_DSD=inlinenever inlines afonts.googleapisURL; it stays a link.) - The build reads
assets.fontsfrommanifest.jsonat config time and bakes the list into the banner, filtering only for non-empty strings before slicing to 8 — it does not run the reference validator. Editingmanifest.jsonwithout rebuilding leaves a stale list in the client bundle, and a malformed entry can ship in the banner while the server-side resolver drops it.
Choosing families, dark-mode and font.family from the base schema are covered in
styling-and-theming.md.
10. Dev-server URLs
npm run dev serves the same URL shapes so mount()’s default resolution works unchanged. The
switch is the __HR_WIDGET_DEV__ define, set to true in dev and false in a build.
// Source: packages/homerunner-widget-core/src/runtime/mount.tsx:134-138
const cssFile = pluginWidgetDistFile(parts, "css");
if (isViteDev()) {
return [`/dist/${parts.pluginSlug}/${cssFile}`];
}
return [getPluginAssetUrl(parts.pluginSlug, cssFile, getProxyBaseUrlOverride())];
| Route | Shape | Serves |
|---|---|---|
/dist/{slug}/{slug}.css | Single-widget | Every .css imported from src/, run through the full Vite transform (Tailwind, PostCSS), concatenated in import order |
/dist/{slug}/{widget}/{widget}.css | Suite | The same, walking from src/widgets/{widget}/index.tsx so each widget’s dev stylesheet carries only its own imports |
/dist/* | Both | Static files from the project’s dist/, with Access-Control-Allow-Origin: * |
/w/{file} | Both | 302 to the hashed twin from dist/manifest.json — mirrors the production proxy contract |
/w/manifest.json, /manifest.json | Both | The build manifest and the root manifest |
CSS imported from src/ is redirected to a no-op virtual module in dev so Vite never injects a
<style> into document.head (a shadow root would ignore it anyway). On a .css change the
server emits the custom HMR event hr-widget-css-update instead of Vite’s default CSS HMR, and
a small client compiled into each widget entry cache-busts the <link> tags inside the shadow
root by appending ?t={timestamp}.
The production proxy origin is overridable per host page via
window.HRWidgetRuntime.proxyBaseUrl; plugin builds carry no environment-specific URL. More on
the dev sandbox: dev-sandbox-and-mocking.md.
Known gaps
Not supported yet. You cannot self-host any asset.
getStaticAssets()output is reduced to a dist-relative name and re-resolved against your published prefix, and register-by-URL is an admin-only escape hatch with no author-facing UI.assets.fontsabsolute URLs are the single exception.
Not supported yet. Pre-release versions (
1.0.0-rc.1) publish successfully but silently drop out of every immutability optimisation — direct hashed URLs, the proxy’s permanent build-manifest cache, and the slug-keyed SSR bundle cache all fall back to TTLs.
Not supported yet. A suite asset emitted outside
dist/{widget}/can never serve through/p/, and nothing warns you at build or publish time.
Not supported yet. Bundled and font asset presence is unvalidated at publish. Only manifest-declared bundles, sub-manifests and
media/refs are checked.
Symptom-first diagnosis for 404s, missing fonts and non-registering bundles: troubleshooting.md. Every number on this page is also indexed in limits-and-errors.md.
Packaging and publishing rules
You ship a plugin as a source zip. You never build the artifact that customers load: a reviewer downloads your zip, runs your build, zips the result, and a second audit decides whether that build may go on the CDN. Two zips, two audits, two different sets of rules — and the second audit runs after you have been told your submission was accepted. This page is the complete rule set for both.
Enforcement levels
| Level | Meaning on this page |
|---|---|
| publish ERROR | Blocks. Source-zip errors block the upload in your browser; built-zip errors block the publish after review. Both are hard failures. |
| publish warning | Shown, never blocks. |
| silent runtime truncation | Accepted, then trimmed with no message. |
| advisory (unvalidated) | Nothing checks it. |
The book-wide legend and the aggregate index of every number and message live in Limits and error index.
Error strings below are quoted verbatim as the tooling emits them. <name>, <path>,
<label>, N and X/Y are runtime substitutions.
The two zips
| Source zip | Built zip | |
|---|---|---|
| Made by | You | The reviewer, from your source |
| Audited by | auditPluginZip (your browser, before any bytes leave) | auditBuildZip (the reviewer’s browser, before any bytes reach the CDN) |
| Contains | manifest.json, package.json, src/, widgets/, media/, config files | Your source plus the whole dist/ tree |
| Must NOT contain | dist/, node_modules/, .git/ | node_modules/ (everything unrecognised is ignored) |
| Size ceiling | 20 MB | 60 MB |
| Uploaded to | A private bucket, presigned PUT, never through a server | The public plugin CDN, one presigned PUT per file |
| Failure means | You fix and re-zip | Your reviewed plugin does not go live; you resubmit with a bumped version |
The source zip
Required root entries
(Both shapes.) Two files must sit at the zip root — or inside a single wrapper folder, see below.
| Entry | Required | Rule | On violation |
|---|---|---|---|
manifest.json | Both — yes | Must parse as a JSON object | publish ERROR: manifest.json is missing from the zip root or is not valid JSON. |
package.json | Both — yes | Must parse as a JSON object | publish ERROR: package.json is missing from the zip root or is not valid JSON. |
package-lock.json | Both — no | Recommended: the reviewer build otherwise resolves fresh | publish warning: No package-lock.json — the reviewer build will resolve dependencies fresh. |
Only those two files are inflated by the audit, and only up to 2 MB each. A manifest.json larger
than 2 MB reads as missing — split a fat single-widget configSchema out, or move to the suite
shape where schemas live in widgets/*.manifest.json.
Wrapper folders and junk
(Both shapes.) If every non-junk entry shares one top-level directory, that directory is treated as the project root and every path rule below is applied relative to it. This is exactly what macOS Finder’s Compress produces, so zipping the project folder is fine.
__MACOSX/ entries and any file named .DS_Store are ignored entirely — they cannot fail an
audit and they are not counted.
One consequence: a zip with two top-level directories has no detected wrapper, so
manifest.json is looked for at the literal zip root and is not found.
Entry rules
(Both shapes.) Every entry in the central directory is checked. All of these are publish ERROR.
| Rule | Scope | On violation |
|---|---|---|
| No absolute paths | Any entry | "<name>": absolute paths are not allowed. |
No .. traversal | Any segment | "<name>": path traversal is not allowed. |
No node_modules | Any depth (path segment match) | "<name>": node_modules must not be included — submit source only. |
No .git | Any depth (path segment match) | "<name>": .git must not be included. |
No dist/ | Root level only, relative to the wrapper folder | "<name>": dist/ must not be included — the reviewer rebuilds it from source. |
| No symlinks | Any entry | "<name>": symlinks are not allowed. |
The dist/ ban is root-scoped: src/vendor/dist/thing.js is accepted, dist/anything is not.
node_modules and .git are banned at every depth.
Backslashes are normalised to / before any of these run, so a Windows-built zip is checked
identically.
Repeated violations of the same rule are de-duplicated, and this entry-level list is capped at
12 lines — a zipped node_modules shows a handful of representative paths, not hundreds.
Size and count limits
(Both shapes.) All publish ERROR.
| Limit | Value | On violation |
|---|---|---|
| Zip size | 20 MB | Zip is <N.N> MB — the limit is 20 MB. |
| Entry count | 2000 | Zip contains too many entries (<N> > 2000). |
| Uncompressed total | 100 MB | Zip expands to <N> MB — the limit is 100 MB. |
| Compression ratio | 100:1 | Zip compression ratio exceeds 100:1. |
Structural failures short-circuit the whole audit:
| Condition | On violation |
|---|---|
| No end-of-central-directory record | Not a zip archive (no end-of-central-directory record). |
| Zip64 archive | Zip64 archives are not supported for plugin submissions. |
| Central directory runs past EOF | Corrupt zip: central directory extends past the end of the file. |
| Malformed central-directory entry | Corrupt zip: bad central-directory entry. |
Zip64 is the one that surprises people: some archivers force the zip64 format regardless of size.
Use the platform archiver or zip -r.
The API re-checks the size independently and refuses anything outside 1 byte..20 MB with
The zip must be between 1 byte and 20 MB.
Manifest fields the source audit reads
The source audit checks the manifest’s shape, not your build. The full field contracts live in Manifest: root fields, Manifest: widget summary and Layout widgets; this table is only what blocks the upload.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
id | string | Both — yes | Non-empty; matches ^[a-z0-9][a-z0-9-]*$. It is the plugin slug. | publish ERROR: manifest.json is missing the required "id" field. / manifest "id" ("<X>") must be lowercase alphanumeric with hyphens — it is the plugin's slug. |
version | string | Both — yes | Non-empty semver (see Version rules) | publish ERROR: manifest.json is missing the required "version" field. / manifest "version" ("<X>") is not valid semver (e.g. 1.2.0). |
name | string | Both — yes | Non-empty | publish ERROR: manifest.json is missing the required "name" field. |
widgets[] | array | Suite — yes | A non-empty array switches the manifest to the suite shape and runs the per-widget summary audit | publish ERROR, see Manifest: widget summary |
widgetType | string | Single-widget — yes | Non-empty | publish ERROR: manifest.json is missing the required "widgetType" field. |
ssr.url | string | Single-widget — yes | Any string | publish ERROR: manifest.json is missing "ssr.url" (the SSR bundle path). |
assets.js | string | Single-widget — yes | Any string | publish ERROR: manifest.json is missing "assets.js" (the client bundle path). |
Which branch runs is decided by exactly one test: widgets is an array and non-empty. An
empty widgets: [] is read as the single-widget shape and will demand widgetType, ssr.url and
assets.js. See The two manifest shapes.
runtime.widgetCore is not checked here. It is stamped by the build, and it is only gated on
the built zip — so a stale stamp in your committed manifest.json cannot fail your submission.
What must be committed
This is the rule that bites after approval. The reviewer runs your build and zips the result; nothing else creates files. Anything your manifest declares that your build does not generate must be in the source zip, or the publish fails — long after you were told the submission was fine.
| Directory | Generated by | Must be in the source zip? |
|---|---|---|
media/ | Nothing. It is authored content: icons, cover, screenshots, README.md, CHANGELOG.md, per-widget docs. | Yes, always. A declared icon, cover, screenshots[] or readme missing from media/ fails the publish. |
widgets/*.manifest.json | Your build:manifest step, if you have one | Commit them unless npm run build regenerates them. They must exist in the built zip. |
dist/ | hr-widget-build | No — banned. The reviewer rebuilds it. |
node_modules/ | npm install | No — banned. |
If media/ is in your .gitignore, or your .svg icons live outside media/, your submission
passes and your publish does not.
A real source zip
# /Users/…/hr-plugins/pdp-suite-1.3.3-source.zip — 50 entries, 82 KB (published suite, v1.3.3)
package.json # required at the root
package-lock.json # optional; absent = warning
manifest.json # required at the root
tsconfig.json
vite.config.ts
scripts/inject-schema.mjs
widgets/pdp-frame.manifest.json # per-widget config schemas — generated, committed
widgets/stay-hero.manifest.json
widgets/stay-facts.manifest.json
widgets/booking-cta.manifest.json
media/README.md # declared as manifest.readme
media/CHANGELOG.md # picked up by convention, never declared
media/pdp-frame.svg # per-widget icons — FLAT media/<file>
media/stay-hero.svg
media/widgets/pdp-frame.md # per-widget READMEs — one nested level allowed
media/widgets/stay-hero.md
src/vite-env.d.ts
src/widgets/<widget>/{index.tsx,widget.tsx,config.ts,ssr-entry.ts,*.css,*.png}
# NOT present: dist/, node_modules/, .git/
media/CHANGELOG.md is fetched by filename convention when it exists. It is declared nowhere and
is never required.
Media reference rules, shape-tagged, all publish ERROR when broken:
| Field | Accepted shape | Max |
|---|---|---|
icon, cover, widgets[].icon | media/<file> (flat, no subdirectory) or an absolute http(s):// URL | 2048 chars |
screenshots[] | media/<file> only — no absolute URL, no subdirectory | no path cap |
readme, widgets[].readme | media/<file>.md or media/<dir>/<file>.md (one nested level) | 255 chars path, 64 KB file |
What the API checks after the upload
The browser audit is UX. Nothing in it is authoritative. After your zip is in the bucket, the metadata leg posts your parsed manifest to the API, which re-runs everything server-side and adds checks the browser cannot make.
| Code | HTTP | Meaning | Message |
|---|---|---|---|
VALIDATION_ERROR | 422 | Malformed submission envelope (bad sha256, size, notes over 5000 chars) | Laravel validation text |
INVALID_MANIFEST | 422 | A manifest field failed the server-side contract | e.g. Manifest missing required field "widgetType"., Manifest "id" must be lowercase alphanumeric with hyphens (it is the plugin slug)., Manifest declares more than 24 widgets. |
INVALID_ZIP_KEY | 422 | The uploaded object is not under your slug’s folder | zip_key must match submissions/<slug>/<file>.zip |
INVALID_VERSION | 422 | Not parseable semver, or not three-part | Manifest "version" is not valid semver: <X> / Manifest "version" must be MAJOR.MINOR.PATCH semver: <X> |
VERSION_NOT_GREATER | 422 | Not strictly greater than what is published | Version <X> must be greater than the published <Y>. |
SLUG_RESERVED | 409 | The slug exists with no owner (a legacy admin-registered row) | The plugin id "<X>" is reserved. Contact an administrator to claim it. |
SLUG_TAKEN | 409 | The slug belongs to another author | The plugin id "<X>" already belongs to another author. — or …was just registered by another author. when you lose a first-submission race |
Server-side manifest limits the browser does not check, all publish ERROR:
| Field | Limit | On violation |
|---|---|---|
id | 255 chars | Manifest "id" exceeds 255 characters. |
name | 255 chars | Manifest "name" exceeds 255 characters. |
version | 64 chars | Manifest "version" exceeds 64 characters. |
description | 4000 chars | Manifest "description" must be a string of at most 4000 characters. |
author.name, author.url | 255 chars each | Manifest "author.<field>" must be a string of at most 255 characters. |
widgets[] | 24 entries | Manifest declares more than 24 widgets. |
What happens to your zip and your submission after this point — review states, resubmission, who acts when — is in Preflight and submit.
Version rules
(Both shapes.) One version number covers the whole plugin, including every widget of a suite.
| Rule | Enforcement | On violation |
|---|---|---|
Semver shape (X.Y.Z, optional -pre and +build) | publish ERROR, browser | manifest "version" ("<X>") is not valid semver (e.g. 1.2.0). |
Exactly three numeric parts — 1.0 and 1 are refused | publish ERROR, API INVALID_VERSION | Manifest "version" must be MAJOR.MINOR.PATCH semver: <X> |
| Strictly greater than the currently published version | publish ERROR, API VERSION_NOT_GREATER | Version <X> must be greater than the published <Y>. |
| Never re-publish an existing version | publish ERROR at publish time | <slug>@<version> already exists on the CDN — published versions are immutable. A changed build needs a resubmission with a bumped version. |
The greater-than check compares against the live current_version (falling back to the cached
manifest’s version). A plugin that has never published has nothing to compare against, so a first
submission may use any valid version.
Not supported yet. Pre-release versions such as
1.0.0-rc.1are accepted by both the browser audit and the API, and they publish — but every immutability optimisation in the platform requires a bareX.Y.Zpath segment. A pre-release publish silently falls back to short-TTL caching everywhere and to proxied instead of content-addressed asset URLs. Do not use them. There is no warning.
The manifest shape is frozen at first publish
(Both shapes.) Once a plugin is live, it can never move between the single-widget shape and the
suite shape. Stored widget keywords (plugin:{slug} versus plugin:{slug}:{widget}) and embed
URLs customers have already copied would stop resolving.
The reviewer’s tooling refuses the build before upload with:
This build changes <slug> between single-widget and multi-widget shapes. Existing placements and
embed URLs would stop resolving — publish the new shape under a new plugin slug.
and the API refuses it at both publish and rollback with MANIFEST_SHAPE_CHANGED:
This manifest changes the plugin between single-widget and multi-widget shapes. Existing widget
placements and embed URLs would stop resolving — publish the new shape under a new plugin slug
instead.
The escape hatch is a new plugin slug, not a new version. Because a suite may contain a single widget, starting new plugins in the suite shape costs nothing and keeps the door open — see The two manifest shapes.
The built zip
You do not produce this zip, but every rule in it is a rule about your build output, and every
failure sends you back to a resubmission with a bumped version. Run npm run build, zip the
project folder without node_modules/, and check the list below before you submit the source.
The publishable surface
Only four kinds of path are read and uploaded. Everything else in the zip — src/,
package.json, tsconfig.json, your lockfile — is ignored and never published. Your source
is not served from the CDN.
# src/lib/plugin-zip.ts — the publishable-path regex, identical in the audit and
# in the server-side presign whitelist.
^(dist\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)?
|widgets\/[A-Za-z0-9._-]+\.manifest\.json
|media\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)?)$
# …plus the root manifest.json, matched separately.
Read that carefully: exactly one directory level under dist/, widgets/ and media/.
| Path | Published? |
|---|---|
manifest.json | Yes |
dist/acme.iife.js | Yes — flat single-widget layout |
dist/hero/hero.iife.js | Yes — one widget directory deep |
dist/hero/chunks/vendor.js | No. Silently dropped: no error, no warning. |
widgets/hero.manifest.json | Yes |
media/icon.svg, media/widgets/hero.md | Yes |
src/**, package.json, anything else | No |
A build that emits nested chunk directories under dist/{widget}/ produces a plugin whose
bundles 404 at runtime with nothing in the audit to explain it. Keep the SDK’s default output
layout.
Path segments may only use letters, digits, ., _ and -. A file under dist/, widgets/ or
media/ with any other character (a space, a @, a non-ASCII letter) is dropped with a publish
warning:
"<path>" won't be published — file names may only use letters, digits, ".", "_" and "-".
The server re-applies the same whitelist when presigning and rejects anything else outright with
Unexpected file path "<path>".
Size limits
| Limit | Value | Enforcement | On violation |
|---|---|---|---|
| Built zip | 60 MB | publish ERROR | Zip is <N> MB — zip the built plugin WITHOUT node_modules (60 MB limit). |
| Per publishable file | 30 MB | publish ERROR | "<path>" is over the 30 MB per-file limit. |
| Total publishable bytes | 150 MB | publish ERROR | The build expands to <N> MB — over the 150 MB limit. |
| Client bundle | 1.5 MB | publish warning | dist/<name> is <N.NN> MB. |
| Stylesheet | 512 KB | publish warning | dist/<name> is <N.NN> MB. |
| SSR bundle | 1 MB | publish warning | dist/<name> is <N.NN> MB — over the renderer's 1 MB cache threshold (slower cold SSR). |
| README (each) | 64 KB | publish ERROR | <label> <ref> is <N> KB — the limit is 64 KB. |
The 1 MB SSR warning is the one worth acting on: past it, the renderer stops caching your bundle in its fast path and every cold server render pays the load cost.
Publish gates
All publish ERROR. <label> is the plugin for a single-widget build and widget "<slug>" for
each summary of a suite.
| Gate | Shape | On violation |
|---|---|---|
Root manifest.json present and parseable | Both | manifest.json not found at the zip root — zip the plugin folder AFTER building. |
manifest.id matches the submission | Both | manifest id "<X>" does not match the submission's "<Y>". |
manifest.version matches the submission | Both | manifest version "<X>" does not match the submitted "<Y>". |
runtime.widgetCore present and semver | Both | manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild. |
runtime.widgetCore ≥ 0.10.0 | Both | Built against widget-core <X> — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild. |
dist/manifest.json present with {buildHash, files} | Both | dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build. |
Every declared asset exists under dist/ | Both | dist/<name> (<label>'s <kind>) is missing from the zip. — <kind> is client bundle, stylesheet or SSR bundle |
| Every declared asset has a hashed twin in the build manifest | Both | The build manifest has no hashed twin for <name>. |
| The hashed twin file itself is in the zip | Both | Hashed twin dist/<hashed> is missing from the zip. |
The client bundle contains the literal HRPlugins | Both | <label>'s client bundle never registers on window.HRPlugins — the widget would never mount. |
| Each declared sub-manifest is present and valid JSON | Suite | widgets/<x>.manifest.json (<label>'s sub-manifest) is missing from the zip or invalid JSON. |
assets.js declared | Single-widget | manifest is missing "assets.js". |
ssr.url declared | Single-widget | manifest is missing "ssr.url". |
icon / cover reference shape | Both | manifest "<field>" must be a "media/<file>" path or an absolute http(s) URL. |
Declared icon / cover file present | Both | Declared <field> <ref> is missing from the zip. |
widgets[].icon reference shape | Suite | widget "<slug>" icon must be a "media/<file>" path or an absolute http(s) URL. |
Declared widgets[].icon file present | Suite | Declared widget "<slug>" icon <ref> is missing from the zip. |
screenshots[] reference shape | Both | manifest screenshot "<X>" must be a "media/<file>" path. |
| Declared screenshot present | Both | Declared screenshot <ref> is missing from the zip. |
| README reference shape | Both | <label> must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. |
| Declared README present | Both | Declared <label> <ref> is missing from the zip. |
| README ≤ 64 KB | Both | <label> <ref> is <N> KB — the limit is 64 KB. |
Three of these deserve a note.
The HRPlugins marker. The audit decodes your client IIFE as UTF-8 and requires the literal
string HRPlugins to appear somewhere in it. Registration is a property write on
window.HRPlugins, a global name no minifier rewrites — so its absence means the bundle does not
register and the widget could never mount. If you tripped this, you did not call
registerPlugin/registerPluginWidget in the IIFE entry, or your bundler dropped it as dead
code. See Runtime, mount and the DOM contract.
Hashed twins. The SDK’s post-build step walks dist/ recursively, writes a sha256-8 twin
next to every file except manifest.json and *.map, and records the mapping in
dist/manifest.json. If a twin is missing, the build was assembled by hand or dist/ was edited
after the build. Rebuild — never patch dist/manifest.json.
Asset path resolution. A declared asset path is reduced to a dist-relative name before
lookup: dist/hero/hero.iife.js → hero/hero.iife.js, and a path with no dist/ segment falls
back to its basename. That name is both the dist/manifest.json key and the lookup key, so
assets.js must name the file exactly as the build emitted it.
// /Users/…/hr-plugins/pdp-suite/dist/manifest.json — a real suite build manifest
{
"buildHash": "77328e0a",
"generatedAt": "2026-09-07T06:25:25.567Z",
"files": {
"booking-cta/booking-cta.css": "booking-cta/booking-cta-a830c68f.css",
"booking-cta/booking-cta.iife.js": "booking-cta/booking-cta.iife-ca74009c.js",
"pdp-frame/pdp-frame-ssr.umd.js": "pdp-frame/pdp-frame-ssr.umd-5872d9b8.js",
"pdp-frame/pdp-frame.css": "pdp-frame/pdp-frame-9992937d.css",
"pdp-frame/pdp-frame.iife.js": "pdp-frame/pdp-frame.iife-be6fddd4.js",
"stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png",
"stay-hero/stay-hero.iife.js": "stay-hero/stay-hero.iife-f2a3fe21.js"
}
}
Keys and values are both dist-relative and include the widget directory. Bundled images are keyed
too. .map files are deliberately absent from this map — they are still uploaded to the CDN, but
they have no hashed twin and are not reachable through the asset proxy. See
Keywords, assets and URLs.
buildHash is recorded as publish forensics. No serving path reads it; it is not a cache key.
Publish warnings
Never blocking, but each one describes a plugin that will disappoint whoever installs it.
Single-widget — the root manifest never got its schema injected:
manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be
empty.
Suite — that widget’s sub-manifest is present but carries no schema:
<sub-manifest> has no configSchema — <label>'s dashboard form will be empty.
Both — your config zod does not extend the base, so the standard controls are gone:
<label>'s configSchema doesn't extend the base widgetSchema (no colorScheme/spacing) — standard
theme + spacing controls won't appear. Build config.ts as widgetSchema.extend({...}) and export
zodToManifestSchema(configSchema).
The check behind the last one is exactly this: the emitted JSON Schema has a properties object
that lacks a colorScheme key or a spacing key. See
Config schema and UI schema.
Not supported yet. Built-zip warnings are shown to the reviewer only. You will never see them in your dashboard, and nothing in your own tooling reproduces them. Treat the three rows above as checks to run yourself: build, then confirm
manifest.json(single-widget) or everywidgets/*.manifest.json(suite) contains aconfigSchemawhosepropertiesincludecolorSchemeandspacing.
What lands on the CDN
Key layout
Every published object is stored at {env}/{slug}/{version}/{path}.
# src/lib/r2.ts — cdnPluginKey(), and src/lib/plugin-zip.ts — the cacheControl assignment.
{env}/{slug}/{version}/manifest.json public, max-age=300
{env}/{slug}/{version}/dist/manifest.json public, max-age=300
{env}/{slug}/{version}/dist/<file> public, max-age=31536000, immutable
{env}/{slug}/{version}/dist/{widget}/<file> public, max-age=31536000, immutable
{env}/{slug}/{version}/widgets/{widget}.manifest.json public, max-age=31536000, immutable
{env}/{slug}/{version}/media/<file> public, max-age=31536000, immutable
{env}/{slug}/{version}/media/{dir}/<file>.md public, max-age=31536000, immutable
# {env} is prod (production dashboard), dev (preview deployments) or local (a laptop).
# One bucket serves all three; the prefix is what stops them colliding on an immutable key.
{env} is decided by the deployment doing the publishing, never by a request. Your plugin lives
under prod/. Any example URL without an {env} segment is wrong and will 404.
The cache split
Exactly two objects carry a short TTL: the root manifest.json and dist/manifest.json, both
public, max-age=300. Everything else — every bundle, stylesheet, image, font, source map,
sub-manifest and media/ file — carries public, max-age=31536000, immutable.
That is safe because the version is in the key. A new version is a new prefix; nothing is ever overwritten.
Content types are assigned from the file extension: .js/.mjs →
text/javascript; charset=utf-8, .css → text/css; charset=utf-8, .json/.map →
application/json; charset=utf-8, .md → text/markdown; charset=utf-8, .svg →
image/svg+xml, .woff2 → font/woff2, .png/.jpg/.jpeg/.webp/.gif as expected, and
anything unrecognised → application/octet-stream. Content-Type and Cache-Control are signed into
each presigned upload, so they cannot drift.
A font or image your build emits with an unlisted extension (.woff, .avif, .ttf) publishes
fine but is served as application/octet-stream. Stick to .woff2 for fonts.
Which of these URLs a customer’s page actually loads — direct content-addressed CDN URLs on
server-rendered pages, the /p/ proxy for CSR embeds — is
Keywords, assets and URLs.
Immutability and retry safety
Before a single byte is presigned, the publish HEADs {env}/{slug}/{version}/manifest.json. If it
exists:
<slug>@<version> already exists on the CDN — published versions are immutable. A changed build
needs a resubmission with a bumped version.
If the storage check itself fails, publish fails closed rather than risk overwriting a version the fleet caches forever:
Could not verify that <slug>@<version> is unpublished (storage check failed). Nothing was
uploaded — retry in a moment.
Uploads are then ordered deliberately: everything else first, dist/manifest.json
second-to-last, and the root manifest.json last. The root manifest is both the immutability
sentinel and the object the publish verifies, so a publish that dies mid-upload leaves no sentinel
and the same zip can be retried safely. Between 1 and 200 files may be uploaded per publish, and
the set must include manifest.json.
Practical consequence for you: a version number is spent the moment it publishes. A one-line
CSS fix to 1.2.0 is not a re-publish of 1.2.0; it is a submission of 1.2.1.
Where the rest of the pipeline is documented
- Preflight checklist, the upload flow, review states and resubmission — Preflight and submit
- What publishing does, how an update propagates, and rollback — Publishing, versions and rollback
- Suspension and per-widget kill switches — Install, customers and kill switches
- Which SDK version you need for suites, layouts, fonts and bundled assets — Install and versions
- Every number and message in one index — Limits and error index
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 |
Public API
Every endpoint your plugin may call lives under /public-api/v1 on HomeRunner Central. It is
the only HomeRunner data API a plugin can reach: there is no plugin-scoped API, no API key and
no per-plugin identity. The identifier in the URL — a feed id, a platform UUID, a property
UUID, a widget id — is what authorises the read.
Everything on this page applies to both manifest shapes (single-widget and suite). The API
does not know which shape you shipped. The one shape-dependent rule is where you declare
externalFetch, covered in Server-side.
Enforcement on this page is an HTTP response, not a publish-time check. No audit inspects
the URLs your code builds, so a call with a misspelled parameter passes review and misbehaves
silently in production. Each parameter table’s On violation column says what actually
happens; advisory (unvalidated) means the value is accepted and then ignored or clamped with
no error. The book-wide enforcement legend lives in
Limits and error index.
Base URL
Resolve the host at runtime. Never hard-code it — the same plugin bytes are served to production, staging and local rigs, and only the runtime knows which Central it is paired with.
// packages/homerunner-widget-core/src/runtime/api.ts:33-44 (verbatim)
export function homerunnerApiBaseUrl(): string {
if (typeof window !== "undefined") {
const fromRuntime = window.HRWidgetRuntime?.apiBaseUrl;
if (fromRuntime) return fromRuntime;
}
const envValue =
typeof process !== "undefined"
? process.env?.NEXT_PUBLIC_HOMERUNNER_BASE_URL
: undefined;
if (envValue) return envValue;
return "https://central.homerunner.io";
}
Resolution order, highest first:
| # | Source | Where it is set |
|---|---|---|
| 1 | window.HRWidgetRuntime.apiBaseUrl | Baked into the env-matched runtime IIFE the host page loads. Authoritative in the browser on customer pages, the dashboard playground, staging and production. |
| 2 | process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL | Vite-injected from your .env.local during npm run dev; also whitelisted into the renderer’s SSR sandbox environment. |
| 3 | https://central.homerunner.io | Hard fallback. |
Build every URL as `${homerunnerApiBaseUrl()}/public-api/v1/...`. The helper is exported
from @homerunner-next/widget-core/runtime — see SDK reference.
Authentication
There is no token, header or signature. Two middlewares decide whether a request is served, and which one a route uses determines which identifier you must supply.
| Middleware | What it resolves | Failure |
|---|---|---|
auth.public.v1 | The first present of platform_uuid → property_uuid → widget_id → feed_id, preferring the route segment. Query fallbacks are ?platform=, ?property=, ?widget=, ?feed= (each logs a server-side warning). Confirms the resource exists; a platform must be active. | 404 NOT_FOUND for an unknown platform, property, widget or feed; 400 BAD_REQUEST with Platform is not active., or with Platform UUID, Property UUID, Widget ID, or Feed ID is required when none is present. |
auth.public.feed.v1 | feed_id only — route segment, else ?feed_id=, else ?feed=. | 401 UNAUTHENTICATED when it is missing; 404 NOT_FOUND when the feed does not exist. |
| (none) | GET /widgets and GET /plugins/{slug} carry no auth middleware at all. Both enforce their own gate in the controller. | See their entries below. |
Because auth.public.v1 resolves the first identifier it finds, a request that carries both
a route feed_id and a ?platform= UUID resolves as the platform. Prefer route segments over
query fallbacks: the query forms work but are logged as suspect and may be withdrawn.
Always spell the query parameter feed_id, never feed. auth.public.feed.v1 accepts
either, but the controllers behind it read only feed_id (or the route segment). Sending
?feed= therefore passes authentication and then fails inside the controller with 400
Feed ID is required. — an error that looks like a bug in your code and is not.
Both middlewares then run one shared availability gate over every feed the request touches, not just the identifier that matched. Three states refuse the request:
| Condition | Status | error.code | error.message |
|---|---|---|---|
| Feed switched off at the edge | 403 | FEED_DISABLED | This feed is no longer being served. |
| Feed archived | 403 | FEED_ARCHIVED | This feed has been archived. |
| Owning account suspended | 403 | ACCOUNT_SUSPENDED | Your account is suspended. Please contact support. |
Treat all three as permanent for the life of the page: do not retry, render your empty or error state. A property attached to several feeds is refused only when every one of them is blocked.
Not supported yet. A plugin has no credential of its own. You cannot prove to Central that a request came from your widget rather than from anyone who read the feed id out of the page source, and you cannot request elevated scope. Everything reachable here is data the feed already publishes. Do not design a feature that depends on privileged reads.
Rate limit
300 requests per minute, per client IP, across every /public-api/* endpoint combined.
// homerunner-central/app/Providers/RouteServiceProvider.php:73 (verbatim)
RateLimiter::for('public-api', fn (Request $request) => Limit::perMinute(300)->by($request->ip()));
- The throttle runs before authentication, so refused requests (bad feed id, archived feed, 404s) consume budget too.
- Successful responses carry
X-RateLimit-LimitandX-RateLimit-Remaining. - The bucket is the IP, not the feed or the widget. On a server-rendered page every widget on that page shares the renderer’s IP; one cold property page can easily cost a dozen calls. Batch and cache accordingly, and see Fetching data.
- A
429body is the canonical error envelope witherror.codeTOO_MANY_REQUESTS, but a gateway may answer before Central does — always readres.statusbefore parsing JSON.
Not supported yet. You cannot read your remaining budget from a
429. The throttle raises an exception, and the exception handler rebuilds the response as the canonical envelope — the throttle’s ownRetry-AfterandX-RateLimit-Resetheaders do not survive that rebuild. Separately, Central’s CORS configuration setsexposed_headersto an empty list, so browser JavaScript cannot readX-RateLimit-*even on a success. Back off on a schedule of your own; do not wait for a header. Server-side code in the SSR sandbox is not subject to CORS, but in a normal render its GETs go through the sandbox’s cached-fetch wrapper, which rebuilds the response withContent-Typeonly — so it cannot read the success headers either. They survive only on non-GET requests and in?dev=1renders, which bypass the cache.
CORS is otherwise fully open for reads: allowed_origins: ["*"], allowed_methods: ["*"],
supports_credentials: false. Never send credentials or cookies — they would be rejected.
Response envelope
Success — two shapes, not one
There is no single success envelope. Older controllers wrap the payload in result, newer ones
in data. Check the per-endpoint entry before writing a type.
// Shape A — older controllers (helper: centralApiResponse; injects status_code)
{ "status": "ok", "status_code": 200, "result": { }, "pagination": { } }
// Shape B — newer controllers
{ "success": true, "status_code": 200, "data": { }, "meta": { } }
Shape A endpoints: /details, all /feed/* property and meta routes, /property/{id},
/property/{id}/full, /property-*, /reviews, /feed/widgets, /feed/plugins,
/plugins/{slug}, quote reads and writes (result).
Shape B endpoints: /widget/{id}, /widgets, /property/{id}/payment-settings, the two
Stripe routes, /{quote_id}/checkout-url, /reservations/{id} and its payment-methods POST.
Errors — one canonical envelope
Every /public-api/* error — thrown, forwarded, validation failure, unmatched route, wrong
verb, 429 — is funnelled through one helper by the exception handler.
// homerunner-central/app/PublicApi/V1/Helpers/ErrorEnvelope.php:98-109
{
"success": false,
"status_code": 404,
"error": {
"code": "NOT_FOUND",
"message": "The requested resource was not found.",
"details": {} // ALWAYS a JSON object. {} when there is nothing to say.
}
}
details is deliberately coerced to an object so {} never arrives as []. On a 422 it is
{ "<field>": ["<message>", …] }.
Status → error.code and the default error.message, verbatim from
ErrorEnvelope::codeForStatus and ErrorEnvelope::defaultMessageForStatus:
| Status | error.code | Default error.message |
|---|---|---|
| 400 | BAD_REQUEST | The request was invalid. |
| 401 | UNAUTHENTICATED | Authentication is required to access this resource. |
| 402 | PAYMENT_FAILED | Payment could not be processed. |
| 403 | FORBIDDEN | You do not have permission to access this resource. |
| 404 | NOT_FOUND | The requested resource was not found. |
| 405 | METHOD_NOT_ALLOWED | The HTTP method used is not allowed for this endpoint. |
| 409 | CONFLICT | The request could not be completed due to a conflict. |
| 422 | VALIDATION_ERROR | The request could not be validated. |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded. Please try again later. |
| ≥ 500 | INTERNAL_SERVER_ERROR | Something went wrong. Please try again later. |
| any other 4xx | ERROR | An error occurred. |
Which message you actually see
This is the rule that surprises people. In production (APP_DEBUG off) most of the specific
messages a controller throws are discarded and replaced by the status default above. Do not
string-match on them.
| Status | What error.message contains in production |
|---|---|
400, 402, 409, 422 | The throw site’s own message, stripped of ids, emails, URLs and payment-processor tokens — provided it passes a guest-safety screen. A message longer than 300 characters, starting with {, [ or <, or containing any of SQLSTATE, Stack trace, vendor/, .php, Exception:, DOCTYPE, token, secret, credential, whitelist, api key, client_id, authorization server, redis, connection refused, undefined array key, undefined variable, undefined index, call to a member, must be of type, curl error, could not resolve, ssl certificate, {", </, platform= is dropped and replaced by the status default. This is why Platform credentials are missing reaches you as The request was invalid. |
401, 403, 404, 405, 429, 5xx | Always the status default. The specific text (Feed not found, This feed has been archived., Quote not found.) exists only when debug mode is on, which production never is. |
Field-validation failures are the exception to the exception: a controller that runs a
validator builds the envelope itself, so a 422 always arrives verbatim as
error.message Validation failed with error.details set to
{ "<field>": ["<message>", …] }. That map is the only part of any error response you can
usefully show a customer.
Endpoint sections below therefore quote a message verbatim only where production really emits
it — everywhere else they name the status and error.code.
Nine codes are emitted outside the status table by middleware and controllers that build their response directly, and are therefore always verbatim:
error.code | Status | Emitted by |
|---|---|---|
FEED_DISABLED, FEED_ARCHIVED, ACCOUNT_SUSPENDED | 403 | the shared availability gate |
MISSING_PARAMETER | 400 | GET /widgets with no ids |
WIDGET_NOT_FOUND | 404 | GET /widget/{id} |
NOT_FOUND / The requested plugin was not found. | 404 | GET /plugins/{slug} |
QUOTE_EXPIRED, QUOTE_EXPIRED_UNAVAILABLE, QUOTE_EXPIRED_PRICE_CHANGED | 409 | POST /{quote_id}/reservation |
Match on status_code first and treat error.code as a refinement — these responses also
serialise details as [], not {}, because they bypass ErrorEnvelope.
Business rejections forwarded from a PMS do survive: a Guesty refusal such as
Listing is not available. minimumStay does not meet booking criteria. arrives at 400 with
its text intact, and is safe to render.
Pagination
Three different conventions are live. Using the wrong page-size parameter is the single most common plugin bug — it fails silently and you get the default page size.
| Endpoint family | Page-size param | Default | Bounds | pagination key style |
|---|---|---|---|---|
/{feed_id}/feed/properties, /{platform_uuid}/properties | limit | 10 | none enforced | camelCase |
/feed/related-properties | limit | 3 | none enforced | camelCase |
/property-images | limit | 50 | 1–100, 422 outside | both styles in one object |
/feed/property-names | limit | 100 | clamped 1–500 | camelCase |
/feed/availability | limit | 20 | clamped 1–50 | camelCase |
/reviews | per_page | 10 | 1–20, 422 outside | snake_case |
Every paginated endpoint takes page (1-based, default 1).
- camelCase block:
{ page, pageSize, totalPages, totalItems, hasNextPage, hasPrevPage }. - snake_case block:
{ page, page_size, total_pages, total_items, has_next_page, has_prev_page }. /property-imagesemits both key sets in the same object./reviewsadditionally carries a deprecatedpagination.average_rating; readmeta.average_ratinginstead.
per_pageis advisory (unvalidated) on the property endpoints — it is accepted, never read, and you silently receive 10 rows. Three of the four published reference plugins (featured-stays,stats-spotlight,amenity-match) ship this bug against/feed/propertiestoday. Uselimitthere andper_pageonly on/reviews.
/{feed_id}/feed/collection and /{feed_id}/feed/property-pins are not paginated at all
and ignore limit/page entirely.
Identifying a property
A {property_identifier} path segment and the ?property= query parameter are feed slugs,
resolved through the feed’s own property table against property_slug or
additional_property_slug. A property UUID does not resolve there.
| Form | Resolves by | Requires |
|---|---|---|
{property_identifier} in the path, ?property= in the query | feed slug (or additional slug) | a feed_id, always — without it you get 400 Feed ID is required to resolve property. |
{property_uuid} in the path (/{property_uuid}/calendar, /property/{property_uuid}/quotes) | properties.uuid, resolved by the auth middleware | nothing else |
id in a list-endpoint row | uuid ?? id — the public identity of a property is its UUID | — |
A slug resolves only when the property is still active and still a member of that feed;
otherwise 404 NOT_FOUND. A feed-scoped payload also overlays the feed’s own
overrides (title, guest cap, rating, meta fields, slug) on top of the base row.
Endpoint index
All paths are relative to {base}/public-api/v1. feed = auth.public.feed.v1,
v1 = auth.public.v1.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /{feed_id}/details | v1 | Feed row |
| GET | /{feed_id}/feed/properties | v1 | Property cards, filtered + paginated |
| GET | /{feed_id}/feed/collection | v1 | Whole catalogue, flat scalar rows, unpaginated |
| GET | /{feed_id}/feed/availability | v1 | Multi-property calendar grid |
| GET | /{feed_id}/feed/related-properties | v1 | Same-city cards for one source slug |
| GET | /{feed_id}/feed/explorer-meta | v1 | Filter vocabulary across the feed |
| GET | /{feed_id}/feed/property-names | v1 | name + slug suggestions |
| GET | /{feed_id}/feed/property-pins | v1 | Whole filtered set as map pins |
| GET | /{feed_id}/feed/widgets | v1 | Widgets configured on the feed |
| GET | /{feed_id}/feed/plugins | v1 | Plugins installed and enabled on the feed |
| GET | /{platform_uuid}/properties | v1 | Property cards for one platform |
| GET | /{platform_uuid}/explorer-meta | v1 | Filter vocabulary for one platform |
| GET | /property/{property_identifier} | feed | One property, columns only |
| GET | /property/{property_identifier}/full | feed | Property + images + descriptions + amenities |
| GET | /property/{property_identifier}/payment-settings | feed | Public payment config |
| POST | /property/{property_identifier}/stripe/setup-intents | feed | Stripe SetupIntent |
| POST | /property/{property_identifier}/stripe/verify-cvc | feed | Server-side CVC check |
| GET | /property-details | feed | One property, columns only, no path segment |
| GET | /property-descriptions | feed | Long-form descriptions |
| GET | /property-amenities | feed | Amenity list |
| GET | /property-spaces | feed | Rooms and bed breakdown |
| GET | /property-images | feed | Gallery, paginated |
| GET | /property-calendar | feed | Availability + pricing by date |
| GET | /{property_uuid}/calendar | v1 | Same, addressed by UUID |
| GET | /property/{property_uuid}/calendar | v1 | Same, addressed by UUID |
| GET | /reviews | feed | Reviews with filters |
| POST | /quotes | feed | Create a quote |
| POST | /{property_uuid}/quotes | v1 | Create a quote, addressed by UUID |
| POST | /property/{property_uuid}/quotes | v1 | Create a quote, addressed by UUID |
| GET | /quotes/{quote_id} | feed | Read a quote |
| POST | /quote/update | feed | Mutate a quote |
| GET | /{quote_id}/checkout-url | feed | PMS-hosted checkout URL |
| POST | /{quote_id}/reservation | feed | Convert a quote to a reservation |
| GET | /reservations/{reservation_id} | feed | Confirmation lookup (hash-gated) |
| POST | /reservations/{reservation_id}/payment-methods | feed | Attach or retry payment (hash-gated) |
| GET | /widget/{widget_id} | v1 | One widget row |
| GET | /widgets?ids= | none | Bulk widget rows |
| GET | /plugins/{slug} | none | Plugin manifest metadata |
An unmatched path returns 404 NOT_FOUND; a wrong verb returns 405 METHOD_NOT_ALLOWED.
There is no fallback route.
Feed
GET /{feed_id}/details
Auth auth.public.v1. No parameters.
Returns the feed row as stored, minus its encrypted credentials, plus a computed
development_mode_active boolean: { id, user_id, name, feed_url, additional_info, archived_at, development_mode, development_mode_expires_at, edge_disabled_at, development_mode_active, created_at, updated_at } under result.
additional_info.branding is the feed’s theme (colorScheme, lightModeColors.accent,
darkModeColors.accent, font.size, font.family) — the values that back the base config
fields described in Config schema and UI schema.
The endpoint does not eager-load relations.
platforms[],properties_countandslugare absent from the response even though the SDK’s exportedFeedtype declares the first two. Treat them as optional and never index into them without a guard.
fetchFeed(feedId) from @homerunner-next/widget-core/runtime wraps this endpoint, throwing
WidgetFetchError unless res.ok and body.status_code === 200.
Properties
GET /{feed_id}/feed/properties
Auth auth.public.v1. The primary listing endpoint: filtered, sorted, paginated property
cards. GET /{platform_uuid}/properties is the same controller scoped to one platform.
Only feed members with status = 1 are returned; being on one of the feed’s platforms is
not enough.
Paging and scope
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
limit | int | no | Page size. Default 10. No maximum is enforced. | advisory (unvalidated) — a non-numeric value is not rejected here |
page | int | no | 1-based. Default 1. | out-of-range page returns an empty result |
platform | UUID | no | Narrows to one platform inside the feed’s scope. | unknown UUID is ignored, not an error |
platform_id | int | no | Same, by internal id. | advisory (unvalidated) |
locale | string | no | Canonical locale (fr_fr). Localises amenity and tag name, and adds a labels map. | unknown locale falls back to the PMS name |
Filters — all optional, all applied with AND between them. A blank or 0 value reads as
absent everywhere (price_min=0, bedrooms=0 and guests=0 do nothing).
| Param | Type | Rule | On violation |
|---|---|---|---|
types | csv int | Property type ids. | advisory (unvalidated); unknown ids simply match nothing |
amenities | csv int | Match any of these amenity ids. | advisory (unvalidated) |
amenities_and | csv int | Match all of these amenity ids. | advisory (unvalidated) |
tags, locations, groups, cities | csv int | Taxonomy term ids. | advisory (unvalidated) |
state, country | csv string | Free-text columns, exact match on any listed value. | advisory (unvalidated) |
featured | bool | true only. Parsed with FILTER_VALIDATE_BOOLEAN, so "false" correctly disables it. | advisory (unvalidated) |
pets | bool | Pet-friendly only. Same parsing. | advisory (unvalidated) |
guests | int | maximum_guest_count >= n. | advisory (unvalidated) |
bedrooms | int | bedroom_count >= n. | advisory (unvalidated) |
bedrooms_eq | int | Exact bedroom count. Overrides bedrooms when both are sent. | advisory (unvalidated) |
bathrooms | int | bathroom_count >= n. | advisory (unvalidated) |
price_field | base | lowest | average | Chooses which stored price column price_min/price_max compare against and which one sort_by price sorts read. Anything else falls back to base. | silently falls back to base |
price_min, price_max | float | Inclusive bounds on the price_field column. | advisory (unvalidated) |
status | active | inactive | bool | Property status. | advisory (unvalidated) |
keywords | string | LIKE %value% on properties.name only — not on title. | advisory (unvalidated) |
exclude | csv int | Exclude these Central property ids. | advisory (unvalidated) |
ctid | csv | Restrict to these ids — matched against either the integer id or the UUID. | advisory (unvalidated) |
exclude_property | csv slug | Exclude these feed slugs. Needs a resolved feed. | ignored without a feed id |
include_property | csv slug | Restrict to these feed slugs. If none resolve, the result is empty — it does not fall back to the whole feed. | ignored without a feed id |
map_bounds | west,south,east,north | Geo box. Properties without both coordinates are excluded. | ignored unless exactly 4 numeric parts |
Dated availability — supply checkin and checkout to switch the endpoint into
availability mode.
| Param | Type | Rule | On violation |
|---|---|---|---|
checkin, checkout | date | Both required together. Parsed with strtotime. | 400 Invalid checkin or checkout date. |
checkin must be strictly before checkout. | 400 Checkout date must be after checkin date. | ||
adults, children | int | When either is present, guests is recomputed as adults + children, overwriting any guests you sent. | advisory (unvalidated) |
guests | int | Capacity filter. In a dated search with no guest count anywhere, it defaults to 1 so the date filter still applies. | advisory (unvalidated) |
A dated response adds datesPrice: { total, average, lowest, nights } per card, computed over
the requested window.
Sorting — sort_by, one of:
featured · title_asc · title_desc · bedrooms_asc · bedrooms_desc · bathroom_asc ·
bathroom_desc · guests_asc · guests_desc · lowest_price_asc · lowest_price_desc ·
average_price_asc · average_price_desc · base_price_asc · base_price_desc ·
created_asc · created_desc · rating_asc · rating_desc · random
Note the asymmetry: bedrooms_* is plural, bathroom_* is singular. sort_by is
advisory (unvalidated) — an unrecognised value produces no error and no ordering clause.
rating_* is feed-scoped and a no-op on the platform route. random accepts sort_seed
(integer); without one the seed is the current date, so the order is stable for a day. A
deterministic tiebreaker on property id is always appended last, so paging is repeatable.
Response (Shape A). Each row:
// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:2053-2127
{
"id": "uuid-or-id", "propertyType": "Villa", "title": "Big Fish Lodge",
"price": 420, // integer, base_daily_rate
"lowestPrice": 380, "averagePrice": 450, "highestPrice": 610,
"bookableNights": 120, // null = no forward calendar at all
"calendarSyncedAt": "2026-09-01T04:00:00+00:00", "calendarStale": false,
"images": ["https://…"], "altText": "Big Fish Lodge", "tag": "Lakefront",
"location": { "label": "Asheville", "lat": 35.5951, "lng": -82.5515 },
"type": { "id": 7, "name": "Villa" }, "city": { "id": 12, "name": "Asheville" },
"groups": [], "locations": [], "tags": [], "amenities": [], // {id,name}[]
"subtitle": { },
"features": { "guests": 8, "baths": 3, "beds": 4 },
"max_pets": 2, "updated_at": "2026-08-30T11:02:00.000000Z",
"slug": "big-fish-lodge", // present only on feed-scoped calls
"rating": { }, // present only on feed-scoped calls
"datesPrice": { } // present only on a dated search
}
slug is the join key for every per-property endpoint. id is uuid ?? id and is what map
pins and card dedupe compare against.
GET /{feed_id}/feed/related-properties
Auth auth.public.v1. Cards in the same city as a source property, source excluded.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
slug | string | yes | The source property’s feed slug. Named slug, not property — ?property= is consumed by the auth middleware as a UUID and 404s before the controller runs. | 400 Property slug is required. |
limit | int | no | Default 3. | as /feed/properties |
platform | UUID | no | Narrows within the feed. | ignored if unknown |
all /feed/properties filters | no | Passed through. City and source-exclusion are applied first and cannot be overridden. | as above |
Default order is is_featured desc, created_at desc; sort_by overrides it. A source property
with no city returns 200 with an empty result and a zeroed pagination block — not an error.
Response shape is identical to /feed/properties.
GET /{feed_id}/feed/collection
Auth auth.public.v1. The feed’s entire active catalogue as flat, scalar-only rows, in one
unpaginated response. Filters, sorting, limit and page are all ignored.
Every key is present on every row (null-padded): id, title, slug, property_type, alt_text, tag, price, price_lowest, price_average, price_highest, currency_code, lat, lng, location_label, city, state, country, post_title, meta_title, meta_description, guests, baths, beds, rating_average, rating_count, amenities, tags, locations, image_count, post_excerpt_length, post_excerpt_1…5, image_1…10. Taxonomies are comma-joined name strings;
the description is split into five ≤ 2000-character parts with the true length in
post_excerpt_length.
This endpoint loads the whole catalogue in one query. Do not call it from a widget render path.
GET /{feed_id}/feed/property-pins
Auth auth.public.v1. The full filtered set reduced to map markers — the answer to
“/feed/properties only gives me pins for the current page”.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
cap | int | no | Server cap on rows. Default 2000, clamped to 50–2000. | silently clamped |
platform, platform_id, price_field | no | As /feed/properties. | as above | |
/feed/properties filters and dated params | no | Shared code path, so the pin set can never disagree with the card set. | as above |
map_bounds, sort_by, limit and page are stripped before filtering — the endpoint is
never viewport-bounded and never ordered.
Response: result: { pins: [{ id, slug, title, lat, lng, price?, currency? }], total, capped }.
total is how many properties matched, not how many were returned; capped is true when the
cap truncated the set. price/currency appear only when the stored price is greater than
zero. Undated requests are cached server-side for a short window (see
Limits and error index); dated ones never are.
GET /{feed_id}/feed/property-names
Auth auth.public.v1. Lightweight { name, slug } suggestions. Rows with no feed slug are
skipped.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
q | string | no | Case-insensitive LIKE on name or title. | — |
limit | int | no | Default 100, clamped 1–500. | silently clamped |
page | int | no | Default 1. | — |
GET /{feed_id}/feed/availability
Auth auth.public.v1. Multi-property calendar rows, one per property.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
start | date | no | Defaults to today. | 400 Invalid start or end date. |
end | date | no | Defaults to start + 30 days. Must be ≥ start. | 400 End date must be on or after start date. |
| Span capped at 92 days. | 400 Date range must not exceed 92 days. | |||
platform | UUID | no | Must belong to this feed. | 404 NOT_FOUND |
guests | int | no | maximum_guest_count >= n. | advisory (unvalidated) |
limit | int | no | Default 20, clamped 1–50. | silently clamped |
page | int | no | Default 1. | — |
Each row: { id, slug, title, thumbnail, currency_code, availability }, where availability
is keyed by date with the same per-day object as the calendar endpoints (below) and is {}
when the property has no rows in the window.
GET /property/{property_identifier} and GET /property-details
Auth auth.public.feed.v1. The same columns-only projection, reachable two ways.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes | Required by auth.public.feed.v1, and again by slug resolution. /property-details treats it as optional in the controller, but the middleware does not. | 401 UNAUTHENTICATED when absent; 400 Feed ID is required to resolve property. when only ?feed= was sent |
property | string | /property-details: yes | Feed slug. | 400 Property identifier is required. |
Returns the property’s own columns with credential-bearing relations stripped, plus
city: {id,name}, type: {id,name}, max_pets, and — on /property-details with a feed —
a rating aggregate. Feed-level overrides (title, guest cap, rating, meta_title,
meta_desc, explorer_subtitle, multi_calendar_title, slug) are overlaid where set.
fetchPropertyBySlug(feedId, slug) in @homerunner-next/widget-core/runtime wraps
/property-details and returns body.result ?? {}.
GET /property/{property_identifier}/full
Auth auth.public.feed.v1. The one endpoint that returns a property with its amenities,
images and descriptions pre-joined.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes | 400 Feed ID is required. | |
include | csv | no | Opt in to extra blocks: reviews, calendar. Nothing else is recognised. | unknown values ignored |
image_limit | int | no | Default 50. | advisory (unvalidated) |
description_limit | int | no | Default 100. | advisory (unvalidated) |
review_limit | int | no | Default 10. Only with include=reviews. | advisory (unvalidated) |
from, to | date | no | Calendar window. Only with include=calendar. Defaults today → today + 1 year. | advisory (unvalidated) |
Result: { property, images[], descriptions[], amenities[], reviews?: { items[], average_rating }, calendar?: { "YYYY-MM-DD": {…} } }. Descriptions are ordered with the
platform’s default locale first — read descriptions[0] only as a fallback.
GET /property-descriptions, /property-amenities, /property-spaces
Auth auth.public.feed.v1. All three require feed_id and property (a feed slug):
400 Feed ID is required. / 400 Property identifier is required.; 404 NOT_FOUND
for an unknown feed or a property that is not in it; 403 FORBIDDEN for an archived feed.
| Endpoint | Extra params | result |
|---|---|---|
/property-descriptions | limit (default 100) | { default_locale, descriptions[] } — default-locale row ordered first |
/property-amenities | — | { amenities: [{ id, name, … }] } |
/property-spaces | — | { spaces: [{ id, name, number, type, privacy, has_private_bathroom, beds, total_bed_count }] } |
GET /property-images
Auth auth.public.feed.v1.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
property or property_uuid | string | yes | Feed slug. The legacy property_uuid spelling is accepted and is also read as a slug. | 401 UNAUTHENTICATED |
feed_id | int | yes | Required by the auth middleware and by slug resolution. | 401 UNAUTHENTICATED; 400 Feed ID is required to resolve property. when only ?feed= was sent |
limit | int | no | Default 50. Validated 1–100. | 422 VALIDATION_ERROR |
has_caption | 0/1/true/false | no | Only captioned / only uncaptioned. | 422 VALIDATION_ERROR |
position_min, position_max | int ≥ 0 | no | Inclusive position window. | 422 VALIDATION_ERROR |
page | int | no | Default 1. | — |
result is [{ url, caption, position }] ordered by position. meta.property.name carries
the property name. This is the only endpoint that emits both pagination key styles.
Calendars
GET /property-calendar (auth auth.public.feed.v1, params feed_id + property slug) and
GET /{property_uuid}/calendar / GET /property/{property_uuid}/calendar (auth
auth.public.v1, addressed by UUID) return the same object.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
from | date | no | Default today. | an unparseable date is not validated and surfaces as 500 INTERNAL_SERVER_ERROR |
to | date | no | Default today + 1 year. | as above |
// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:2517-2542
{
"status": "ok",
"result": {
"2026-01-15": {
"date": "2026-01-15",
"available": 1, "checkin": 1, "checkout": 0, // 0 / 1, not booleans
"min_stay": 2, "max_stay": 30,
"price": 150.00
}
}
}
Dates with no calendar row are simply absent from the map — iterate your own date range and treat a missing key as unknown, not as available.
Explorer metadata
GET /{platform_uuid}/explorer-meta and GET /{feed_id}/feed/explorer-meta, auth
auth.public.v1. The feed variant aggregates and de-duplicates across every platform on the
feed and returns 404 NOT_FOUND when the feed has none.
| Param | Type | Required | Rule |
|---|---|---|---|
price_field | base | lowest | average | no | Which column the returned priceRange bounds are computed from. Must match the price_field you send to /feed/properties or your slider and your filter disagree. |
locale | string | no | Localises amenity and tag labels and adds a labels map. |
// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:798-820
{
"placeTypes": ["Villa"], // name strings
"priceRange": { "min": 80, "max": 950 },
"amenities": ["Hot Tub"], // name strings
"location": { "cities": [{ "id": 12, "name": "…", "title": "…",
"description": "", "image": "…" }],
"states": ["NC"], "countries": ["USA"] },
"taxonomies": { // id-bearing — use THESE for filters
"types": [{ "id": 7, "name": "Villa" }],
"amenities": [], "locations": [], "groups": [], "tags": [], "cities": []
}
}
Filter parameters take ids, so read taxonomies.*, not the flat name arrays. The
location.cities[].image field is a placeholder URL, not real content.
Reviews
GET /reviews
Auth auth.public.feed.v1. Note the middleware: feed_id is what authorises the call.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes (auth) | Authorises the request. Does not filter the results. | 401 UNAUTHENTICATED |
property_id | int | no | A Central integer property id. Cast with (int) before use, so a UUID becomes 0 and the filter is dropped, and a CSV keeps only the first id. | advisory (unvalidated) |
property | string | no | Feed slug; resolved to a property id. The reliable way to scope to one property. | 404 NOT_FOUND |
platform_id | int | no | All properties on that platform. Note: platform (a UUID) is not read here. | advisory (unvalidated) |
per_page | int | no | Default 10. Validated 1–20. | 422 VALIDATION_ERROR, error.message Validation failed |
page | int | no | Default 1. | — |
rating | csv int | no | Exact ratings, e.g. 4,5. Validated only as a string. | 422 VALIDATION_ERROR if not a string |
min_rating, max_rating | int | no | Validated 1–5. 0 is rejected — omit the parameter to mean “no filter”. | 422 VALIDATION_ERROR |
source, channel | csv string | no | Max 100 chars. Exact match on any listed value. | 422 VALIDATION_ERROR |
author | string | no | Max 255. Substring; % and _ are escaped. | 422 VALIDATION_ERROR |
date_from, date_to | date | no | date_to must be ≥ date_from. | 422 VALIDATION_ERROR |
has_response | bool | no | true/false/1/0/on/off. An unrecognised value is dropped before validation. | silently ignored |
search | string | no | Max 255. Substring on title or content. | 422 VALIDATION_ERROR |
order_by | enum | no | Default date_desc. rating_, date_, created_, author_, source_, channel_, property_name_ each _asc/_desc. | 422 VALIDATION_ERROR |
meta.average_rating is computed from the property_id / platform_id scope before the
rating, source, date and search filters are applied — it is a stable badge value, not the mean
of the rows you received.
Not supported yet.
/reviewscannot be scoped to a feed. The feed filter exists in the controller but is commented out, sofeed_idauthorises the request and contributes noWHEREclause. A call carrying onlyfeed_idreturns an unscoped page of reviews, not the feed’s. Always sendproperty,property_idorplatform_id— otherwise your widget renders rows that do not belong to the page it is on.
Row shape: { id, property_id, property_uuid, property_name, title, content, response, rating, date, author, source, channel, guests_count, created_at, updated_at }.
Quotes and reservations
These are write endpoints against a live PMS. Rate-limit budget and PMS latency both apply; never call them during SSR.
POST /quotes · POST /{property_uuid}/quotes · POST /property/{property_uuid}/quotes
/quotes uses auth.public.feed.v1 with feed_id in the body; the UUID forms use
auth.public.v1.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
feed_id | int | yes | Body or query. Required by auth.public.feed.v1 and again by the controller. | 401 UNAUTHENTICATED when absent; 400 Feed ID is required when only ?feed= was sent; 404 NOT_FOUND for an unknown feed |
property | string | yes | Feed slug. | 422 VALIDATION_ERROR |
checkin | date | yes | Must be after today. | 422 VALIDATION_ERROR |
checkout | date | yes | Must be after checkin. | 422 VALIDATION_ERROR |
adults | int | yes, unless guests is sent | 1–50. | 422 VALIDATION_ERROR when neither is present |
guests | int | no | 1–50. Copied into adults when adults is absent. | 422 VALIDATION_ERROR |
children | int | no | 0–50. | 422 VALIDATION_ERROR |
infants | int | no | 0–20. | 422 VALIDATION_ERROR |
pets | int | no | 0–10. | 422 VALIDATION_ERROR |
additional_guests | int | no | 0–50. Stored on the quote’s additional_info. | 422 VALIDATION_ERROR |
coupon | string | no | ≤ 100 chars. | 422 VALIDATION_ERROR |
ip_address | string | no | Must be an IP. Auto-filled from the request when absent. | 422 VALIDATION_ERROR |
referrer | string | no | ≤ 500 chars. | 422 VALIDATION_ERROR |
Success is 201 with Shape A (result): { id, property_id, status, checkin, checkout, guests, adults, children, infants, pets, additional_info, nights, ip_address, referrer, rateplans, coupons, addons, source, expires_at, created_at, updated_at, property: { title, slug } }.
expires_at is real — a quote expires and reservation creation rejects an expired one. Show a
countdown or re-quote before submitting.
Money contract on rateplans[]: discount, stayDiscount, couponDiscount and
promotionalDiscount are always >= 0 and always reductions; a length-of-stay price increase
is reported separately as surcharge (>= 0), never as a negative discount. totalRent is
pre-discount, total is post-discount. Invoice line items keep signed amounts for display.
This is uniform across PMS integrations — never Math.abs() or sign-guess these fields.
POST /quote/update
Auth auth.public.feed.v1. Only the fields you send change; everything else is carried over
from the stored quote.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
quote_id | string | yes (route or body) | 404 NOT_FOUND | |
checkin | date | no | Must be after today. | 422 VALIDATION_ERROR |
checkout | date | no | Must be after checkin when both are sent. | 400 Checkout date must be after checkin date |
adults | int | no | 1–50. | 422 VALIDATION_ERROR |
children | int | no | 0–50. | 422 VALIDATION_ERROR |
infants | int | no | 0–20. | 422 VALIDATION_ERROR |
pets | int | no | 0–10. | 422 VALIDATION_ERROR |
additional_guests | int | no | 0–50. | 422 VALIDATION_ERROR |
coupon | string | no | ≤ 100. Omitting it keeps the existing coupon. | 422 VALIDATION_ERROR |
PMS-level refusals: 400 Method not supported; 400 The request was invalid. when the
platform’s credentials are missing (the real message names credentials and is screened out);
503 INTERNAL_SERVER_ERROR when the integration is in maintenance.
GET /quotes/{quote_id}
Auth auth.public.feed.v1. Same result shape as the create call. Pass feed_id to get
property.slug resolved; without it the slug is null. 404 NOT_FOUND for an unknown id.
GET /{quote_id}/checkout-url
Auth auth.public.feed.v1. Shape B: data: { url }. Refusals: 404 NOT_FOUND for an
unknown quote or a quote whose property is gone; 400 Platform not connected to an integration.; 400 External checkout not supported by this integration.; 400
The request was invalid. when platform credentials are missing (screened out by name).
Check payment-settings.reservation_target first: homerunner means the local checkout flow
is supported, anything else means the PMS hosts it and this endpoint is the right call.
POST /{quote_id}/reservation
Auth auth.public.feed.v1. Converts a quote into a reservation. Only properties whose
payment-settings.reservation_target is homerunner can be booked here — anything else is
refused at 400.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
first_name, last_name | string | yes | Guest identity. | 400, all validator messages joined into one string |
email | string | yes | Must be a valid address. | 400 |
phone | string | yes | 400 | |
language | string | no | xx or xx_YYYY / xx-YYYY. | 400 |
rateplan | string | no | The id of one of the quote’s rateplans[]. An unmatched id silently falls back to rateplans[0]. | advisory (unvalidated) |
reservation_method | inquiry | request | instant | homerunner | no | Defaults to the property’s own method. | 400 Reservation method {value} not supported. |
payment_settings.payment_status | string | no | paid or authorized marks the booking pre-paid. The legacy payment_paid boolean is still honoured and takes precedence. | advisory (unvalidated) |
Success is 201:
// homerunner-central/app/Services/ReservationService.php:978-985
{
"success": true,
"status_code": 201,
"data": {
"reservation": { /* the reservation row, property and platform stripped */ },
"confirmation_hash": "$2y$..."
}
}
Store data.confirmation_hash. It is the ?hash= value the confirmation endpoints below
require, and it is returned exactly once — the key is confirmation_hash, not hash.
This is the one endpoint that does not use the canonical error envelope. Its errors are built by the private-API helper, so
401is spelledUNAUTHORIZED(notUNAUTHENTICATED) and422isUNPROCESSABLE_ENTITY(notVALIDATION_ERROR), and every message except the429one collapses toAn error occurred. Please try again.in production. A stale quote that could not be transparently re-validated returns409with one of three machine-readable codes:QUOTE_EXPIRED(request a fresh quote and retry),QUOTE_EXPIRED_UNAVAILABLE(the dates are gone — do not retry) andQUOTE_EXPIRED_PRICE_CHANGED(re-quote and re-consent the guest before charging). Branch onerror.code; the message will tell you nothing.
GET /reservations/{reservation_id}
Auth auth.public.feed.v1 plus an in-controller hash check.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
hash | string | yes | Query string. Verified against the reservation id. | 400 Confirmation hash is required.; 403 FORBIDDEN on a mismatch |
Shape B. data carries { id, type, status, checkin, checkout, guests, adults, children, infants, pets, nights, currency, amount, first_name, property_id, rateplan, payment_settings, property: { id, title, image_url } }.
Guest contact fields (last_name, email, phone) and the payment-provider block
(payment_processor, stripe_publishable_key, has_secure_stripe, payment_provider_id)
appear only while the reservation can still be paid — status not closed, and
payment_status empty, pending or failed. On a settled reservation they are absent, not
null. rateplan is whitelisted to id, title, currency, total, subTotal, totalRent, totalFees, totalTaxes, discount, surcharge, securityDeposit, cleaningFee, invoice_items, invoiceItems.
POST /reservations/{reservation_id}/payment-methods
Auth auth.public.feed.v1 plus the same hash gate. Attaches or retries a payment method.
Refused with 409 This reservation is not awaiting payment. when payment_status is
anything other than empty, pending or failed. Returns the refreshed reservation in Shape B.
GET /property/{property_identifier}/payment-settings
Auth auth.public.feed.v1. Requires feed_id. Shape B. Public payment configuration only —
no secret keys are ever returned.
data: { payment_processor, reservation_method, inquiry_requires_payment, stripe_publishable_key, has_secure_stripe, payment_provider_id, payment_target, reservation_target, currency_code, cancellation_policy, rental_condition, payment_terms[], house_rules[] }.
payment_processor is one of stripe, amaryllis, card, none. payment_terms[] entries
are { event, amount_type, amount, time_relation, time_amount, time_unit }.
Refusals: 400 Feed ID is required.; 404 NOT_FOUND for an unknown feed or a property
that is not in it; 403 FORBIDDEN for an archived feed.
POST /property/{property_identifier}/stripe/setup-intents and /stripe/verify-cvc
Auth auth.public.feed.v1, both require feed_id. setup-intents takes no body and returns
the SetupIntent in data. verify-cvc requires payment_method_id in the body
(400 Payment method id is required.) and returns data: { cvc_check }.
Widgets and plugins
GET /{feed_id}/feed/widgets
Auth auth.public.v1. Active widget rows for the feed, built-in and plugin:* alike.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
expand | bool | no | false (default) returns { id, keyword, display_name } only. true returns the full row and, for plugin:* rows, inlines plugin_manifest and plugin_manifest_url. | any non-boolean reads as false |
A manifest is inlined only when the plugin is approved and enabled on that feed. The
inlined manifest already has the feed’s per-widget toggles applied, so a widget switched off
for the feed is absent from widgets[]. This is the SSR kill switch — see
Install, customers and kill switches.
GET /widget/{widget_id}
Auth auth.public.v1. Shape B: data: { id, status, keyword, user_id, feed_id, display_name, settings, created_at, updated_at }. 404 WIDGET_NOT_FOUND / Widget not found.
settings is the raw stored object. It is not validated, not defaulted and not the same as
what your component receives — see
Config schema and UI schema for parseWidgetConfig.
fetchWidget(widgetId) from @homerunner-next/widget-core/runtime wraps this endpoint and
throws WidgetFetchError unless res.ok and body.success.
GET /widgets?ids=
No auth middleware. The feed-availability gate runs in the controller instead, and refuses the whole batch — not a filtered subset — if any widget belongs to a blocked feed.
| Param | Type | Required | Rule | On violation |
|---|---|---|---|---|
ids | csv UUID | yes | Comma-separated widget ids. | 400 MISSING_PARAMETER / The ids parameter is required |
Shape B, data is an array. Unknown ids are simply absent — the response is not padded and
carries no error, so compare lengths yourself.
GET /plugins/{slug}
No auth middleware. Approved plugins only.
Shape A: result: { plugin: { slug, name, status, featured, manifest_url, manifest_cache } }.
manifest_cache is the published manifest; manifest_url is the absolute URL it was fetched
from and is the base for resolving path-only ssr.url and assets.* entries — see
Keywords, assets and URLs.
404 with error.code NOT_FOUND and error.message The requested plugin was not found.
for an unknown slug and for a pending or suspended one — suspension takes a plugin’s assets
offline through this endpoint.
GET /{feed_id}/feed/plugins
Auth auth.public.v1. Plugins installed and enabled on the feed, approved only, ordered by
sort_order.
Shape A: result: { plugins: [{ plugin_id, slug, name, featured, enabled, config, sort_order, manifest_url, manifest }] }. manifest has both the platform-wide kill switch and the feed’s
own widget toggles already applied.
Calling from a plugin
Client-side
Ordinary browser fetch under ordinary CORS. No host restrictions apply.
// Corrected from hr-plugins/stats-spotlight/src/data.ts:27-37 — the shipped
// version sends `per_page`, which /feed/properties ignores. `PropertiesResponse`
// is declared alongside it in the same file.
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";
export async function fetchProperties(
feedId: number,
opts: { limit?: number; platform?: string } = {},
): Promise<PropertiesResponse> {
const url = new URL(`${homerunnerApiBaseUrl()}/public-api/v1/${feedId}/feed/properties`);
url.searchParams.set("limit", String(opts.limit ?? 12));
if (opts.platform) url.searchParams.set("platform", opts.platform);
const res = await fetch(url.toString());
// Read the status BEFORE parsing: a 429 may not be JSON at all.
if (!res.ok) throw new WidgetFetchError(res.status, `properties: HTTP ${res.status}`);
return res.json();
}
Throw WidgetFetchError rather than a bare Error, so isRateLimitError,
isPermanentError and retryTransient classify your failures the same way they classify the
SDK’s own. Every helper named on this page — homerunnerApiBaseUrl, fetchFeed,
fetchWidget, fetchPropertyBySlug, WidgetFetchError, isRateLimitError,
isPermanentError, retryTransient, getProxiedImageUrl — exists in every publishable
widget-core (0.10.0+). Full signatures are in SDK reference; retry and
query-key discipline are in Fetching data.
Server-side
Inside getInitialData / dehydrateState the renderer hands your module a wrapped fetch,
not the platform one. Central’s host is always allowed. Any other host must be declared in
externalFetch — on the widget’s summary entry for a suite, on the root manifest for a
single-widget plugin — or the call throws before the socket opens. Loopback, private,
link-local and cloud-metadata hosts are refused even when declared. Redirects are followed
manually and every hop is re-validated. The exact block messages, the host versus host:port
form and the redirect cap live in
Component and SSR module.
Server-side GETs are additionally served from a short in-process cache (duration in Limits and error index), so data you fetch during SSR can be slightly stale. That is deliberate: composed pages are cached for hours anyway, and one uncached property page fans out to many public-api calls against a per-IP budget.
Images
getProxiedImageUrl(url, width?, height?) from @homerunner-next/widget-core/utils rewrites
any image URL from the API through HomeRunner’s resizing proxy
(https://hrimgs.co/w:{w}/h:{h}/rt:fill/plain/{url}), defaulting to 64 × 64. Scheme-less and
protocol-relative inputs get https://; a URL with no image extension gets -homerunner.jpg
appended so the proxy treats it as JPEG. Empty input is returned unchanged.
Known gaps
Not supported yet. There is no write API beyond the quote and reservation flow. You cannot create, update or delete feeds, properties, widgets or plugin state from a widget.
Not supported yet. There are no webhooks, no subscriptions and no change feed. Every update is a poll, and every poll is charged against the shared per-IP rate limit.
Not supported yet. There is no cursor pagination, no
fields/sparse-fieldset parameter and no batch endpoint that takes a list of slugs. To hydrate N properties you make N calls to/property/{slug}/full, or one wide/feed/propertiescall and accept its card projection.
Not supported yet.
sort_by,price_fieldand every taxonomy filter are unvalidated. A typo produces a200with a silently different result set, so there is nothing to catch in an error handler. Assert on the shape of what you got back instead.
Limits and error index
Every hard number the platform enforces, and every message it can show you, in one place. Other pages state a rule in context and link here for the number and the exact string; this page is the normative home for both.
Paste a message you got into your browser’s find-in-page. Each row names the cause and links to the page that explains the fix.
Enforcement levels
This legend is used throughout the book. Four levels, and only four:
| Level | What it means | Where you see it |
|---|---|---|
| publish ERROR | Blocks. Nothing is uploaded, published or made live until you fix it. | Your browser (source-zip audit), the reviewer’s browser (built-zip audit), or a coded 4xx from central. |
| publish warning | Recorded, never blocking. The build still goes live. | Source-zip warnings appear in your uploader. Built-zip warnings appear only to the reviewer — see Known gaps. |
| silent runtime truncation | Accepted at publish, then quietly clipped or dropped when the platform reads it. No message, anywhere. | Nowhere. You only notice the missing behaviour. |
| advisory (unvalidated) | Nothing checks it at any layer. A typo is yours to find. | Nowhere. |
The three gates a publish ERROR can come from
| Gate | Runs where | Audits what | You see it |
|---|---|---|---|
| Source-zip audit | Your browser, before any bytes leave your machine (auditPluginZip) | Your submission zip and its manifest.json | Immediately, in Dashboard → Plugins → My plugins |
| Central submit / publish / rollback API | Central, after upload | Slug ownership, version monotonicity, manifest shape, widget-core stamp | As a coded 4xx surfaced by the dashboard |
| Built-zip audit | The reviewer’s browser at publish (auditBuildZip) | The zip the reviewer built from your source | Never directly — only if the reviewer relays it in review notes |
The source-zip audit and the built-zip audit share one widget-summary validator, so a widgets[]
mistake fails at gate 1 (fast) rather than gate 3 (slow). Central re-validates the same rules
server-side; the browser audit is UX, not the authority.
Limits
Manifest counts and sizes
| Limit | Value | Shape | Enforcement | Message / behaviour |
|---|---|---|---|---|
| Widgets per plugin | 24 | Suite | publish ERROR | manifest declares {n} widgets — the limit is 24 per plugin. |
| Presets per plugin | 8 (0.11.0+) | Suite | publish ERROR | manifest "presets" must be a list of at most 8 entries. |
prefill keywords per slot | 8 (0.12.1+) | Suite (layouts) | publish ERROR and silent runtime truncation | manifest widget "{slug}" slot "{name}" needs a "prefill" list of at most 8 keywords. At runtime normalizeLayoutSlots dedupes, drops malformed entries and slices to 8 with no message. |
assets.fonts per widget | 8 (0.12.0+) | Both | publish ERROR and silent runtime truncation | manifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets. normalizeFonts filters invalid entries and slices to 8. |
| Slot name length | 64 chars | Suite (layouts) | publish ERROR | manifest widget "{slug}" has an invalid or duplicate slot name. |
expectedHeight | 0 <= n <= 10000 (px) | Suite | publish ERROR | manifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px). A value of 0 also means “no reservation”: data-hr-min-height is emitted only when > 0. |
Media ref length (icon, cover, widgets[].icon) | 2048 chars | Both | publish ERROR | manifest "icon" must be a "media/<file>" path or an absolute http(s) URL. |
| Font ref length | 2048 chars | Both | publish ERROR | manifest widget "{slug}" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix. |
| README ref length | 255 chars | Both | publish ERROR | {label} must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. |
| README file size | 64 KB | Both | publish ERROR | {label} {ref} is {n} KB — the limit is 64 KB. The dashboard also drops an over-size README silently when fetching it for display. |
tags shown on the plugin page | first 12 | Both | silent truncation (display only) | Extra tags are stored and served, just not rendered. |
Submission notes | 5000 chars | Both | publish ERROR (central) | 422 VALIDATION_ERROR |
Field-by-field rules for these live in Manifest: root fields, Manifest: widget summary and Layout widgets.
Source zip
The zip you upload. Rules are checked in your browser before a single byte is transmitted.
| Limit | Value | Enforcement | Message |
|---|---|---|---|
| Compressed size | 20 MB | publish ERROR | Zip is {n} MB — the limit is 20 MB. The presign step re-checks with The zip must be between 1 byte and 20 MB., and central again with 422 VALIDATION_ERROR |
| Entry count | 2000 | publish ERROR | Zip contains too many entries ({n} > 2000). |
| Uncompressed total | 100 MB | publish ERROR | Zip expands to {n} MB — the limit is 100 MB. |
| Compression ratio | 100:1 | publish ERROR | Zip compression ratio exceeds 100:1. |
| Inflated for inspection | manifest.json and package.json only, 2 MB each | silent | A larger manifest.json is treated as unreadable → manifest.json is missing from the zip root or is not valid JSON. |
| Per-entry error lines | de-duplicated, first 12 | display | Per-entry rule violations are collapsed to one line per rule and capped at 12; the manifest/package.json checks are appended after that cap. |
Forbidden content and the required root files are in Packaging and publishing rules; their exact strings are indexed under “Source-zip audit” below.
Built zip and its size warnings
The zip the reviewer produces from your source. You never upload it, but its limits decide whether your reviewed plugin actually goes live.
| Limit | Value | Enforcement | Message |
|---|---|---|---|
| Zip size | 60 MB | publish ERROR | Zip is {n} MB — zip the built plugin WITHOUT node_modules (60 MB limit). |
| Per publishable file | 30 MB | publish ERROR | "{path}" is over the 30 MB per-file limit. |
| Total publishable bytes | 150 MB | publish ERROR | The build expands to {n} MB — over the 150 MB limit. |
| Files per publish request | 1–200 | publish ERROR | Between 1 and 200 files per publish. |
Directory depth under dist/, widgets/, media/ | exactly one level | silent — file is not published, no error | dist/a/b/c.js is dropped without a word. |
| File-name charset | [A-Za-z0-9._-] per segment | publish warning | "{path}" won't be published — file names may only use letters, digits, ".", "_" and "-". |
Size warnings — never blocking, and visible only to the reviewer:
// src/lib/plugin-zip.ts (auditBuildZip) — the three soft thresholds, per widget
if (plan.js) logical.push({ kind: "client bundle", name: plan.js, warn: 1_500_000 });
if (plan.css) logical.push({ kind: "stylesheet", name: plan.css, warn: 512_000 });
if (plan.ssr) logical.push({ kind: "SSR bundle", name: plan.ssr, warn: 1_000_000 });
// → `dist/{name} is {n} MB.`
// → `dist/{name} is {n} MB — over the renderer's 1 MB cache threshold (slower cold SSR).`
The 1 MB SSR threshold is not cosmetic: a bundle over 1,000,000 bytes is skipped by the renderer’s Redis bundle cache entirely, so every cold lambda re-fetches it from the CDN.
Runtime bounds
Every timeout, cap and cache window the platform applies to a plugin at run time.
| Bound | Value | Governs | What happens at the edge |
|---|---|---|---|
| SSR module-scope execution | 5000 ms (floor 250 ms; PLUGIN_SSR_EXEC_TIMEOUT_MS) | Synchronous work in your SSR bundle’s module scope | The vm throws; the loader returns null; your widget becomes a failure node. It does not bound async work your module queues, nor getInitialData/dehydrateState. |
| SSR bundle fetch redirects | 0 | Fetching ssr.url | HTTP {status} redirect refused — SSR bundles must be served directly from their published URL |
externalFetch redirects | 5 hops, each re-validated | Your server-side fetch | [plugin] fetch blocked — more than 5 redirects. |
| Sandbox GET response cache | 60 s, ok-GET only, 50 entries LRU | Your server-side fetch on non-dev renders | Server-side data can be up to 60 s stale. ?dev=1 renders bypass it (still enforced, just uncached). |
| Sub-manifest fetch | 5 s (AbortSignal.timeout(5000)) | Dashboard reading widgets/{slug}.manifest.json | A timeout or non-2xx degrades to a schemaless config panel, not an error. |
| Build-manifest fetch (renderer) | 3 s | Resolving hashed CDN asset URLs | Falls back to the /p/ proxy URL. |
Build-manifest fetch (/p/ proxy) | 3 s | Mapping a logical file to its hashed twin | Serves the logical name; cached for the life of the edge instance when the dist location is a versioned prefix. |
| Shadow-root CSS load | 3000 ms | waitForShadowLinks before revealing a migrated/ponyfilled root | The widget is revealed unstyled rather than staying hidden. |
| Child widget registration | 10000 ms | A CSR layout waiting for a slot child’s IIFE | [widget-core] widget "{type}" did not register within 10000ms — that slot child stays empty; the layout still renders. |
| Evaluated SSR module cache | 48 entries LRU | Warm SSR renders | Versioned (/{x.y.z}/dist/…, non-loopback) URLs never expire; legacy author-hosted URLs expire after 5 min. |
| Redis SSR bundle cache | skip above 1,000,000 bytes | Cold-start bundle text | Versioned bundles are slug-keyed with a 30-day backstop (a publish overwrites them); legacy bundles are URL-keyed with a 5-min TTL. |
/p/ proxy pointer TTL | 60 s | Which plugin/version the proxy resolves to | Only 403/404/410 from central are negative-cached; 429/5xx/timeouts keep serving the last good entry. |
| Composed page cache | 86400 s (24 h) | The customer page your widget renders into | A page containing any data-hr-widget-error= breadcrumb, or a failed prefetch, is marked degraded and drops to 60 s. A 404 is negative-cached for 60 s. |
| Publish purge fan-out | concurrency 4, 30 s in-request + 150 s after | How fast a publish reaches live pages | Feeds not reached inside the budget fall back to the 24 h page TTL. |
Public-API rate limiting is a separate contract — see Public API. Cache-Control headers on published files are in Keywords, assets and URLs.
Two of these bounds also apply on your laptop (widget-core 0.12.2+): npm run dev:ssr
applies the same 5000 ms module-scope budget and the same 5-hop externalFetch redirect cap.
Neither is adjustable from npm run dev:ssr — SSRDevServerOptions exposes no knob for
either. Only a hand-rolled renderPluginSSR / evaluatePluginSSR call can move the
module-scope budget, via execTimeoutMs (SDK reference); the redirect cap is
fixed everywhere. Nothing else in the table has a local equivalent — the caches, the page TTL
and the purge budgets are all platform-side.
Error index
1. Source-zip audit (your browser, before upload)
Structure and content of the zip itself.
| Message | Cause | Fix |
|---|---|---|
Zip is {n} MB — the limit is 20 MB. | Compressed zip over 20 MB | Exclude node_modules/, dist/, .git/ — see Packaging and publishing rules |
Not a zip archive (no end-of-central-directory record). | Not a zip, or truncated | Re-create the archive |
Zip64 archives are not supported for plugin submissions. | Zip64 extensions in use (usually a huge or >65535-entry archive) | Shrink the archive |
Corrupt zip: central directory extends past the end of the file. | Truncated upload | Re-create the archive |
Corrupt zip: bad central-directory entry. | Malformed archive | Re-create the archive |
Zip contains too many entries ({n} > 2000). | Over 2000 entries | Prune generated/vendored files |
"{name}": absolute paths are not allowed. | An entry name starts with / | Zip from inside the project directory |
"{name}": path traversal is not allowed. | A .. segment in an entry name | Same |
"{name}": node_modules must not be included — submit source only. | node_modules at any depth | Delete it before zipping |
"{name}": .git must not be included. | .git at any depth | Same |
"{name}": dist/ must not be included — the reviewer rebuilds it from source. | A root-level dist/ (root-level only; nested dist dirs are fine) | Delete dist/ before zipping |
"{name}": symlinks are not allowed. | A symlink entry | Replace with the real file |
Zip expands to {n} MB — the limit is 100 MB. | Uncompressed total over 100 MB | Prune |
Zip compression ratio exceeds 100:1. | Zip-bomb-shaped archive | Prune |
manifest.json is missing from the zip root or is not valid JSON. | No root manifest.json, invalid JSON, or over 2 MB | See Manifest: root fields |
package.json is missing from the zip root or is not valid JSON. | No root package.json | The reviewer builds from it; it is required |
Warning No package-lock.json — the reviewer build will resolve dependencies fresh. | No lockfile | Commit package-lock.json for a reproducible reviewer build |
__MACOSX/ entries and .DS_Store are ignored. A single top-level wrapper folder (what macOS
“Compress” produces) is tolerated, and every path rule is then applied relative to it.
Manifest identity and shape:
| Message | Cause | Fix |
|---|---|---|
manifest.json is missing the required "id" field. (also "version", "name") | Missing or non-string | Manifest: root fields |
manifest "id" ("{id}") must be lowercase alphanumeric with hyphens — it is the plugin's slug. | id fails /^[a-z0-9][a-z0-9-]*$/ | Rename the plugin slug |
manifest "version" ("{v}") is not valid semver (e.g. 1.2.0). | Not MAJOR.MINOR.PATCH | Use three parts |
manifest.json is missing the required "widgetType" field. | v1 shape with no widgetType | Single-widget only; suites must not carry it |
manifest.json is missing "ssr.url" (the SSR bundle path). | v1 shape with no ssr.url | v1 plugins must server-render; ssr: false is a suite-only option |
manifest.json is missing "assets.js" (the client bundle path). | v1 shape with no assets.js |
Widget summaries — Shape: Suite. These run in both the source-zip and built-zip audits, and central mirrors them:
| Message | Cause |
|---|---|
manifest widgets[{i}] is not an object. | A non-object entry in widgets[] |
manifest widgets[{i}] needs a lowercase-alphanumeric-with-hyphens "slug". | Missing slug, or fails /^[a-z0-9][a-z0-9-]*$/ |
manifest widget slug "{slug}" is reserved. | Slug is dist, widgets or media |
manifest declares widget slug "{slug}" more than once. | Duplicate slug |
manifest widget "{slug}" is missing "name". | Empty or non-string name |
manifest widget "{slug}" needs a relative "assets.js" path inside the version prefix. | Absolute path, URL scheme, backslash or .. |
manifest widget "{slug}" has an invalid "assets.css" path. | Same rule, on assets.css |
manifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets. | Not an array, or over 8 |
manifest widget "{slug}" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix. | A bad font entry |
manifest widget "{slug}" needs "ssr.url" (relative path) or an explicit "ssr": false for CSR-only widgets. | Neither a confined ssr.url nor the literal false |
manifest widget "{slug}" has an invalid sub-manifest path. | manifest is not a confined relative path |
manifest widget "{slug}" has an unknown "category" (content | layout). | category is neither literal |
manifest widget "{slug}" declares "pages" but is not a layout. | pages on a content widget |
manifest widget "{slug}" needs a non-empty "pages" list. | pages: [] |
manifest widget "{slug}" has unknown page(s) in "pages": {list} (pdp | listings | checkout | confirmation | collection | misc). | An unknown page key |
manifest widget "{slug}" declares "slots" but is not a layout. | slots on a content widget |
manifest widget "{slug}" needs a "slots" list. | slots is not an array |
manifest widget "{slug}" has an invalid or duplicate slot name. | Empty, non-string, over 64 chars, or repeated |
manifest widget "{slug}" slot "{name}" needs a "prefill" list of at most 8 keywords. | Not an array, or over 8 |
manifest widget "{slug}" slot "{name}" has invalid prefill keyword(s): {list} (system widget keywords only, e.g. gallery). | An entry failing /^[a-z][a-z0-9-]{0,63}$/ — including any plugin:… reference |
manifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px). | Not a number in range |
manifest declares {n} widgets — the limit is 24 per plugin. | Over 24 |
Presets — Shape: Suite (0.11.0+):
| Message | Cause |
|---|---|
manifest "presets" must be a list of at most 8 entries. | Not an array, or over 8 |
each manifest preset needs a "name". | Missing name |
preset "{name}" must reference a declared layout widget. | layout names an unknown slug, or one whose category is not layout |
preset "{name}" needs a "slots" object. | slots is missing, null or an array |
preset "{name}" has an invalid slot binding shape. | A slot value that is not an array |
preset "{name}" references "{x}" which is neither a declared content widget nor a system widget keyword. | A child that is not one of your own non-layout widget slugs and not a plain system keyword |
Preset semantics are in Layout widgets.
2. Central submit API (after upload)
Every response uses central’s coded envelope.
| HTTP | Code | Message | Cause |
|---|---|---|---|
| 422 | VALIDATION_ERROR | first validator message | zip_sha256 not 64 hex chars, zip_size_bytes outside 1..20971520, notes over 5000 chars, zip_key over 512 chars |
| 422 | INVALID_ZIP_KEY | zip_key must match submissions/{slug}/<file>.zip | The upload key does not belong to this slug |
| 422 | INVALID_MANIFEST | Manifest "id" must be lowercase alphanumeric with hyphens (it is the plugin slug). | Slug shape |
| 422 | INVALID_VERSION | Manifest "version" is not valid semver: {v} · Manifest "version" must be MAJOR.MINOR.PATCH semver: {v} | Central rejects two-part versions like 1.0 that a loose parser would accept |
| 422 | VERSION_NOT_GREATER | Version {v} must be greater than the published {current}. | Every submission must strictly increase over the live version |
| 409 | SLUG_RESERVED | The plugin id "{slug}" is reserved. Contact an administrator to claim it. | A pre-pipeline row owns the slug with no author |
| 409 | SLUG_TAKEN | The plugin id "{slug}" already belongs to another author. · … was just registered by another author. | Someone else owns the slug |
| 409 | NO_SUBMISSION | This plugin has no submission in review. | A review action with nothing in flight |
| 409 | PLUGIN_EXISTS | A plugin with this slug is already registered. | Legacy admin-only register-by-URL against an existing slug |
Only VERSION_NOT_GREATER, SLUG_TAKEN and SLUG_RESERVED can surprise you after a clean
browser audit — the browser only checks version shape, never monotonicity. See
Preflight and submit.
3. Built-zip audit (publish; reviewer-visible only)
These block your reviewed plugin from going live. You will normally learn about them through review notes.
| Message | Cause |
|---|---|
manifest.json not found at the zip root — zip the plugin folder AFTER building. | Wrong zip root |
manifest id "{x}" does not match the submission's "{y}". | Built manifest drifted from what was submitted |
manifest version "{x}" does not match the submitted "{y}". | Same |
manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild. | The SDK post-build step never ran, or an SDK older than 0.10.0 |
Built against widget-core {v} — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild. | Stamp below the floor |
dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build. | Built with a bare bundler instead of the widget-core preset |
manifest is missing "assets.js". · manifest is missing "ssr.url". | v1 shape, missing at build time |
dist/{name} ({label}'s {kind}) is missing from the zip. | A declared client bundle / stylesheet / SSR bundle was never emitted |
The build manifest has no hashed twin for {name}. | dist/manifest.json does not list the file |
Hashed twin dist/{hashed} is missing from the zip. | The hashed file was pruned |
{label}'s client bundle never registers on window.HRPlugins — the widget would never mount. | The IIFE never calls registerPlugin/registerPluginWidget; the audit greps for the literal HRPlugins |
{path} ({label}'s sub-manifest) is missing from the zip or invalid JSON. | A declared widgets/{slug}.manifest.json was not built or not committed |
manifest "icon" must be a "media/<file>" path or an absolute http(s) URL. (also "cover") | Wrong ref shape, or over 2048 chars |
Declared icon {ref} is missing from the zip. (also cover) | A media/ ref with no file |
manifest screenshot "{x}" must be a "media/<file>" path. | Screenshots are stricter than icons: an absolute URL is rejected here |
Declared screenshot {ref} is missing from the zip. | |
widget "{slug}" icon must be a "media/<file>" path or an absolute http(s) URL. | Per-widget icon shape |
Declared widget "{slug}" icon {ref} is missing from the zip. | |
{label} must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. ({label} = manifest "readme" or widget "{slug}" readme) | Wrong README ref shape |
Declared {label} {ref} is missing from the zip. | README declared but not shipped |
{label} {ref} is {n} KB — the limit is 64 KB. | Over 64 KB |
"{path}" is over the 30 MB per-file limit. · The build expands to {n} MB — over the 150 MB limit. · Zip is {n} MB — zip the built plugin WITHOUT node_modules (60 MB limit). | Size |
Could not read the zip: {msg} | Decompression failure |
media/ is authored content, not build output. Anything you declare — icons, cover,
screenshots, READMEs — must be committed and present in your source zip, or these errors fire
at publish, long after you were told the submission looked fine.
Built-zip warnings (never blocking):
| Message | Meaning |
|---|---|
manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty. | v1 only; the schema-inject step did not run |
{path} has no configSchema — {label}'s dashboard form will be empty. | A sub-manifest with no schema |
{label}'s configSchema doesn't extend the base widgetSchema (no colorScheme/spacing) — standard theme + spacing controls won't appear. Build config.ts as widgetSchema.extend({...}) and export zodToManifestSchema(configSchema). | You hand-wrote JSON Schema instead of extending the base — see Config schema and UI schema |
dist/{name} is {n} MB. | Client bundle over 1.5 MB, or stylesheet over 512 KB |
dist/{name} is {n} MB — over the renderer's 1 MB cache threshold (slower cold SSR). | SSR bundle over 1 MB |
"{path}" won't be published — file names may only use letters, digits, ".", "_" and "-". | The file is silently excluded from the CDN |
4. Central publish and rollback
| HTTP | Code | Message | Cause |
|---|---|---|---|
| 409 | NOT_APPROVED | Only an approved submission can be published (current: {status}). | Publish attempted before review approval |
| 422 | VERSION_MISMATCH | Requested version {x} does not match the submitted {y}. | |
| 422 | MANIFEST_URL_INSECURE | manifest_url must be served over https. | Non-https CDN (loopback is allowed for local rigs) |
| 422 | MANIFEST_INVALID | validator message | The published manifest fails central’s field validation |
| 422 | MANIFEST_MISMATCH | The published manifest does not match the submission's slug/version. | |
| 422 | MANIFEST_URL_MISMATCH | manifest_url must address /{slug}/{version}/. | The URL is not under the immutable version prefix |
| 422 | MANIFEST_SHAPE_CHANGED | This manifest changes the plugin between single-widget and multi-widget shapes. Existing widget placements and embed URLs would stop resolving — publish the new shape under a new plugin slug instead. | Permanent contract. A live plugin can never flip shapes — see The two manifest shapes |
| 422 | MANIFEST_WIDGET_CORE_MISSING | The manifest has no runtime.widgetCore stamp — rebuild with @homerunner-next/widget-core 0.10.0 or newer. | Central applies the same floor as the built-zip audit, on publish and rollback |
| 422 | MANIFEST_WIDGET_CORE_TOO_OLD | Built against widget-core {v}; the platform requires 0.10.0 or newer. | |
| 409 | ALREADY_LIVE | Version {v} is already live. | Rollback to the current version |
| 422 | ROLLBACK_MANIFEST_MISMATCH | manifest_url must address /{slug}/{version}/. · The manifest at manifest_url is {x}@{y}, not {a}@{b}. | |
| 422 | ROLLBACK_MANIFEST_UNREACHABLE | Could not fetch the manifest for {v}: {msg} | The old prefix was deleted |
| 422 | PLUGIN_NOT_APPROVED | Only approved plugins can be enabled on a feed. | Install attempted on a non-approved plugin |
| 409 | PLUGIN_IN_USE | Remove the {n} widget(s) built from this plugin first. (with details.widget_count) | Uninstall blocked while widget instances exist |
See Publishing, versions and rollback and Install, customers and kill switches.
5. Dashboard operator messages
Shown to whoever drives the upload. Quoted because you will see them relayed to you.
| Message | Meaning |
|---|---|
The zip must be between 1 byte and 20 MB. | The presign step refuses the size before uploading |
{slug}@{version} already exists on the CDN — published versions are immutable. A changed build needs a resubmission with a bumped version. | Version prefixes can never be overwritten |
Could not verify that {slug}@{version} is unpublished (storage check failed). Nothing was uploaded — retry in a moment. | The immutability check fails closed |
The published manifest isn't readable yet at {url} (HTTP {n}). Check the bucket is public and the custom domain is live, then retry. | Publish verification could not read the manifest back |
The manifest served at {url} is for {x}@{y}, not {a}@{b}. | The CDN served a different build |
This build changes {slug} between single-widget and multi-widget shapes. Existing placements and embed URLs would stop resolving — publish the new shape under a new plugin slug. | The dashboard’s pre-upload mirror of MANIFEST_SHAPE_CHANGED |
Only an approved submission can be published. · This build no longer matches the submitted version. | Publish preconditions |
The build upload must include manifest.json. · Between 1 and 200 files per publish. · Unexpected file path "{path}". | Publish request shape |
v{version} is the live version — roll back to another version first. | Refusal to delete the live version prefix |
This plugin has no submission zip on file. | The zip was cleared (a rejection nulls it) |
Publishing is not configured — set R2_PUBLIC_BASE_URL. | Deployment misconfiguration |
Refusing to publish from a local dashboard (CDN prefix "local/") against {host}. Publish from the deployed dashboard, or set PLUGIN_CDN_ALLOW_LOCAL_PUBLISH=1 on purpose. | Guard against a laptop publishing to production |
Retrying a failed publish is always safe: the root manifest.json is uploaded last and is
both the immutability sentinel and the verification target, so a publish that dies mid-upload
leaves nothing live.
6. Renderer SSR failures
A plugin failure never takes the page down. The widget’s markup is replaced by a hidden breadcrumb node:
// packages/homerunner-renderer/lib/render-utils.tsx (widgetFailureNode)
const safe = `HR widget failed — type="${info.type}"${info.id ? ` id="${info.id}"` : ""}: ${info.reason}`
.replace(/-{2,}/g, "-")
.replace(/>/g, ">");
return (
<div
data-hr-widget-error={info.type} // the widget KEYWORD, not the failure kind
hidden
dangerouslySetInnerHTML={{ __html: `<!-- ${safe} -->` }}
/>
);
View-source and search for data-hr-widget-error to read the reason. A page containing one is
flagged degraded, so its cache TTL drops from 24 h to 60 s.
reason= | Cause | Fix |
|---|---|---|
keyword does not resolve against the plugin manifest | The stored keyword does not match the manifest shape: a bare plugin:{slug} against a suite, a widget slug against a v1 manifest, or an unknown/kill-switched slug | The two manifest shapes |
plugin layout widget rendered as content — needs a layout entry with slots | A category: "layout" widget was placed as ordinary content | Layout widgets |
no plugin SSR URL in manifest | A v1 manifest with no ssr.url. In v1 this is a failure, not a CSR-only widget — only a suite summary may declare ssr: false | Manifest: root fields |
bad plugin asset URL: {msg} | A relative asset path with no known manifest URL, or an unparseable URL | Keywords, assets and URLs |
SSR bundle failed to load from {url} | Fetch failed, a redirect was refused, the module-scope timeout fired, require hit a non-shared module, or the bundle has no default export | Component and SSR module |
module load failed: {code} | The bundle could not be imported at all | |
SSR data prep failed: {msg} | A throw inside getInitialData, dehydrateState, getStaticAssets or the component’s renderToString | Parse ctx.options with parseWidgetConfig and default everything |
layout SSR data prep failed: {msg} | The layout equivalent. {msg} is one of: keyword does not resolve against the plugin manifest, "{type}" is not a layout widget (category: {cat}), plugin layout widgets require an SSR bundle (`ssr: false` is content-only), SSR bundle failed to load from {url} | Layout widgets |
layout-in-layout nesting not allowed | A slot binding points at another layout | Layouts cannot nest |
slot module load failed: {code} | One slot child’s module failed | Only that child is dropped |
Not supported yet. A content widget that throws during render becomes a failure node and the rest of the page survives. A layout component that throws during render is not contained — its element is rendered later in the page-level
renderToString, which has no try/catch, so the whole render endpoint 500s. Never throw in a layout’s render path.
Renderer-side log lines you can ask an operator to grep, by prefix:
[plugin] Fetching SSR bundle: {url}— a cold load (no in-process module, no Redis entry).[plugin] SSR bundle from Redis: {url}— L2 hit.[plugin] Failed to load SSR bundle from {url}: {msg}— the load threw; the widget fails.[plugin] Loaded "{type}" ({id}) via SSR bundle— success.[plugin] No manifest entry for "{type}". Skipping.— keyword/shape mismatch.[plugin] "{type}" is a layout widget — it must render as a layout entry (with slots), not content. Skipping.[widget] SSR prefetch FAILED — shipping loading markup. key={hash} error={msg}— a prefetch error is swallowed, the widget still renders, and the page is marked degraded.[layout] Slot "{name}" references missing widget {id}— a stale slot binding.[layout] Plugin layout widgets are not supported in slots yet. Slot "{name}" references "{keyword}". Skipping.[layout] Failed to prepare "{type}" ({id}): {msg}— precedes alayout SSR data prep failedbreadcrumb.
Your own console.* calls go straight to renderer stdout — the host console is injected into
the sandbox.
7. SSR sandbox throws
Thrown inside the node:vm context. Each one ends up as a SSR bundle failed to load or
SSR data prep failed reason above, but the text lands in the renderer’s logs. The local
harness is a port of the same sandbox and raises the same strings — the require
whitelist’s seventh entry, the externalFetch block messages and the execution timeout all
reached it in widget-core 0.12.2. The divergences that remain are in
Previewing SSR locally.
| Message | Cause |
|---|---|
Plugin cannot require "{mod}" (not in shared modules whitelist) | Only seven modules are shared: react, react/jsx-runtime, react-dom, react-dom/client, react-dom/server, @tanstack/react-query, @homerunner-next/widget-core/runtime. Everything else must be bundled |
[plugin] fetch to "{host}" blocked — not declared in the widget's externalFetch manifest field. | Server-side fetch to a host outside central ∪ your declared externalFetch |
[plugin] fetch to "{host}" blocked — private/metadata hosts are never allowed. | Loopback, private ranges, *.local, link-local, metadata.google.internal — refused even when declared |
[plugin] fetch blocked — more than 5 redirects. | Redirect chain over 5 hops |
[plugin] fetch blocked — unparseable URL: {raw} | A non-URL passed to fetch |
HTTP {status} redirect refused — SSR bundles must be served directly from their published URL | Your ssr.url 3xx’d |
HTTP {status} | Non-2xx fetching the SSR bundle |
[plugin] SSR bundle has no default export: {url} | Your ssr-entry.ts exports no default — mandatory, layouts included |
// packages/homerunner-renderer/lib/plugin-federation.ts (buildEnforcedFetch)
const host = u.hostname.toLowerCase();
const hostPort = u.port ? `${host}:${u.port}` : host;
const allowed = host === centralHost() || declared.has(host) || declared.has(hostPort);
// a declared entry may be "api.example.com" (any port) or "api.example.com:8443"
8. Browser: mount and runtime
data-hr-error on the host element — the complete vocabulary:
| Value | Set by | Meaning |
|---|---|---|
mount-failed | mount() per-embed catch, or a deferred hydrate/render rejection | That embed threw; other embeds on the page still mounted |
render-failed | WidgetRenderBoundary | Your component threw during render; the error state is shown instead of a blank node. Cleared when a changing resetKey retries |
rate-limited | ClientWrapperForWidget | The config fetch returned 429. Removed on a successful retry |
widget-fetch-failed | ClientWrapperForWidget | The config fetch failed for any other reason. Removed on a successful retry |
property-not-found | the rendered error element only | A configured property slug does not resolve |
Console strings, by prefix:
| String | Meaning |
|---|---|
Widget ID is required | The host element has no id. It must be "{widgetId}:{feedId}" |
[hr-widget] "{type}" mount failed for one embed — other embeds continue. | Per-embed isolation working as designed |
[hr-widget] "{type}" deferred mount step failed (widget {id}, feed {feed}). | A throw after the CSS gate resolved |
[hr-widget] "{type}" render failed (widget {id}, feed {feed}) — rendering the error state instead of a blank node. | Render boundary caught your component |
[widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry. | Script order on a hand-written embed |
[widget-core] widget "{type}" did not register within 10000ms | A CSR layout’s slot child never registered — usually a 404 on its bundle |
Cannot resolve relative plugin asset "{x}": manifest URL not known. Ship absolute URLs in the manifest, or pass the manifest URL when fetching. | resolvePluginAssetUrl with no base |
[widget] SSR state could not be inflated; widgets fetch client-side. | A malformed dehydrated-state block; the page degrades to client fetching rather than breaking |
[layout-csr] Skipping nested layout child "{keyword}" ({id}) in slot "{slot}". | A system layout bound into a slot |
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest. | A plugin layout bound into a slot |
[layout-csr] Slot "{slot}" child {id} failed to resolve; skipping. | The child widget row could not be fetched |
[layout-csr] Child widget "{keyword}" failed to load/register; its slots stay empty. | The child IIFE 404’d or never registered |
[layout-csr] Property "{slug}" was not found in feed {id}. Check the layout's Property setting, or the data-hr-options filter.property on the embed. | A hard 4xx on the pushed property |
[HRWidget] discarded slot child "{type}" (id {id}) has no unmount handle (stale bundle or custom mount) — its widget tree may stay orphaned. | A slot child mounted by something other than widget-core’s mount() |
9. The /p/ asset proxy
GET {assets}/p/{slug}/{file} — {file} is the dist-relative name (acme.iife.js for v1,
hero/hero.iife.js for a suite). Full contract in
Keywords, assets and URLs.
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"Missing path: /p/{slug}/{file}"} | No / after the slug |
| 404 | {"error":"Plugin not found"} | Unknown slug, or the plugin is not approved (a suspended plugin 404s within the 60 s pointer TTL) |
| 500 | {"error":"Could not resolve plugin dist location"} | The manifest has no usable dist base |
| 404 | {"error":"Asset not in plugin manifest"} | The file is neither a manifest-declared asset nor a key of your own dist/manifest.json. A kill-switched widget’s whole dist/{widget}/ directory 404s this way |
| 302 | — | Success: Location is the hashed twin, Cache-Control: public, max-age=60, stale-while-revalidate=30, Access-Control-Allow-Origin: * |
10. Dashboard config panel
| Message | Cause | Fix |
|---|---|---|
Plugin "{slug}" not enabled for this feed. | The plugin is not installed on the feed | Install, customers and kill switches |
Widget "{keyword}" is not declared by plugin "{slug}". | A stored keyword the current manifest no longer resolves | |
Loading plugin manifest... | Transient | |
No configSchema for this widget. Ask the plugin developer to ship one via zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts. | The sub-manifest is missing, 404’d, timed out at 5 s, or carries no configSchema. This degrades — it is never an error | Config schema and UI schema |
sub-manifest fetch failed (HTTP {status}) | Thrown by the fetcher; every call site catches it and degrades to the banner above | |
Unsupported schema type: [object Object] | The whole config panel crashes. Caused by z.any(), z.unknown(), z.tuple(), z.intersection() or z.literal() surviving the JSON-Schema round trip | Config schema and UI schema |
11. Local build and dev tooling
Build CLI (hr-widget-build, run for you by npm run build):
| Message | Cause |
|---|---|
hr-widget-build: no manifest.json in the current directory. | Run it from the project root |
hr-widget-build: vite is not installed in this project. | Missing dev dependency |
hr-widget-build: invalid widget slug in manifest.widgets: {json} | Slug fails /^[a-z0-9][a-z0-9-]*$/ |
hr-widget-build: widget slug "{s}" is reserved. | dist, widgets or media |
hr-widget-build: widget slug "{s}" is declared more than once. | Duplicate slug |
hr-widget-build: missing entry for widget "{s}" ({path}). | No src/widgets/{slug}/index.tsx |
[viteHomerunnerWidget] This project declares manifest.widgets[] — build it with `hr-widget-build` (which loops BUILD_WIDGET per widget), not a bare `vite build`. | A suite built with a bare vite build. Use npm run build |
Scaffolder (create-hr-plugin). Every row but the last is checked before a single file
is written, and exits 1:
| Message | Cause |
|---|---|
Error: expected a plugin slug first — got "{arg}". | The first argument starts with -; flags go after the slug |
Error: invalid plugin slug "{slug}" — lowercase letters, digits and hyphens only, starting with a letter or digit (e.g. "acme-weather"). | The slug fails /^[a-z0-9][a-z0-9-]*$/ — the same rule the publish audit applies to manifest.id (create-hr-plugin 0.8.1+; earlier versions accepted anything and you found out at publish) |
Error: plugin slug "{slug}" is reserved — it collides with the published layout's own directories. | The slug is dist, widgets or media (0.8.1+) |
Error: unknown template "{t}" — expected "single" or "suite". | --template / --template= with anything else, including nothing usable after the flag (0.8.0+; the --template=suite form is parsed from 0.8.1) |
Error: directory "{slug}" already exists. | Target exists |
npm install failed — you can run it manually. | Not fatal — the scaffold continues and exits 0 |
SSR preview (npm run dev:ssr). The first row is a red panel on every scenario tab. The
rest are resolved at boot, before the server starts (widget-core 0.12.2+): the suite
scaffold’s scripts/dev-ssr.mts prints them and exits 1, while a direct
createSSRDevServer call throws the same string:
| Message | Cause |
|---|---|
Missing {file} — run `npm run build` first. | No SSR bundle at that path. {file} is dist-relative: {slug}-ssr.umd.js (single-widget) or {widget}/{widget}-ssr.umd.js (suite) |
No widget selected — manifest.json declares `a`, `b`, `c`. Pass `widget: "a"` (or `--widget a`) to preview one. | A suite with no --widget / --widget= / HR_SSR_WIDGET |
Unknown widget "{w}" — manifest.json declares `a`, `b`, `c`. | The named widget is not in widgets[] |
Unknown widget "{w}" — manifest.json declares no `widgets[]`. Single-widget plugins have no widget to name; drop the option. | A widget named on a v1 project |
Widget "{w}" is CSR-only (`"ssr": false`) — there is no SSR bundle to preview. | Correct behaviour, not a fault — verify it in the browser sandbox |
More symptom-first triage in Troubleshooting.
Known gaps
Not supported yet. Built-zip audit results — errors and warnings — are visible only in the reviewer’s console. You cannot run that audit yourself, and nothing forwards its warnings to you. Treat the size thresholds and the
configSchemawarnings above as a self-checklist.
Not supported yet. There is no notification of any review outcome. No email, no in-app message. Check Dashboard → Plugins → My plugins.
Not supported yet.
accepts: ["layout"]can never match. The slot picker always excludes layout-category candidates before theacceptspredicate runs, andacceptsis not validated at publish at all — it is advisory (unvalidated).
Not supported yet. Preset slot keys are never validated against the layout’s declared slot names. A typo creates a binding the layout will never render, with no error at any gate.
Not supported yet.
prefillkeywords are shape-validated only (/^[a-z][a-z0-9-]{0,63}$/) — never checked against the real system-widget keyword list. An invented keyword passes publish and is reported at creation time as{keyword}: not a widget this platform can create.
Not supported yet.
externalFetchis advisory (unvalidated) at publish: any list of strings passes. It is enforced only at SSR runtime, so a typo’d host is discovered as a blocked fetch on a live page.
Not supported yet.
replacedByis declared, diffed at review, and read by no runtime or UI code — it is surfaced nowhere.
Not supported yet. A pre-release version (
1.0.0-rc.1) passes both the browser audit and central, but every immutability optimisation keys off a bareX.Y.Zpath segment. Publishing one silently drops you to TTL-bound caching and/p/-proxied asset URLs everywhere. Use plain three-part versions.
Styling and theming
Two things decide how a plugin widget looks: where your stylesheet is attached, and which host attribute says “dark”. Get those right and the rest is ordinary CSS. Get them wrong and you either repaint the customer’s whole page or flash light before going dark. The field rules this page depends on stay on the contract pages, linked rather than repeated.
Where your CSS actually lands
A shadow root protects your widget from the customer’s CSS. It does not protect the customer from yours.
| Path | Your stylesheet is attached… |
|---|---|
| Content widget, server-rendered page | in the page <head> and inside your shadow root |
| Content widget, CSR embed | inside your shadow root only |
| Layout shell, server-rendered page | in the page <head> only — the shell is light DOM |
| Layout shell, CSR embed | inside the layout’s shadow root |
The <head> half surprises everyone. On a composed page the renderer collects every widget’s
resolved assets.css, de-duplicates the list, and the edge worker appends one
<link rel="stylesheet"> per entry to <head>:
// src/lib/homerunner/cf-worker.ts — buildCssHtml(), fed by the renderer's
// combined, de-duplicated assets.css.
return (assets.css || [])
.map(function (href) { return '<link rel="stylesheet" href="' + escapeHtml(href) + '">'; })
.join("");
That is deliberate: on browsers that do not get Declarative Shadow DOM the server-rendered
markup is plain light DOM until mount() runs, and it has to be styled meanwhile. The
consequence is the same either way — on a server-rendered page every selector you ship is a
global selector. Three rules follow, for content widgets exactly as much as for layouts:
- Namespace every class.
.sfacts,.pdpf-region,.acme-weather-card— a prefix you own. Never.card,.title,.grid. - Never ship a global reset. See the Tailwind section below; this is the one that bites.
:host(...)rules are the one form that self-scopes — outside a shadow tree they are valid CSS matching nothing, so they are inert in<head>and live in the shadow root.
Layouts add a fourth rule: never paint a background on the shell. That rule, and why a
:host-only stylesheet is inert on every server-rendered page, are normative in
Layout widgets.
The Tailwind stack, and why a suite ships none of it
Single-widget only. create-hr-plugin --template single ships Tailwind 4 through
@tailwindcss/vite, plus tw-animate-css, clsx + tailwind-merge behind a cn() helper,
Radix primitives and lucide-react. There is no tailwind.config.js; v4 is configured in CSS.
/* packages/create-hr-plugin/template/src/widget.css (first two lines) */
@import "tailwindcss";
@import "tw-animate-css";
Suite. --template suite ships none of that (create-hr-plugin 0.8.1) — no Tailwind, no
tailwindcss() Vite plugin, no clsx/tailwind-merge, no Radix, no lucide-react, and no
src/components/ui/ kit. Each widget gets a hand-written, class-prefixed {slug}.css
instead, which is the same choice the reference suite made. The reason is the preflight
problem below: a suite is expected to carry a layout, and a layout’s stylesheet is loaded
document-level and unisolated on a server-rendered page, so a framework’s global reset lands
on the customer’s whole document. Nothing stops you adding Tailwind to a suite yourself —
everything in this section then applies, per widget.
Build a suite has the file-by-file conversion.
Utilities work unchanged inside a shadow root because Tailwind 4 emits its theme variables on
:root, :host — confirm it on any built plugin stylesheet with
grep -o ':root[^{]*{' dist/acme-weather.css. Two things do not carry over from a normal
Tailwind app.
dark: is the wrong signal. Tailwind’s built-in dark variant compiles to
@media (prefers-color-scheme: dark) — the visitor’s OS preference, not the widget’s
configured colour scheme. A customer who explicitly picks dark gets light utilities; one who
picks light gets dark ones on a dark laptop. Never use dark: for theme-driven styling; use
the host-attribute contract below.
Preflight is a page-wide reset. @import "tailwindcss" pulls in preflight, whose first
rule is a universal selector:
/* tailwindcss/preflight.css:7-16 */
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
Inside a shadow root that is contained. In the customer’s <head> it strips every margin,
padding and border on their whole page. If your widget is server-rendered — or is a
layout, which is always light DOM there — import Tailwind without preflight:
/* Preflight-free Tailwind 4. Both subpaths are real package exports
(tailwindcss/package.json "exports"); `@import "tailwindcss"` is just
these two plus preflight in @layer base. */
@layer theme, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
Set your own box model on your namespaced root instead, then build and confirm the emitted CSS
carries no *,::after,::before reset. Both the suite scaffold and the reference suite
pdp-suite sidestep the whole problem by shipping no Tailwind at all.
The theme contract: data-hr-theme vs data-hr-scheme
Both attributes live on the host element — the shadow host for a content widget, the
light-DOM shell <div> for a server-rendered layout. They mean different things and different
actors own them.
| Attribute | Means | Set by |
|---|---|---|
data-hr-theme="light|dark" | the resolved theme; JS owns theming from here | the runtime at mount on every path — and the server, but only on a layout shell |
data-hr-scheme="auto" | config: “follow the OS”. A pre-hydration CSS gate, nothing more | the server only, under the conditions below |
They are never both meaningful at once: the runtime removes data-hr-scheme the moment it
attaches, and the media-query arm you write carries :not([data-hr-theme]) so it yields
cleanly.
Why the attribute and not a wrapper class. Your stylesheet paints the host itself
(:host { background: … }), and a .dark class on a descendant <div> can never change a
custom property on the host — inheritance only flows down. That is why widget-core keeps a
marker on the host (HOST_THEME_ATTR, runtime/host-theme.ts) instead.
Why not props.resolvedTheme. For an explicit light or dark the prop is correct. For
auto it is derived from a cookie the Cloudflare worker never forwards, so it is "light" on
every production server render — and because mount() hydrates with the server’s props, a
component that branches on it stays light after hydration too. Every published sample plugin
does this, and every one of them is light-only on a server-rendered page. Branch in CSS off the
host attribute instead: attachHostThemeController also keeps that attribute live across an OS
theme flip mid-session, which the React prop never does. Per-path prop details are in
Component and SSR module.
Dark at first paint
Replacing the old guide’s “accept the flash or skip auto”: you can paint dark before a single
byte of JavaScript runs. Two steps.
1. Dual-emit every dark rule under exactly these two prefixes, appended after the light rule — never branched, never replacing it:
/* The two prefixes required by the `mediaSafeAuto` contract in
packages/homerunner-widget-core/src/manifest.ts. Light rule first, unchanged. */
.sfacts { background: #fff; color: #18181b; }
:where(:host([data-hr-theme="dark"])) .sfacts {
background: #0a0a0b;
color: #f4f4f5;
}
@media (prefers-color-scheme: dark) {
:where(:host([data-hr-scheme="auto"]:not([data-hr-theme]))) .sfacts {
background: #0a0a0b;
color: #f4f4f5;
}
}
2. Declare mediaSafeAuto: true on the widget summary (widget-core 0.12.0+). It is a
promise about your CSS that nothing validates; the field rule and its enforcement level are in
Manifest: widget summary.
The stamp appears only when all three hold: the resolved colorScheme is auto, the request
emitted a Declarative Shadow DOM template, and the flag is true. On the legacy (non-DSD)
bucket you keep the old behaviour, light until JS — correctly, because :host rules are inert
without a shadow root at parse time. See
How a widget renders for when DSD is emitted.
Four footguns, each of which shipped a real bug
:not()must be INSIDE:host(...).:host([data-hr-scheme="auto"]:not([data-hr-theme]))works.:host([data-hr-scheme="auto"]):not([data-hr-theme])parses fine and silently never matches a shadow host. Your dark arm is dead and nothing fails.:where()holds specificity at zero, so the dark selector must string-match the light one.:where(:host([data-hr-theme="dark"])) .priceloses to.acme-card .price— same specificity contest, and the light rule may come later in the file. Repeat the light rule’s full descendant selector in the dark arm. The zero-specificity wrap is what lets a customer’scustomCsskeep winning, so do not drop it.- Gate symmetrically. If a light rule is emitted only under a condition (an accent is set,
a variant is on), the dark arm needs a branch for the “no dark value” case that restores the
base values — otherwise
autopaints the light value and flashes at hydration. - Never resolve
autoon the server. No cookies, no client hints, no UA sniffing. The server stamps configuration; the browser resolves it. Anything else poisons the shared page cache or is wrong on the first visit.
When your tokens come from JS
If you build a <style> string from options (accent, font, custom properties), emit the dark
half twice from one helper so the two arms cannot drift:
// Shape of the platform's own palette hook — see the two `explorerDarkOverrides`
// calls in packages/homerunner-embeddables/widgets/explorer/hooks/useExplorerCss.ts.
// Token names are yours.
const darkTokens = (prefix: string, dc: PluginConfig["darkModeColors"]) => `
${prefix} .acme-card { --acme-accent: ${dc.accent}; color-scheme: dark; }
`;
const themeCss = `
:host { --acme-accent: ${options.lightModeColors.accent}; }
${darkTokens(':where(:host([data-hr-theme="dark"]))', options.darkModeColors)}
@media (prefers-color-scheme: dark) {
${darkTokens(':where(:host([data-hr-scheme="auto"]:not([data-hr-theme])))', options.darkModeColors)}
}
${options.customCss ?? ""}
`;
Read options.darkModeColors directly, never through a resolvedTheme-gated variable —
the dark arm has to ship on every render. Keep customCss last.
Theming a layout
A layout shell carries the same two attributes, but it is light DOM on a server-rendered page
and a shadow host on a CSR embed. :host(...) only covers the second case, so write both arms
in one forgiving selector list:
/* Layout shell: `[data-hr-theme]` matches the light-DOM wrapper on an SSR page;
`:host([data-hr-theme])` matches the shadow host on a CSR embed. Each is inert
on the other path. */
.pdpf-label { color: rgba(100, 100, 120, 0.9); }
:where([data-hr-theme="dark"], :host([data-hr-theme="dark"])) .pdpf-label {
color: rgba(200, 200, 220, 0.9);
}
@media (prefers-color-scheme: dark) {
:where([data-hr-scheme="auto"]:not([data-hr-theme])) .pdpf-label {
color: rgba(200, 200, 220, 0.9);
}
}
One difference from content widgets: a layout shell gets its server theme attribute without
mediaSafeAuto. On a DSD-bucket request an auto shell is stamped data-hr-scheme="auto";
anything else is stamped data-hr-theme="light|dark" outright. A layout shell is the only
server-rendered surface that carries a resolved data-hr-theme.
Rendering the base fields yourself
The dashboard renders the controls for spacing, colorScheme, accent colours, font,
customCss and section automatically — see
Config schema and UI schema. The platform applies
none of them. They arrive in options and you render them.
// packages/create-hr-plugin/template/src/widget.tsx (parse + spacing, verbatim)
// plus the token/section pattern from hr-plugins/reviews-marquee/src/widget.tsx.
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";
import { Stylesheet, WebFont, WidgetSection } from "./components/ui";
const options = React.useMemo(
() => parseWidgetConfig(configZod, props.options) as PluginConfig,
[props.options],
);
const themeCss = `
:host {
--acme-accent: ${options.lightModeColors.accent};
font-size: ${options.font.size}px;
font-family: ${options.font.family}, ui-sans-serif, system-ui, sans-serif;
}
${/* dark arms here — see above */ ""}
${options.customCss ?? ""}
`;
return (
<>
<WebFont family={options.font.family} />
<Stylesheet css={themeCss} />
<div style={resolveWidgetSpacingStyle(options)}>
<WidgetSection show={options.section.show} title={options.section.title} />
{/* … */}
</div>
</>
);
Four corrections to the old guide, all in that block:
<Stylesheet>takescss(string or string array, joined) and renders one plain<style>. It does not de-duplicate and it does not update in place.<WidgetSection>takes{show, title, className}and returnsnullwhenshowis false ortitleis empty. It is a heading, not a wrapper — no children, nooptionsprop.resolveWidgetSpacingStyleaccepts raw or parsed options (it runs the legacy-widthlift itself). Never hand-roll CSS from thespacingobject.<Stylesheet>,<WebFont>and<WidgetSection>are scaffold components undersrc/components/ui/, not SDK exports — in a suite, copy them into your project.
options is raw until you parse it, on the server and in the browser
(Settings and options); customCss goes last so the
customer’s rules win. Every value interpolated into that template is an admin-supplied string
heading for dangerouslySetInnerHTML — sanitise the accent, family and customCss before you
concatenate. And if you open a dialog through the elevated portal, only <link> tags are cloned
into it: this whole <style> is missing there unless you pass the same string as cssString.
See Portals and dialogs.
Spacing for a layout’s slots
Not supported yet. The platform’s own layouts expose per-slot spacing and a
gapcontrol. Those fields live in a private package and a plugin layout inherits neither.
Declare your own, reusing the SDK’s schema builders so the dashboard renders the same compound control:
// Builders from packages/homerunner-widget-core/src/spacing.ts:224-268.
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
marginBoxSchema, paddingBoxSchema, widthSchema, maxWidthSchema,
resolveSpacingStyle, DEFAULT_SLOT_SPACING,
} from "@homerunner-next/widget-core/spacing";
const slotSpacing = z
.object({
margin: marginBoxSchema(DEFAULT_SLOT_SPACING.margin),
padding: paddingBoxSchema(),
width: widthSchema(DEFAULT_SLOT_SPACING.width),
maxWidth: maxWidthSchema(DEFAULT_SLOT_SPACING.maxWidth),
})
.default(DEFAULT_SLOT_SPACING);
export const configZod = widgetSchema.extend({
slots: z.object({ hero: z.array(z.string()).default([]) /* … */ }).default({}),
slotSpacing: z.object({ hero: slotSpacing /* … */ }).default({}),
});
Apply it with resolveSpacingStyle(cfg.slotSpacing.hero) on the region wrapper.
DEFAULT_SLOT_SPACING starts fully neutral, so your stylesheet keeps winning until the
customer changes something. The slots field itself is mandatory and its keys must match your
manifest slot names exactly (Layout widgets); every /spacing
export is listed in SDK reference.
Fonts and bundled images
@font-face is ignored inside a shadow root — a stylesheet loaded there can use a
family but can never define one. Hence two channels:
- A face your design depends on: declare it in
assets.fontson the widget summary. It is injected at document level on both paths, where the browser will honour it. - A family the customer picks (
options.font.familyfrom the base schema): load it at runtime with the scaffold’s<WebFont family={options.font.family} />— browser-only, and a no-op in the SSR bundle.
Images and other files are ordinary Vite imports (import heroBg from "./hero-bg.png", or
url(./x.png) in CSS). The build rewrites each reference so it resolves against wherever your
bundle actually loaded from, on the client and inside the SSR sandbox alike; small files of
known asset types become data: URLs and larger ones are emitted beside your bundle. Never
hand-write an absolute /dist/... path. Font counts and path rules, the inline threshold, the
extension list and the suite-directory requirement are all in
Keywords, assets and URLs — which also lists the origins a
customer’s Content-Security-Policy has to allow for your stylesheets and fonts.
While you are developing
npm run dev intercepts CSS imports from src/ so Vite never injects a <style> into
document.head (a shadow root would ignore it), serves your compiled stylesheet at the URL
shape mount() expects, and on save cache-busts the <link> inside the shadow root rather
than doing a normal CSS hot update. You configure none of it; the routes and the HMR event name
are in Keywords, assets and URLs and the sandbox itself in
Dev sandbox and mocking.
The document-level <head> copy of your stylesheet you can see locally, and should.
npm run dev:ssr in its default server-render mode builds no shadow root and links your
stylesheet into the harness page’s own <head> — the same unisolated condition as a composed
page, which makes it the local test for the three rules above. Both shapes. One caveat:
that <link> comes from your getStaticAssets, and a bundle exporting none — what
--template suite scaffolds — needs widget-core 0.12.3+ to get one at all; below that it
previewed with an empty <head> and no CSS.
Previewing SSR locally lists what that harness still cannot do.
What you cannot see locally is dark-at-parse, which needs a real DSD-capable request. Check it on a server-rendered page before you submit — reload with the OS in dark mode and JavaScript disabled.
Related
- Shadow-root lifecycle, CSS URL precedence, and the
manifest.shadowDOM-vs-mount()disagreement: Runtime, mount and the DOM contract - Dialogs and the elevated portal’s token trap: Portals and dialogs
mediaSafeAutoand every other summary field: Manifest: widget summary- Layout CSS rules, the no-background product rule and slots: Layout widgets
- Symptom-first fixes for unstyled widgets, missing fonts and light-only dark mode: Troubleshooting
Fetching data
Your widget fetches in two places: inside the renderer’s server sandbox during SSR, and in the
browser after it mounts. Both talk to the same HomeRunner public API, both share one React
Query cache per page, and the whole point of doing the server half is that the browser half
does not repeat it. One rule holds them together: the key you prefetch under on the
server must be the key you read on the client. Everything else here is detail around that.
It all applies to both manifest shapes; the one shape-dependent rule is where you declare
externalFetch.
The base URL
Never hard-code a Central origin. The same plugin bytes are served to production, staging and
local rigs, so the base URL is resolved at runtime by homerunnerApiBaseUrl() from
@homerunner-next/widget-core/runtime, in this order:
window.HRWidgetRuntime.apiBaseUrl— baked into the env-matched runtime IIFE the host page loads. This is what answers on every real customer page and in the dashboard playground.process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL— Vite-injects it from your.env.localduringnpm run dev, and the renderer whitelists it into the SSR sandbox’sprocess.env.- A hard fallback origin.
No host sniffing, nothing baked into your build. For local work, copy .env.example to
.env.local and point that variable at whichever Central has your test data. The verbatim
implementation and the fallback value live in Public API.
A fetcher you can actually ship
Put your fetchers and your query keys in one file so the server and client halves cannot
drift. This is the shipped featured-stays fetcher, corrected:
// Corrected from hr-plugins/featured-stays/src/data.ts:30-45 (real, published).
// Three changes: `limit` (the shipped file sends `per_page`, which /feed/properties
// ignores), a WidgetFetchError instead of a bare Error, and an abort timeout.
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";
export async function fetchProperties(
feedId: number,
opts: { limit?: number; platform?: string } = {},
): Promise<PropertiesResponse> {
const url = new URL(`${homerunnerApiBaseUrl()}/public-api/v1/${feedId}/feed/properties`);
if (opts.limit) url.searchParams.set("limit", String(opts.limit));
if (opts.platform) url.searchParams.set("platform", opts.platform);
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(8000) });
// Read the STATUS before parsing: a 429 can be answered by a gateway and need
// not be JSON, and the status is what the retry predicates classify on.
if (!res.ok) throw new WidgetFetchError(res.status, `properties: HTTP ${res.status}`);
return res.json();
}
export const queryKeys = {
properties: (feedId: number, limit: number, platform?: string) =>
["featured-stays:properties", feedId, limit, platform] as const,
};
Why each change matters:
- Page-size parameter names differ per endpoint family.
per_pageis silently ignored on the property endpoints and you get the default page — three of the four published reference plugins ship that bug. The per-endpoint table is in Public API. AbortSignal.timeoutmatters most on the server. The vm timeout bounds only synchronous top-level work, so a hanging fetch insidedehydrateStateholds the whole page render.- Throw
WidgetFetchError, notError. It carriesstatusas an own property, which is what both the browser and the server retry policies read.
Route image URLs from the API through getProxiedImageUrl(url, width, height) from
@homerunner-next/widget-core/utils, passing real dimensions — it defaults to 64 x 64 and
its normalisation rules are in Public API.
The SDK’s fetch vocabulary
Available from @homerunner-next/widget-core/runtime (0.10.0+, so present in the version on
npm today): homerunnerApiBaseUrl, fetchFeed, fetchWidget, useFetchWidget,
WidgetFetchError, isRateLimitError (429), isPermanentError (any 4xx), retryTransient,
and WidgetErrorState for a visible failure. From /utils: getFeedQuery, getWidgetQuery,
getProxiedImageUrl. Signatures are in SDK reference.
retryTransient(max) returns a TanStack retry callback that retries a network error or a 5xx
and never a 4xx; max counts retries, not attempts, so retryTransient(1) makes at most
two requests.
Two names the old guide used do not exist in widget-core and will not resolve:
okOrThrow and retryUnlessRateLimited. Both live in HomeRunner’s private packages.
Not supported yet.
fetchPropertyBySlugexists in widget-core — it is how the CSR layout renderer validates a pushed property once instead of 404-ing in every slot — but it is not exported from/runtime. Call/public-api/v1/property-detailsyourself withfeed_idandproperty.
The shared query client
Every widget on a page — first-party and plugin alike — renders inside one React Query
client. In the browser it is the getSharedQueryClient() singleton on window.HRWidget, and
it is created with these defaults:
| Default | Value | Why it matters to you |
|---|---|---|
staleTime | 5 minutes | Data is fresh for five minutes after it was fetched. |
refetchOnMount | false | A widget never refetches on mount, even when its data is stale. |
refetchOnWindowFocus, refetchOnReconnect | false | Tabbing back or coming back online does nothing. |
retry | retryTransient(3) | Up to three retries for network/5xx, none for a 4xx. |
refetchOnMount: false is the load-bearing one. Server-rendered pages are cached for hours
(see Limits and error index), so by the time a visitor
loads one the hydrated dataUpdatedAt is usually far past staleTime — and nothing refetches
anyway. Hydrated SSR data is what the visitor sees until they navigate. If your widget
genuinely needs live data, override it on your own query (refetchOnMount: "always", an
interval, an explicit refetch()) and pay for it against the shared rate budget knowingly.
Get the client with useQueryClient(), not by calling getSharedQueryClient() inside your
component: on the server that function returns a fresh throwaway client and anything you write
to it is discarded.
The renderer’s server-side client is a different object with different defaults —
staleTime: Infinity, and a retry policy that does retry a 429 (with backoff) because a
page’s parallel prefetch burst can trip the limiter and bake skeleton markup into a cached
page. It classifies errors by reading error.status structurally before falling back to
instanceof, which is the second reason to throw WidgetFetchError.
Query-key discipline
The dehydrated state carries queryKey, queryHash and the data — never your queryFn.
Hydration matches on the hash, which is JSON.stringify of the key with object keys sorted.
So:
- Build the key from one factory imported by both
ssr.tsandwidget.tsx. A key assembled twice by hand is a key that will drift. - Every element counts.
["k", 42, 6, undefined]hashes as["k",42,6,null], which is not["k", 42, 6]. Dropping a trailing argument on one side is the classic silent double-fetch. Keep keys to primitives. - Namespace with your plugin slug —
["pdp-suite:stay-hero", feedId, …]. The cache is page-wide, so an un-namespaced["properties", 42]can collide with another vendor’s widget and silently serve their data. - The
["widget", …]and["feed", …]key spaces belong to the platform. The renderer pre-seeds them before your hook runs and the CSR layout renderer writes them too. Do not write to them. See Component and SSR module.
Parse before you build a key. ctx.options arrives RAW in dehydrateState, exactly as it
does in your component, so a key built from raw options can differ from the key the parsed
component asks for — schema defaults, null/"" save artifacts and lifted legacy values all
move the value. Run parseWidgetConfig(configZod, ctx.options) first, on both sides. The rules
are in Config schema and UI schema.
The SDK’s own helpers disagree with each other and are not interchangeable:
useFetchWidget(feedId, widgetId) caches under ["widget", feedId, widgetId],
getWidgetQuery(widgetId) under ["widget", widgetId], and getFeedQuery(feedId) under
["feed", feedId]. Pick one per resource and use it on both sides.
Prefetch on the server, reuse in the browser
dehydrateState(queryClient, ctx) runs before your component renders on the server. Write into
the client it hands you; the renderer serialises the whole cache into the page and
getSharedQueryClient() rehydrates it before the first widget mounts.
// hr-plugins/featured-stays/src/ssr.ts:10-25 (real, published) — unchanged
// shape; the fetcher it calls now sends `limit`.
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { fetchProperties, queryKeys } from "./data";
import { configZod, type PluginConfig } from "./config";
export async function dehydrateState(
queryClient: { setQueryData: (key: unknown, data: unknown) => void },
ctx: { options: Record<string, unknown>; feedId: number; widgetId: string },
) {
// Parse RAW stored settings with the component's own schema — the prefetch
// key must equal the key the hydrated component asks for.
const options = parseWidgetConfig(configZod, ctx.options) as PluginConfig;
const limit = options.limit ?? 6;
const platform = options.filter?.platform ?? undefined;
try {
const res = await fetchProperties(ctx.feedId, { limit, platform });
queryClient.setQueryData(queryKeys.properties(ctx.feedId, limit, platform), res);
} catch (err) {
console.error("[featured-stays] dehydrateState failed:", err instanceof Error ? err.message : err);
}
}
The component makes the ordinary call, and on a server-rendered page it resolves from the hydrated cache with no request:
// hr-plugins/featured-stays/src/widget.tsx:39-45 (real, published) — the same
// key factory, the same arguments, derived from the same parsed options.
const limit = o.limit ?? 6;
const platform = o.filter?.platform ?? undefined;
const q = useQuery({
queryKey: queryKeys.properties(props.feedId, limit, platform),
queryFn: () => fetchProperties(props.feedId, { limit, platform }),
});
prefetchQuery({queryKey, queryFn}) is the fire-and-forget alternative: it swallows the
failure and caches an errored query. setQueryData inside a try/catch — what all four
published reference plugins do — is the shape where you decide what a failure looks like,
at the cost of an uncaught throw removing the widget.
Either way, only successful queries are dehydrated — TanStack’s default
shouldDehydrateQuery is status === "success". A query that errored on the server ships no
data, so your component renders its no-data branch into the HTML. If that branch is a spinner,
crawlers get a spinner; return a real empty state instead. The renderer warns and marks the
page degraded, which collapses its cache lifetime; failure semantics are normative in
Component and SSR module.
getInitialData or dehydrateState?
Use dehydrateState. It is the one that gives you a single code path.
getInitialData(ctx) returns a value that becomes props.data, and the renderer ships it to
the browser in the page’s props registry, so hydration does not refetch it either. But
props.data is only ever populated on the SSR path: on the CSR path mount() passes
data={undefined} explicitly, so a CSR-only widget, a hand-written customer embed and the
dashboard playground all get nothing and you write the fetch twice anyway. dehydrateState +
useQuery behaves the same everywhere — cache hit after SSR, live fetch on CSR, one component
code path.
Reach for getInitialData only for a small non-query value the server render cannot proceed
without, and handle it being undefined. Both receive the same three-key ctx, normative in
Component and SSR module. Do not use initialData
on your useQuery to paper over the difference; it competes with the hydrated entry.
What is already in the cache when you hydrate
On a server-rendered page the renderer seeds the widget config before any plugin code runs, so
useFetchWidget and getWidgetQuery resolve without a network call. One trap comes with it:
the seeded feed record is a projection, not the API response. To keep operator fields out
of the page source, the renderer seeds only id and additional_info — under ["feed", id]
and as the feed half of the ["widget", feedId, widgetId] pair. On a CSR page those keys
hold whatever /details actually returned. So a component that reads feed.name works when
you test it as a standalone embed and renders blank on a real server-rendered page. Read feed
data only from additional_info (branding, route config), or fetch it under your own key.
Rate limits, and what a 429 does to a page
The public API is rate-limited per client IP across every endpoint, and on a
server-rendered page every widget shares the renderer’s IP — one cold property page can spend a
dozen calls before your widget’s first request. The number, the 429 envelope and the fact
that you cannot read your remaining budget are in Public API.
- Do not retry a 429 in the browser.
retryTransientalready refuses to, because retrying while throttled only stretches a visible loading state. Do not override it. - Detect it and say so.
isRateLimitError(error)is true only for a 429; render a temporary, retry-later state —WidgetErrorStatefrom/runtimegives you an amber one. - Treat
isPermanentError(error)as a misconfiguration, not a blip: a 404, a disabled or archived feed, a bad id. Stop spinning and render a final state. - Batch on the server. One wide
/feed/propertiescall indehydrateStatecosts the page one request; N per-property calls from N widgets cost N.
A 429 during SSR is retried by the renderer; if it still fails the widget ships no-data markup
and the page is degraded. In the browser a failed top-level config fetch also stamps the host
element — the data-hr-error vocabulary is in
Runtime, mount and the DOM contract.
Third-party hosts: externalFetch
In the browser there is no restriction — an ordinary fetch under ordinary CORS reaches
any host that allows you. In the SSR sandbox fetch is wrapped in an allowlist. Central’s
host is always reachable; every other host must be declared in the widget’s externalFetch
array — on the widgets[] entry for a suite, at the manifest root for a single-widget
plugin. Undeclared hosts, and private / loopback / link-local / cloud-metadata hosts even when
declared, throw before the socket opens; redirects are re-validated at every hop up to a cap.
The block messages, the host versus host:port form and the cap are in
Component and SSR module; the field row is in
Manifest: widget summary.
Nothing validates externalFetch at publish time (advisory (unvalidated)), so a typo’d
host survives review and is discovered on a live page. The block throws inside your hook, so
you choose the outcome: catch it for optional enrichment and render without the extra data;
let it propagate for primary content, and the widget is replaced by a failure breadcrumb
rather than rendering something wrong.
Two sandbox behaviours to design around:
- Successful GETs are cached in-process for a short window (duration in
Limits and error index), keyed by URL alone. Your
server-side data can be that stale, and two widgets requesting the same URL make one
request.
?dev=1renders bypass the cache but stay fully enforced — you see a block locally, not in review. - A cached GET loses its response headers. The wrapper rebuilds the
ResponsewithContent-Typeonly, so header-driven logic (ETags, rate-limit counters, pagination links) is unavailable server-side on a normal render.
Mutations
Writes run client-side only. Use useMutation; the shared client’s retry default applies to
queries, not mutations, so a mutation makes exactly one attempt unless you say otherwise.
// Corrected from the previous data-fetching page, which called an undefined
// `apiBase()`. The write surface is the quote/reservation flow — see
// contracts/public-api.md for bodies and the reservation envelope.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";
const queryClient = useQueryClient();
const createQuote = useMutation({
mutationFn: async (payload: QuotePayload) => {
const res = await fetch(`${homerunnerApiBaseUrl()}/public-api/v1/quotes`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new WidgetFetchError(res.status, `quote: HTTP ${res.status}`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["my-plugin:availability"] }),
});
invalidateQueries refetches any active subscriber immediately, which is the supported way
to refresh data past the refetchOnMount: false default. Never send cookies or credentials —
the API is supports_credentials: false and would reject them.
Developing against this offline
npm run dev intercepts your fetches with a mock service worker so you can drive empty, large,
loading and error states without a live feed, and npm run dev:ssr runs your SSR bundle the
way the renderer does, dehydrateState included. Both have real limits — the mock worker is a
page singleton bound to one keyword, and the SSR dev server renders one widget per run (a suite
names it: npm run dev:ssr -- --widget {slug}, widget-core 0.12.2+), never a composed page. See
Dev sandbox and mocking and
Previewing SSR locally.
Related
- Public API — endpoints, parameters, pagination, envelopes, the rate limit.
- Component and SSR module — SSR exports,
ctx, the sandbox,externalFetch, failure semantics. - SDK reference — signatures for every helper named here.
- Config schema and UI schema —
parseWidgetConfig. - How a widget renders — where the two halves sit in the pipeline.
- Composing a page — how a layout pushes
filter.propertydown to the slot children that fetch with it.
Portals and dialogs
Your widget renders inside a shadow root. A dialog rendered in place inherits that root’s
styles — and also its clipping, its overflow, and every stacking context between the host
element and the viewport. React portals move the dialog elsewhere in the DOM. This page is about
where, because the wrong target silently drops every stylesheet you rely on. Everything here
ships in @homerunner-next/widget-core/runtime at the 0.10.0 publish floor, so no recipe below
needs a newer SDK — see Install and versions.
The shadow structure you portal into
Two code paths build the same shape, and both name their parts with class names, not data
attributes. mount adopts a server-emitted root by scanning for hr-react-root; if that class
is missing, adoption fails and it rebuilds from scratch.
<!-- packages/homerunner-renderer/lib/render-utils.tsx:745-747 — server-emitted DSD -->
<div data-hr-widget="plugin:acme-weather:forecast" id="{widgetId}:{feedId}" data-hr-ssr-id="…">
<template shadowrootmode="open">
<link rel="stylesheet" href="…"> <!-- your CSS, plus any declared fonts -->
<div class="hr-react-root">…your server markup…</div>
<div class="hr-portal-container"></div>
</template>
</div>
// packages/homerunner-widget-core/src/runtime/shadow-dom.ts:85-120 — client-built
[data-hr-widget="plugin:acme-weather:forecast"] ← shadow host
└─ #shadow-root (open)
├─ <link rel="stylesheet"> ← one per resolved CSS URL
├─ <div class="hr-react-root"> ← React renders/hydrates here
└─ <div class="hr-portal-container"> ← in-shadow portal target
setupShadowDOM(hostEl, cssUrls) returns
{shadowRoot, shadowHost, reactRoot, portalContainer, cssReady, cssPending}. cssPending is
the field most often missed: it says whether the react-root must stay hidden until cssReady
settles. It is true whenever the <link>s were only just connected (a fresh client build, or
the DSD ponyfill attaching a root around an inert template) and false for native Declarative
Shadow DOM, which the browser already styled at parse. cssReady resolves when every sheet
applies, on error, or after a hard timeout — see
Limits and error index.
adoptDeclarativeShadowRoot and waitForShadowLinks are internal and not exported;
setupShadowDOM, migrateSSRContent, getCSSUrlsFromElement and isShadowDOMSupported are.
The adopt-versus-migrate order, the visibility gate and the shadowDOM disagreement matrix are
in Runtime, mount and the DOM contract.
Not supported yet. A widget whose entire server output is a hoistable tag — a lone
<style>,<link>,<script>,<meta>,<title>or<base>— reads as “no server markup”, somountclient-renders instead of hydrating. If your component’s top level is a bare<Stylesheet>, wrap it in a real element.
Two portal targets, and one failure that looks like a third
Both shapes. There are exactly two targets the platform gives you.
| Target | Hook | Lives in | Use it when |
|---|---|---|---|
| In-shadow container | usePortalContainer() | div.hr-portal-container, a sibling of your react-root | The default. The dialog fits inside the widget’s box and nothing on the host page clips it. |
| Elevated singleton | useTopLevelPortal({cssString, enabled}) | Its own shadow root inside div.hr-dialog-portal-host at document.body | A sticky sidebar, an overflow: hidden navbar, a fixed banner or a small card would clip or under-stack the dialog. |
There is no third target. If no provider is in scope, usePortalContainer() returns undefined
and Radix falls back to the document body:
// @radix-ui/react-portal 1.1.x, dist/index.mjs — the fallback that bites
const container = containerProp || (mounted && globalThis?.document?.body);
When your widget lives in a shadow root, document.body is outside it, so none of your
stylesheets reach the dialog: it renders as unstyled browser-default HTML over the customer’s
page. Treat that as a bug, never a design. Two ways to land there: portalling from a component
that is not a descendant of mount’s providers, or calling useTopLevelPortal on a widget that
has no shadow host — the hook needs one to clone stylesheets from and returns undefined
without it, falling through to the same fallback. (With shadowDOM: false the fallback is
harmless, because the whole widget is already in the light DOM.)
The providers come from WidgetHydrationTree on the hydrate path and from mount directly on
the CSR path. On the server both are empty — the renderer passes no elements — and Radix’s
Portal renders null until it has mounted, so dialog content is always client-only and has no
server markup to mismatch against.
The default: usePortalContainer()
The scaffold’s DialogContent already does this, so most widgets never call the hook directly:
// packages/create-hr-plugin/template/src/components/ui/dialog.tsx:112-117
const portalContainer = usePortalContainer();
const elevatedPortal = useTopLevelPortal({ cssString, enabled: !!elevateDom });
const effectiveContainer = elevateDom ? elevatedPortal : portalContainer;
return <DialogPortal container={effectiveContainer}>…</DialogPortal>;
Note DialogPortal. It is a separate named export from the same module, because Dialog is
a re-export of Radix’s Root and has no .Portal property — <Dialog.Portal> is a
TypeError, not an API. The vendored Tooltip wires usePortalContainer() the same way.
Because this container sits inside your own shadow root, everything already works: your
stylesheet, your CSS variables, your :host([data-hr-theme="dark"]) rules. Nothing to pass.
Escaping the widget: useTopLevelPortal
useTopLevelPortal({cssString, enabled}) builds, on first use
(packages/homerunner-widget-core/src/runtime/elevated-portal.tsx:87-136):
- a refcounted singleton
div.hr-dialog-portal-hostappended todocument.body— fixed, 0 × 0,overflow: visible,z-index: 99999. It is created on the first mount and removed when the last elevated portal unmounts; - inside it, a per-dialog wrapper
<div>with its ownattachShadow({mode: "open"}); - inside that shadow root, a cloned copy of your stylesheet links, one
<style>fed fromcssString, anddiv.hr-dialog-portal-root— the element the hook returns.
enabled: false skips all of it and returns undefined, which is how an elevateDom-style
switch falls back to the in-shadow container. The two-field option type is normative in
SDK reference.
The token trap
This is the failure everyone hits once. Read the two lines that cause it:
// packages/homerunner-widget-core/src/runtime/elevated-portal.tsx:105-119
const stopThemeMirror = mirrorHostTheme(shadowHost, wrapper); // theme IS mirrored
const links = originalShadowRoot.querySelectorAll('link[rel="stylesheet"]');
links.forEach((link) => shadow.appendChild(link.cloneNode(true))); // ONLY <link> is cloned
const styleEl = document.createElement("style"); // cssString goes here
Only link[rel="stylesheet"] elements are cloned. Your compiled stylesheet — Tailwind
utilities, your component classes — comes across intact. Everything you rendered as an inline
<style> does not: accent colours, font.size, font.family and the customer’s customCss
are all computed from options at render time and injected with <Stylesheet>, which is a
<style> tag inside the react-root. In the elevated portal those declarations are simply
absent, so every var(--your-token, <light fallback>) resolves to its fallback — a dialog that
looks almost right in light mode and paints a white panel over a dark widget.
data-hr-theme is the exception. mirrorHostTheme copies it onto the portal wrapper and keeps
it live with a MutationObserver, so an OS dark-mode flip while a dialog is open re-themes the
dialog too, and :host([data-hr-theme="dark"]) rules inside the cloned sheets do match. The
parse-time data-hr-scheme="auto" arm is not mirrored and need not be: the runtime replaces it
with a concrete data-hr-theme the moment JS takes over, long before a dialog can open.
The fix: emit your tokens once, feed them to both
// derived from packages/homerunner-embeddables/widgets/split-cost/widget.tsx:191-226
const tokenCss = useMemo(
() => `
:host { font-size: ${cfg.font.size}px; }
.acme-scope { --acme-accent: ${cfg.lightModeColors.accent}; }
:where(:host([data-hr-theme="dark"])) .acme-scope {
--acme-accent: ${cfg.darkModeColors.accent};
}
${cfg.customCss ?? ""}
`,
[cfg.font.size, cfg.lightModeColors.accent, cfg.darkModeColors.accent, cfg.customCss],
);
return (
<div className="acme-scope" style={resolveWidgetSpacingStyle(cfg)}>
<Stylesheet css={tokenCss} />
<Dialog>
<DialogTrigger>Open</DialogTrigger>
{/* the scope class must be on the portalled content too — see rule 1 below */}
<DialogContent className="acme-scope" elevateDom cssString={tokenCss}>…</DialogContent>
</Dialog>
</div>
);
Two rules make the same string work in both shadow roots:
- Scope tokens to
:host, or to a class you also put on the portal content.:hostresolves to your widget host in one root and to the portal wrapper in the other, so it works in both. A class selector only matches what is actually inside the portal — and Radix renders only yourDialogContentsubtree there, not your outer wrapper. If your variables hang off a container class, add a layout-free scope class to the dialog’s own root element and include it in the token selector list. The platform’s explorer added exactly that (ELEVATED_TOKEN_SCOPE, inpackages/homerunner-embeddables/widgets/explorer/hooks/elevatedScope.ts) after shipping without it and painting light-mode popovers over dark widgets. - Sanitise before interpolating. Accent colours, font family and
customCssare admin-supplied strings going intodangerouslySetInnerHTML, twice over.
cssString syncs in place through its own effect, so changing a token neither tears down the
portal nor closes an open dialog.
elevateDom and cssString are props of DialogContent (and of the scaffold’s
ResponsiveDialogContent, which forwards both), never of Dialog. The dual-emit dark selectors
and the <Stylesheet> API belong to Styling and theming.
Radix’s accessibility warnings cannot see into a shadow root
Both portal targets put your DialogTitle inside a shadow tree. Radix checks for it with
document.getElementById(titleId), which only searches the document’s own node tree, so the
check fails and logs, on every open, from an otherwise correct dialog:
`DialogContent` requires a `DialogTitle` for the component to be accessible for screen reader users.
Warning: Missing `Description` or `aria-describedby={undefined}` for {DialogContent}.
Both are false positives — the aria-labelledby wiring inside the portal is correct. The
platform’s own dialog fixes them; the scaffold’s vendored copy does not, so add this to your
DialogContent if the noise matters:
// packages/homerunner-embeddables/components/ui/dialog.tsx:144-182 — the two fixes
<DialogPrimitive.Content aria-describedby={undefined} …> {/* opts out of the description check */}
…
{/* sibling of DialogPortal, not a child, so Radix's Portal does not swallow it */}
{elevateDom && mounted && createPortal(
<DialogPrimitive.Title style={srOnlyStyle} aria-hidden>{title ?? ""}</DialogPrimitive.Title>,
document.body,
)}
Gate the mirror behind a post-mount useState/useEffect flag, as shown. typeof document
differs between the server render and the hydration pass, and a structural difference there
makes React discard your whole SSR tree.
useShadowHost()
Returns the element the shadow root is attached to, or null when shadow DOM is off. Three real
uses: dispatching events the host page can hear (only the host is visible from outside the
boundary); measuring, since getBoundingClientRect() on the host gives the widget’s box on the
customer’s page and a shadow root has no box of its own; and reaching the light DOM for a
third-party SDK that mounts by id into document and cannot see inside a shadow tree.
What it is not for: reading data-hr-options or data-hr-css off the host. Server-rendered
pages moved props, options and CSS into a page-level registry keyed by data-hr-ssr-id; those
attributes survive only on hand-written CSR embeds and old cached pages. The attributes actually
present on a host today are listed in
Runtime, mount and the DOM contract.
Events across the shadow boundary
Outbound — a custom event must be marked composed to cross an open shadow root, and
dispatched on the host so the host page’s listeners see it:
// packages/homerunner-embeddables/widgets/related-properties/impressions.ts:55
hostEl?.dispatchEvent(
new CustomEvent("acme:opened", { detail, bubbles: true, composed: true }),
);
Inbound — a document-level listener sees event.target retargeted to your shadow host,
so root.contains(e.target) reads every click inside the widget as outside. Outside-close
handlers must use composedPath():
// packages/homerunner-embeddables/widgets/explorer/components/filters/pro/ProWhereField.tsx:37-40
const isEventInsideRoot = (root: Node, e: Event): boolean =>
typeof e.composedPath === "function"
? e.composedPath().includes(root)
: root.contains(e.target as Node);
One consequence worth planning for: an elevated portal is a different shadow tree hanging off
document.body, so a containment check against your own root correctly reports clicks inside an
elevated dialog as outside your widget. Check containment against the portal target too, or let
Radix’s own dismissable-layer handling own the close.
Layout widgets have no portal container on a server-rendered page
Suite, category: "layout" only. On a server-rendered customer page the layout shell is
emitted as static light DOM — no <template shadowrootmode>, no shadow root — and its client
mount is a deliberate no-op. Inside a layout shell on that path usePortalContainer() returns
undefined, useShadowHost() returns null, and nothing ever hydrates: a dialog there can
never open. Put interactive UI in a content widget bound into a slot — each slot child is its
own island with its own shadow root and portal container. The same layout embedded as a CSR
snippet does get a shadow root and full providers through LayoutCsrRenderer, which is why the
asymmetry is easy to miss locally. Full contract: Layout widgets.
Checklist
- Default to
usePortalContainer(); reach foruseTopLevelPortalonly when the host page clips or under-stacks the dialog. - Never let a portal target be
undefinedon purpose — that is the unstyleddocument.bodyfallback, not a feature. - Build one token string; render it with
<Stylesheet>and pass it ascssString, scoped to:hostor to a layout-free class you also put on the dialog’s root element. - Keep
mount’sshadowDOMin step with the manifest’s: mountingshadowDOM: falseunder a manifest that allows shadow DOM leaves no shadow host to clone from, so every elevated portal degrades to the body fallback on top of the stranding described in the mount contract. - Verify in dark mode, from a widget inside a sticky container, on a real page — not only in the dev sandbox.
See also
- Styling and theming — dual-emit dark selectors,
<Stylesheet>, and why layout CSS is document-level. - Runtime, mount and the DOM contract — the shadow
lifecycle, the DOM attribute contract, the
shadowDOMdisagreement matrix. - SDK reference — every
/runtimeexport, author-facing versus platform-internal. - How a widget renders — where the DSD template comes from.
Composing a page
Your suite becomes a page when a customer binds widgets into your layout’s slots and
points a page at it. Nothing in your manifest does that on its own — a person does it in
the dashboard, in four clicks, and every one of those clicks is shaped by decisions you
made in manifest.json.
This page follows that person: the widget picker, one click on a preset, a layout that opens populated, the slot editor, and finally page assignment. Then it turns around and asks what you have to design for so all of it holds up.
Every rule, cap and error string mentioned here has its normative home in Layout widgets. This page is the story and the design consequences, not the contract.
Layouts and presets exist only in the suite manifest shape and need widget-core
0.11.0+; pages needs 0.12.0+ and slots[].prefill needs 0.12.1+
(The two manifest shapes,
Install and versions).
Everything below assumes the customer has already installed your plugin from the Marketplace and enabled it on the feed (Install, customers and kill switches).
1. The widget picker
Feed → Widgets → the Create New Widget dialog: a searchable grid of cards, one card per creatable thing, then a display name, then Create. An enabled plugin contributes two kinds of card:
- One card per widget in
widgets[]that is notdeprecated(a v1 single-widget plugin contributes exactly one card, forplugin:{slug}). - One card per entry in
presets[], listed ahead of that plugin’s widget cards and marked with a smallpresetbadge.
A layout’s card differs from a content widget’s in two ways the customer can see: the
glyph is a Layers icon instead of the plugin puzzle piece, and the platform appends
(layout — hosts other widgets in its slots) to whatever description you wrote in the
summary. Write a description that still reads well with that clause bolted on the end.
The icon on a card resolves widgets[].icon → the plugin’s root icon → the glyph, and
an icon URL that fails to load falls back to the glyph as well
(Keywords, assets and URLs). Card descriptions are
clamped to two lines with a More link that reveals the full text, a Plugin details
link to your plugin page, and — for a preset — the list of widgets it will create. Two
lines is the real budget for a summary description; everything after that is one click
away.
A preset card is labelled {plugin name}: {preset name} — PDP Suite: PDP Starter for the
reference suite — over a description the platform writes for you
(The customer-facing surface). You
choose the second half of that label and nothing else, so make presets[].name say what
the customer gets (“PDP Starter”), not what it is built from.
Cards vanish when a widget is deprecated, when it is switched off platform-wide, or when
the feed has switched it off — and a preset vanishes with any widget it references,
because central strips presets whose layout or children are withheld from the manifest it
serves. A preset is only ever offered when everything it needs is live
(Install, customers and kill switches).
2. What one preset click creates
The customer types one name — say Stay page — picks the preset card, and clicks Create.
Four things then happen, in this order.
- System-keyword children are ensured. Every child in your
slotsthat is a bare system keyword rather than one of your own slugs is matched to the feed’s oldest active widget with that keyword. Missing ones are created first, with the platform’s own defaults. A keyword this platform cannot create is reported in a toast and skipped, not fatal (prefilldescribes the same machinery). These are shared, never duplicated per preset — if the feed already has a gallery, your preset binds that gallery. - One instance of each of your own content widgets referenced by the preset, created
with settings = your schema defaults + the feed’s branding +
filter.feed_id, and display-named{typed name} — {widget name}. - The layout, created with settings = its schema defaults plus a
slotsobject mapping each preset slot name to the ids just created or ensured. - A redirect to the new layout’s playground, so the customer lands on the slot editor with everything already bound.
A name a customer types once therefore spreads across the whole composition:
// Illustrative: the feed after one click on "PDP Starter", named "Stay page".
// Naming rule from src/components/widget/CreateWidgetDialog.tsx →
// createPluginPresetWidgets; widget names from pdp-suite 1.3.3 manifest.json.
Stay page plugin:pdp-suite:pdp-frame ← the layout, slots bound
Stay page — Stay Hero plugin:pdp-suite:stay-hero
Stay page — Stay Facts plugin:pdp-suite:stay-facts
Stay page — Booking CTA plugin:pdp-suite:booking-cta
<the feed's own widgets> property-title, gallery, description, amenities,
calendar, booking, reviews ← reused, or created for them
Three consequences for how you build:
- Your widgets are created unconfigured. Every own-widget child arrives with schema
defaults only. If a widget renders nothing, or renders an error, until someone fills in
a field, the preset produces a broken page on click one. Give every field a sensible
.default()(Config schema and UI schema). - Name your widgets so the
{typed name} — {widget name}form reads well.Stay page — Booking CTAis a good row in a widget table;Stay page — Widget 2is not. - A child slug that matches one of your own widget slugs is always your widget. The
platform only treats a preset child as a system keyword when it is not one of your
declared slugs, so naming a widget
galleryshadows the system gallery inside your own presets.
If something fails part-way through, the dashboard best-effort deletes the own-widget
children it created in step 2 and surfaces the original error. Note what it does not
roll back: system widgets ensured or created in step 1 stay on the feed, because they are
shared with everything else there. If a referenced widget stopped being available between
the page load and the click, the customer sees
Preset widget "{slug}" is not available right now. and nothing is created.
Not supported yet. Preset slot keys are never checked against your layout’s declared slot names. A typo creates real widgets, binds them, and renders nothing — there is no error anywhere. Diff your preset keys against
slots[].nameby hand before you submit (Presets).
3. prefill: a layout that opens populated
A customer who picks your layout card directly gets a different courtesy. If any slot
declares prefill (0.12.1+), the dashboard collects the union of those keywords in
declared order, creates the ones the feed has no active widget for, and binds the feed’s
oldest active widget per keyword into the slots that asked for it — all before the layout
row is written.
So pdp-frame opens with the customer’s existing gallery in hero, their existing
booking widget in sidebar, and so on, instead of four empty regions. A slot that
resolves to nothing is simply left out of the stored bindings.
What that means for you:
prefillis a first-run convenience, not a contract. It runs once, at creation, is never re-applied, and the renderer never reads it. A customer who clears a slot has cleared it.- It only takes system keywords. Your own widgets cannot be prefilled — that is
exactly what a preset is for. The rule and its publish errors are in
slots[].prefill. - Prefer real keywords over aspirational ones. Nothing validates them against the platform’s actual widget list, so a typo silently prefills nothing.
4. The slot editor
The playground for a layout is the normal config form with one extra panel at the top,
Slot Configuration, injected by the dashboard from your manifest’s slots[] — one
labelled row group per slot, each a list of widget pickers with Add Widget and remove
buttons. Edits autosave like any other setting.
Two behaviours worth knowing before you design your slots:
acceptsfilters the candidate list, and only that. A widget is offered whenacceptsnames its category or its exact keyword; the layout’s own keyword is always excluded, and so is every other layout. It is a hint to the customer, checked nowhere else (slots[].accepts).- An empty row is not a binding. Nothing resolves it, and both render paths drop ids
that do not match a live widget on the feed
(
settings.slots).
The customer can bind anything they like, in any order, including nothing at all. Your
component sees the result as renderedSlots and must survive all of it.
5. Page assignment
A layout that only ever renders as an embed is a component. A layout that takes over a server-rendered page is what most authors are actually after, and that is a separate, explicit step the customer takes at Feed → Page URLs → Page layouts.
Four rows — Property page, Listings page, Checkout page, Confirmation page — each a picker
of Automatic plus every layout on the feed that may serve that page. Your layout appears
in a row when its plugin is approved and enabled on the feed, the widget is not
kill-switched or deprecated, and the widget summary’s pages names that page. A plugin
option is labelled with the customer’s own name for that layout instance followed by your
plugin’s name, and carries a puzzle-piece marker once selected — so it is always visible
that a third party renders the page. Leaving a row on Automatic means the feed’s own
system layout for that page.
The picker also warns when Automatic is ambiguous — more than one system layout instance of that kind on the feed, with the oldest silently winning — which is a good reason to give a customer an explicit choice. Switching a layout purges the page cache, so the change is live in seconds rather than at the page TTL (Publishing, versions and rollback).
Three things about an assigned plugin layout that change how you design it:
- Stored bindings only. Unlike a system layout, nothing is backfilled from the platform’s default slot map. A slot the customer never bound does not appear at all.
- The property page renders your slot vocabulary; the other three do not. Listings,
checkout and confirmation are fixed to a single
contentslot, and they 404 rather than degrade when it is empty. Read What the renderer does with an assignment before you put anything butpdpinpages. - Anything the renderer cannot honour degrades to Automatic, once, with a logged reason — a deleted or paused layout, a plugin no longer live on the feed, a widget whose summary does not declare the page. The customer’s page keeps working and your layout quietly stops being used, so “my layout vanished” is nearly always one of those reasons (Degrading to Automatic).
6. How the property reaches your widgets
One PDP layout instance serves every property page on the feed, so the property cannot
come from the layout’s stored settings. It is injected, and every widget in every slot
receives it the same way: as options.filter.property.
- On a server-rendered property page, the page itself supplies the filter — feed id,
property slug, and
platform: null— and it is merged over every widget’s resolved settings, the layout’s included, after the customer’s own values. A storedfilter.propertyon a child never wins on a PDP. - On a CSR embed, the layout resolves a property (its own Property setting, or a
per-embed
data-hr-optionsoverride on the layout’s host element) and pushes it onto every slot child as it mounts them. The layout verifies the slug once, so a bad property fails in one place instead of 404ing in every slot at the same time. The exact payload and the console lines it can produce belong to Runtime, mount and the DOM contract.
Both paths land in the same place, so a slot child reads one thing:
// pdp-suite 1.3.3 (the reference suite) — src/widgets/stay-hero/widget.tsx
// The same three-line helper appears verbatim in stay-facts and booking-cta.
/** The property slug the platform injects when a parent layout resolves one. */
function injectedProperty(options?: Record<string, unknown>): string | null {
const filter = (options as { filter?: { property?: unknown } } | undefined)?.filter;
return typeof filter?.property === "string" && filter.property ? filter.property : null;
}
Because your config extends widgetSchema, the base filter object is part of your
schema, so parseWidgetConfig(configZod, props.options) keeps the injected value too and
cfg.filter?.property reads the same slug
(Settings and options). The raw helper above is
what the reference suite ships: it is deliberately independent of the parse, so a widget
still knows which property it is on even while its own config is being edited.
Design for injection, not configuration: treat filter.property as the identity of the
page and fall back to your own setting only when nothing was injected. A widget that
demands the customer pick a property in its own config cannot be dropped into a PDP layout
at all.
7. Design for composition
Everything above adds up to five habits.
Fail alone. Each slot child is its own island — its own render, its own data, its own error boundary. A content widget that throws costs one region of the page. Your layout component throwing during a server render costs the whole page, so the shell must be the most defensive code in your suite: parse, default every slot, and never assume a key exists (Failure semantics).
Read well when empty. The customer decides what goes into each slot, and “nothing” is a legitimate answer — especially on an assigned page, where unbound slots are never backfilled. A grid that collapses gracefully with two of four regions filled is worth more than one that only looks right in your screenshot. Do not paint a background on the shell and do not reserve space for children that may not exist (Layout CSS, Styling and theming).
Mirror the system slot vocabulary. Naming your property-page slots hero, main,
sidebar and bottom is what lets the platform’s own widgets — and the customer’s
existing ones — drop straight in, and it is what makes prefill and page assignment feel
native rather than parallel.
Put interactivity in the children. On a server-rendered page the layout shell is static light DOM forever: no hydration, no React tree, no event handlers. A tab strip or a sticky booking bar belongs in a content widget bound into a slot, not in the shell (SSR).
Compose one level. Layouts cannot nest, on either render path, and there is no manifest field that opts in. Everything your layout arranges is content (Layouts cannot nest).
Known gaps on this path
Not supported yet.
- Preset slot keys are never validated against your layout’s declared slot names.
prefillkeywords are shape-validated only, never checked against the real system-widget keyword list.accepts: ["layout"]can never match — the slot picker excludes layouts before your filter runs.- A plugin layout on a server-rendered page can never be interactive.
- The renderer keeps server-rendering an assigned layout you later mark
deprecated, while the dashboard flags that same assignment as an error. Deprecating a layout does not take it off a page.- A layout previews on its own —
npm run dev:ssr -- --widget {layout}(widget-core 0.12.2+) — but its composed server render does not. The harness passes norenderedSlots, so the shell renders with every slot empty; only the platform composes the bound children (Previewing SSR locally).
Where to go next
- Add a layout widget — build
pdp-framefile by file. - Layout widgets — every field, cap and error string this page narrated.
- Fetching data — how each slot child gets its own data.
- Troubleshooting — when a slot renders nothing, or a page falls back to Automatic.
Dev sandbox and mocking
npm run dev is fully offline. The scaffold starts a Vite server on :3001, mounts your
widget exactly the way a customer page does, and answers every HomeRunner public-api call
from an MSW service worker fed by local fixtures. You never need a feed, an account or a
network connection to drive the UI through every data state. Server rendering is a separate
harness — see Previewing SSR locally.
Both shapes. create-hr-plugin generates a sandbox for each: --template single (the
default) the one described below, --template suite a version that mounts every widget of
the suite on one page and composes the layout — see Suites. Everything in
between the two sections applies to both.
What npm run dev actually does
index.html loads exactly one script, /src/dev/main.tsx. That file is dev-only — the
production client entries are src/index.tsx (single-widget) or
src/widgets/{slug}/index.tsx (suite), and neither they nor any ssr-entry.ts ever import
src/dev/ or mocks/, so MSW never reaches a published bundle.
// packages/create-hr-plugin/template/src/dev/main.tsx:39-70 (abridged) — single-widget.
// The suite template's src/dev/main.tsx has the same four steps; see "Suites" below.
const params = new URLSearchParams(window.location.search);
const mockEnabled = params.get("mock") !== "off";
if (mockEnabled) {
await startMockWorker(KEYWORD, {
scenario: (params.get("scenario") as MockScenario) || "default",
fixtures,
widgetSettings: defaultSettings(), // configZod.parse({}), try/catch → {}
delayMs: 300,
});
getSharedQueryClient().setDefaultOptions({ queries: { retry: false } });
}
registerPlugin(SLUG, Widget);
// …then mount() every [data-hr-widget-container] holding a [data-hr-widget="{KEYWORD}"]…
if (mockEnabled) mountDevPanel(KEYWORD);
Four of those steps have consequences you will meet:
?mockand?scenarioare read once, at boot, and neither is validated.- Settings are seeded with
configZod.parse({}), and the mocked/widget/{id}returns that object as the widget’s storedsettings— sooptions.<field>reads are defined instead of crashing. The parse is wrapped in atry/catchthat falls back to{}, so a schema with a required field and no default silently seeds nothing. - React Query retries are off. Dev only. Without it the
errorscenario looks like a long spinner while the default retry backoff runs. mount()is the real one — shadow root, theme controller,ClientWrapperForWidget, error boundary: Runtime, mount and the DOM contract.
The mount host is <div data-hr-widget="plugin:{slug}" id="dev-widget:1"> — one per
namespaced keyword in a suite, with its own id (frame-1:1, intro-1:1, facts-1:1).
That id is the colon-joined {widgetId}:{feedId} pair mount splits to build its config
fetch, and the single-widget default matches the mock defaults
(widgetId: "dev-widget", feedId: 1). The built-in /widget/{id} route matches any
id, so you can change it freely — the data-hr-widget keyword is the part that must stay
in sync with what you pass to mount(). In a suite the ids matter for a second reason:
mocks/fixtures.ts keys each one to its own keyword (Suites).
The two switches are ?scenario=default|empty|single|large|loading|error and ?mock=off
(real staging data). ?scenario= is cast, not validated: an unknown
value is stored verbatim, behaves like default, and leaves every panel button unhighlighted
— that unlit button row is the tell.
Scenarios
Six named data states, defined by the SDK and exported as MOCK_SCENARIOS:
| Scenario | Behaviour | Rows the built-in generators return |
|---|---|---|
default | a healthy, representative payload | 6 |
empty | zero results — empty states, placeholders | 0 |
single | exactly one result — single-item layouts | 1 |
large | a big page — overflow, pagination, virtualization | 48 (of a reported 240 total) |
loading | the request never resolves — spinners, skeletons | — |
error | the request 500s — error states, fallbacks | — |
loading is MSW’s delay("infinite"), so the promise genuinely never settles; error
returns a 500 after the configured latency; everything else applies the latency and falls
through to the data path.
Why the widget always mounts
Both shapes. The three config endpoints — /{feedId}/details, /widget/{widgetId}
and /{feedId}/feed/widgets — are deliberately not scenario-gated; they apply the
latency and nothing else. mount() cannot render until the widget’s feed and settings
resolve, so gating them would turn loading and error into a blank page with nothing to
look at. The handler file says so in as many words: “These are NOT gated by loading/error so
the widget always mounts”. The gated data endpoints are /{feedId}/feed/properties,
/{platform}/properties, /reviews, /property/{id}/full, /property-calendar and
/{uuid}/calendar — so the scenarios exercise your data code, never the bootstrap.
Fixtures — mocks/fixtures.ts
Anything you do not override uses the SDK’s built-in generators, scaled by the active scenario. Override an endpoint only when your widget needs a specific shape.
| Fixture key | Endpoint | Factory arguments |
|---|---|---|
feedDetails | GET /{feedId}/details | (feedId) |
widget | GET /widget/{widgetId} | (widgetId, widgetSettings) |
feedWidgets | GET /{feedId}/feed/widgets | none |
properties | GET /{feedId}/feed/properties, GET /{platform}/properties | (request) |
reviews | GET /reviews | (request) |
propertyFull | GET /property/{id}/full | none |
calendar | GET /property-calendar, GET /{uuid}/calendar | none |
Each value is either the raw JSON the endpoint returns, or a (…) => json factory called at
request time — a factory is how you vary the response by request:
// packages/create-hr-plugin/template/mocks/fixtures.ts — the commented example, uncommented
export const fixtures: MockFixtures = {
reviews: (request) => {
const url = new URL((request as Request).url);
const perPage = Number(url.searchParams.get("per_page") ?? 10);
return { status: "ok", result: [], pagination: { total_items: 0, average_rating: 0 } };
},
};
Two rules that catch people out. An override replaces the generator, not the gate —
loading and error still short-circuit before your fixture runs, so an error-scenario
screenshot is the platform’s 500, never your rows. And envelopes are not uniform: the
generators mirror the real API, where /{feedId}/details returns {status, status_code, result} while /widget/{id} returns {success, status_code, data, meta}. Match the
endpoint you are overriding — Public API.
Handlers match any origin (*/public-api/v1/…), so they work whatever
NEXT_PUBLIC_HOMERUNNER_BASE_URL points at. Anything they do not match falls through
untouched (onUnhandledRequest: "bypass") — Vite HMR, images, your own third-party APIs —
which is also why a mistyped public-api path silently hits the network instead of erroring.
Not supported yet.
MockFixtureshas an index signature, so TypeScript accepts extra keys — butcreateMockHandlersreads only the seven above, and an extra key is inert. To mock an endpoint that is not in the table, take the running worker off the control handle and add your own MSW handler:window.__HR_MOCK__.worker.use(…).
The dev panel
A plain-DOM bar pinned to the bottom of the page, mounted only when mocking is on. It drives
window.__HR_MOCK__, the control handle startMockWorker publishes.
| Control | What it does |
|---|---|
| scenario | control.setScenario(s), then a targeted query reset (below) |
| theme | light / dark / auto → writes colorScheme into mock branding and the widget settings, then dispatches options-updated |
| latency | control.setDelay(ms), 0–2000 in steps of 100, applied to every mocked response |
| settings | a JSON textarea → control.setSettings(obj) + the same options-updated push |
The reset-versus-invalidate rule
Both shapes. This is the one piece of panel behaviour you have to know, because it makes
the console and the buttons behave differently. Every scenario button runs this after
setScenario:
// packages/create-hr-plugin/template/src/dev/DevPanel.ts:44-52 (try/catch elided)
getSharedQueryClient().resetQueries({ predicate: (q) => q.queryKey?.[0] !== "widget" });
reset, not invalidate: invalidating keeps the previous data cached, so isLoading stays
false and a switch to loading / error / empty would keep painting the old rows. The
predicate spares every ["widget", …] key — the feed-and-settings query
ClientWrapperForWidget owns — so the widget re-runs its data queries without unmounting.
That reset lives in the panel, not in the mock layer, so the console equivalent needs both
calls (the shared client is a page singleton on window.HRWidget.__QUERY_CLIENT__):
__HR_MOCK__.setScenario("error") // alone: stale data stays on screen
HRWidget.__QUERY_CLIENT__.resetQueries({ predicate: (q) => q.queryKey?.[0] !== "widget" })
If your own queries use a key starting with the literal "widget" the predicate spares them
too, and the scenario buttons will appear not to work. Prefix your keys with your plugin slug,
as the scaffold’s queryKeys factory does — Fetching data.
Dark mode works here
The theme select writes colorScheme into both the mocked feed branding and the pushed
settings, and the CSR mount() path resolves resolvedTheme from that value: dark and
light pass through, auto follows your OS prefers-color-scheme, and light is only the
fallback when nothing is set anywhere. Older documentation claimed CSR mount always passed
resolvedTheme: "light" and sent you to the SSR harness for dark mode — that was wrong.
What is still true is that resolvedTheme is effectively always light in production
SSR, which is why dark styling belongs in CSS —
Styling and theming.
Testing options-updated
The settings editor is your test harness for the live-settings channel — the same
CustomEvent the dashboard playground dispatches while a customer types in the config form.
Paste an options object, hit apply settings, and the mounted widget re-renders without a
remount. The listener table, and the fact that a hydrated SSR widget has none, is in
Runtime, mount and the DOM contract. Two mechanics to
know before you trust what you see:
- The event payload replaces the widget’s settings wholesale. It is treated as the
widget’s complete stored
settingsand re-run throughgetFinalSettings, so paste a partial object and every other field falls back to feed branding or disappears. Test the payload your form would actually send — see Settings and options. - The panel’s own copy of the settings merges instead.
control.setSettings(obj)patches the mock store, while the dispatched event carries exactly what you typed. After a partial apply, the mocked/widget/{id}and the mounted widget hold different objects; a reload resolves to the merged one. Apply a complete object and the two never diverge.
Use it to prove your parse is real: clear a field to null or "" and confirm the component
still renders, because parseWidgetConfig strips those leaves rather than throwing. A widget
that reads props.options directly flips to data-hr-error="render-failed" here — exactly
the failure a customer would see.
Real staging data
Both shapes. ?mock=off skips the worker and the panel entirely, and your fetches go out
for real. Copy .env.example to .env.local, set
NEXT_PUBLIC_HOMERUNNER_BASE_URL=https://beta.homerunner.io, then open
http://localhost:3001/?mock=off. That file is optional for mocked development and only
consulted here and by npm run dev:ssr. Three things to get right:
- The env injection is dev-only.
viteHomerunnerWidgetreads everyNEXT_PUBLIC_*key from.env/.env.localand defines it as aprocess.env.Xliteral in the dev build only; production reads the same value fromwindow.HRWidgetRuntime.apiBaseUrl. A key that works here can be undefined at SSR time. - With no
.env.localyou hit the production fallback, not staging. Set the variable. - Change the mount
id.dev-widget:1exists on no real feed; the widget-config fetch fails and the host getsdata-hr-error="widget-fetch-failed". Put a real{widgetId}:{feedId}inindex.html. The public API needs no key but is rate limited per IP — Public API.
With no panel there is nothing to flip: this mode checks real response shapes against your fixtures, it does not drive states.
Suites
npx create-hr-plugin <slug> --template suite generates a sandbox that mounts every
widget of the suite on one page and composes the layout (create-hr-plugin 0.8.0+).
index.html carries one host per namespaced keyword, src/dev/main.tsx imports each
widget’s own src/widgets/{slug}/index.tsx — which registers and auto-mounts it, exactly
as its built IIFE does in production — and the layout’s mocked settings carry slots, so
it resolves the other two as real slot children through LayoutCsrRenderer. That is the
whole client half of layout composition, offline. The server half is not reproducible
locally: Previewing SSR locally.
Not supported yet.
startMockWorkeris a page singleton bound to ONE keyword — it returns the existing control on every repeat call and bakes that keyword into the handlers. So one page has one scenario, one latency, one branding and onesettingsobject for every widget on it. The scaffold seeds those settings with the union of every widget’sconfigZod.parse({})and relies on each widget’s zod stripping the keys that are not its own; give two widgets a field with the same name and they share its value here. (npm run dev:ssrrenders one widget, so it seeds only that widget’s defaults and has no such collision.)
What the singleton would break, and what stops it:
- Every content widget still mounts.
mount()renders the component you hand it and never routes on the keyword the mocked/widget/{id}echoes back. - A CSR layout’s slots would not work.
LayoutCsrRendererresolves each binding by fetching/widget/{childId}, and the built-in handler hands every id the one baked keyword — so every slot child would resolve to the layout itself.
The escape hatch is the widget fixture factory, which receives the requested id. The
suite template ships it, and it is not optional in a suite:
// packages/create-hr-plugin/template-suite/mocks/fixtures.ts (abridged) —
// per-id keywords, which is what lets three widgets coexist on one page.
const KEYWORD_BY_ID: Record<string, string> = {
"frame-1": `plugin:${SLUG}:page-frame`,
"intro-1": `plugin:${SLUG}:intro-card`,
"facts-1": `plugin:${SLUG}:quick-facts`,
};
export const fixtures: MockFixtures = {
widget: (widgetId: unknown, settings: unknown) => ({
success: true, status_code: 200, meta: [],
data: {
id: String(widgetId), status: true, feed_id: 1, user_id: 1, display_name: "Dev Widget",
keyword: KEYWORD_BY_ID[String(widgetId)] ?? `plugin:${SLUG}:intro-card`,
settings: settings ?? {},
},
}),
};
The bindings the layout opens with are a plain Record<slotName, widgetId[]> — the same
shape the dashboard’s Slot Configuration panel writes
(Layout widgets) — exported from the same file and merged into
the seeded settings under slots:
// packages/create-hr-plugin/template-suite/mocks/fixtures.ts — DEV_SLOTS
export const DEV_SLOTS: Record<string, string[]> = {
hero: ["intro-1"], main: ["facts-1"], sidebar: [], bottom: [],
};
// packages/create-hr-plugin/template-suite/src/dev/main.tsx (abridged) —
// import the entries AFTER the worker resolves, or the first fetch escapes unmocked.
await startMockWorker(KEYWORDS[0], { fixtures, widgetSettings: defaultSettings(), delayMs: 300 });
getSharedQueryClient().setDefaultOptions({ queries: { retry: false } });
await import("../widgets/page-frame/index");
await import("../widgets/intro-card/index");
await import("../widgets/quick-facts/index");
mountDevPanel(KEYWORDS); // a LIST of keywords — it pushes settings to all of them
The keyword passed to startMockWorker is only the fallback the built-in /widget/{id}
response echoes back; the factory above overrides it per id. mountDevPanel takes the
keyword list in the suite template and targets
[data-hr-widget="<keyword>"] for each, so one apply settings click reaches every
mounted widget — with the one shared object described above.
Converting an existing single-widget sandbox. Three files change, and nothing else:
index.html grows one [data-hr-widget-container] host per namespaced keyword with
distinct ids; src/dev/main.tsx drops its static ../widget / ../config imports for one
dynamic import() per src/widgets/{slug}/index, and passes a keyword list to
mountDevPanel; mocks/fixtures.ts gains the widget factory above. The build side needs
nothing — Vite’s CSS middleware already serves each widget’s transformed stylesheet at
/dist/{plugin}/{widget}/{widget}.css, walking that widget’s own entry so the sheet carries
only its imports, and the hr-widget-css-update HMR event cache-busts the <link> inside
that widget’s shadow root. The full project conversion is in
Build a suite.
The worker file
The sandbox registers /mockServiceWorker.js, which ships in public/ and is regenerated by
npx msw init public (both templates record msw.workerDirectory for exactly that) — do
that if the sandbox stops intercepting after an msw upgrade. Never delete it: it is
what makes npm run dev work.
Vite copies publicDir into outDir on every build, which used to publish that dev-only
file inside dist/ and list it in dist/manifest.json. Both scaffold templates now switch
publicDir off for builds and leave it on for dev (create-hr-plugin 0.8.0+):
// packages/create-hr-plugin/template/vite.config.ts and template-suite/vite.config.ts
publicDir: command === "build" ? false : "public",
A project scaffolded before 0.8.0 does not have that line — add it, or keep living with a
harmless extra file in your published prefix. Either way it is your build config that
decides, never the file’s presence in public/.
What the sandbox does not cover
It is a real mount() against fake data, and nothing more:
- No server render — no
node:vmsandbox, norequirewhitelist, nogetInitialData/dehydrateState, no hydration: Previewing SSR locally. - No Declarative Shadow DOM and no props registry. The sandbox always takes the CSR
branch, so
data-hr-ssr-idhydration bugs cannot appear here. - No
externalFetchenforcement. A cross-origin call your manifest never declares succeeds locally and is blocked on the server: Component and SSR module. - No dashboard form. Your
configSchemais never converted back to zod here, so a round-trip breaker renders fine locally and empties the customer’s config panel: Config schema and UI schema. - No server-side layout composition. The suite sandbox composes a layout’s slots the
client way, from bindings you wrote into
mocks/fixtures.ts. On a server-rendered page the renderer renders the bound children and hands them to the shell asrenderedSlots, in light DOM, with the client mount a deliberate no-op — a different code path with no local equivalent: Layout widgets.
Walk all six scenarios plus a dark-theme pass, then verify the server render. Symptoms and messages are indexed in Troubleshooting.
Previewing SSR locally
npm run dev proves your widget works in a browser. It proves nothing about the server.
On a customer page the renderer evaluates your built SSR bundle inside a node:vm
sandbox with no DOM, calls your data hooks there, and ships the resulting HTML before any
client code runs — see How a widget renders.
npm run dev:ssr runs that path on your laptop. It is a port of the renderer’s loader,
shipped in the SDK’s /testing subpath (widget-core 0.8.0+, so the npm latest build has
it too — Install and versions). Three things
it gained recently matter enough to gate every claim below. In 0.12.2: it previews one
named widget of a suite, and it stopped diverging from the renderer’s sandbox in five
places. In 0.12.3: it links a stylesheet for a widget that exports no getStaticAssets,
which before then previewed unstyled.
Both shapes. A single-widget project needs nothing but a build. A suite has no default widget, so you name the one to preview — see Suites: one widget at a time. What has no local equivalent, in either shape, is a layout’s server-side slot composition.
Running it
npm run dev:ssr is tsx scripts/dev-ssr.mts, which calls createSSRDevServer from
@homerunner-next/widget-core/testing. It reads dist/ and never compiles anything,
so a build must come first.
# packages/create-hr-plugin/template/package.json and template-suite/package.json —
# the same "dev:ssr": "tsx scripts/dev-ssr.mts" script in both shapes.
npm run build # Single-widget: dist/{slug}-ssr.umd.js + .iife.js + .css
npm run dev:ssr # Single-widget — serves http://localhost:3003
npm run build # Suite: dist/{widget}/{widget}-ssr.umd.js per widget
npm run dev:ssr -- --widget intro-card # Suite — name the widget to preview
Skip the build and every scenario tab renders a red SSR render failed: panel naming the
missing bundle by its dist-relative path; the exact string, and the four boot-time
selection errors below, are in the error
index.
Naming a widget — Suite
The selection is resolved before the server starts, against the root manifest.json’s
widgets[], so an unusable choice fails at boot with a message naming the widgets that do
work — never a red panel on every scenario tab. The suite scaffold’s scripts/dev-ssr.mts
runs that lookup itself and prints the message before exiting 1; call createSSRDevServer
yourself and it throws the same string. Three ways to make the selection, highest
precedence first (widget-core 0.12.2+):
| How | Example |
|---|---|
--widget {slug} in argv | npm run dev:ssr -- --widget intro-card |
--widget={slug} in argv | npm run dev:ssr -- --widget=intro-card |
HR_SSR_WIDGET | HR_SSR_WIDGET=intro-card in .env.local |
Four conditions fail instead of booting: naming no widget at all, naming one the manifest
does not declare, naming one on a single-widget project (there is nothing to name), and
naming a CSR-only widget — a summary carrying "ssr": false ships no SSR bundle, so
there is nothing to preview. That last one is the correct outcome, not a fault; verify a
CSR-only widget in the sandbox instead.
Everything the selection drives comes from that one lookup: the keyword
(plugin:{slug}:{widget}), the nested dist/{widget}/{widget}-ssr.umd.js, .iife.js and
.css bundles, and the widget’s declared externalFetch hosts
(Manifest: widget summary). A single-widget project
resolves the flat plugin:{slug} keyword and dist/{slug}-* files exactly as it always
has.
Routes: / redirects to /preview?scenario=default, which shows the scenario tabs, the
rendered envelope and an inspector (raw server HTML, the props your component received,
the dehydrated React Query cache). /__render.json?scenario=… returns the same render as
JSON — html, props, dehydratedState, assets — which is the scriptable one.
/dist/* and /mockServiceWorker.js are static, and /p/{anything}/{file} is a local
stand-in for the plugin asset proxy served out of dist/
(Keywords, assets and URLs).
The SSR bundle is re-read from disk on every request, so npm run build in another
terminal plus a refresh picks up new code with no restart. The mock handlers, the widget
selection and the seeded settings are fixed at boot — changing a config.ts or your
fixtures needs a restart.
Configuration
scripts/dev-ssr.mts calls process.loadEnvFile(".env.local") then
process.loadEnvFile(".env"), each in a try/catch so a missing file is fine. Node’s
loader never overwrites a variable that is already set, which gives you this precedence
(verified on Node 22): shell environment › .env.local › .env › the defaults below.
--widget is the only CLI flag the script reads; npm run dev:ssr -- --hydrate still does
nothing.
| Key | Default | Effect |
|---|---|---|
PORT | 3003 | Server port |
HR_SSR_WIDGET | unset | Suite only. Which widget to preview, when you do not pass --widget (0.12.2+) |
HR_SSR_HYDRATE | unset | 1 turns on client hydration; anything else is server-render only |
HR_RUNTIME_URL | ${assetBaseUrl}/w/runtime.iife.js, hydrate mode only | The runtime IIFE that defines window.HRWidgetRuntime. In server-only mode it stays undefined and is never used |
NEXT_PUBLIC_HOMERUNNER_BASE_URL | https://beta.homerunner.io | Exposed inside the sandbox as process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL, and to the client as HRWidgetRuntime.apiBaseUrl |
NEXT_PUBLIC_WIDGET_ASSET_BASE_URL | https://assets-dev.homerunner.io | Exposed as process.env.NEXT_PUBLIC_WIDGET_ASSET_BASE_URL; also the origin HR_RUNTIME_URL defaults from |
.env.example covers most of that, but not all of it and not uniformly. Both templates
ship the two NEXT_PUBLIC_* keys uncommented, as live values, plus HR_SSR_HYDRATE and
HR_RUNTIME_URL commented out; the suite’s file adds a commented HR_SSR_WIDGET, which the
single-widget one has no use for. PORT is in neither file — set it in the shell or add
it to .env.local yourself. The two base URLs barely matter for data: the mock
handlers match any origin, so the value only shows up if you read it in your own code.
Scenarios, and the loading downgrade
The tabs are the same six scenarios the browser sandbox uses; their meanings and the
fixture keys are in Dev sandbox and mocking. Two behaviours
are specific to this server. loading is silently downgraded to default for the server
render — the loading handler never resolves, so an un-downgraded server render would
hang forever instead of producing HTML. The real scenario is restored immediately
afterwards, so in hydrate mode the client sees loading over a populated server render.
And latency defaults to 0 ms here, not to the sandbox’s default.
Settings come from configZod.parse({}) at boot — the whole default object — so
options.<field> reads never crash. In a suite that is the selected widget’s own
src/widgets/{widget}/config.ts, not the page-wide union npm run dev has to seed, so
there is no cross-widget field collision here. See the
divergences for why pre-parsed settings are
also a trap.
Hydrate mode
Set HR_SSR_HYDRATE=1 and the page additionally loads, in document order: the dehydrated
cache as window.HRWidget.__REACT_QUERY_STATE__, the runtime IIFE from runtimeUrl, a
config block setting HRWidgetRuntime.apiBaseUrl and pointing proxyBaseUrl at
window.location.origin, then the selected widget’s client IIFE — dist/{slug}.iife.js
for a single-widget project, dist/{widget}/{widget}.iife.js for a suite. Your mount()
finds the server markup, builds a shadow root, migrates the SSR content into it and hydrates
(Runtime, mount and the DOM contract). Because
proxyBaseUrl is the local server, CSS resolves through the local /p/ route, not the CDN.
Two things to know before you trust it. Hydrate mode is not offline — the runtime IIFE
comes from the asset origin, and with hydrate on and no runtimeUrl resolvable the page
prints an amber warning and shows the server HTML only. And client-side requests are not
mocked: the page registers mockServiceWorker.js directly, but MSW only intercepts for
clients that have sent it a MOCK_ACTIVATE message, which only startMockWorker() does,
and no handlers are registered in the page at all. Every fetch your component makes after
hydration goes to the real apiBaseUrl with the harness’s placeholder ids.
The server render is always resolvedTheme: "light" unless you pass branding. For a dark
one, add branding: { colorScheme: "dark" } to the mock option in
scripts/dev-ssr.mts — the harness derives the theme from nothing else.
What the preview genuinely reproduces
renderPluginSSR is a port of the renderer’s plugin-federation.ts loader, kept in the
SDK so it stays versioned with the contract. Faithfully reproduced:
- The same vm contract — your UMD is IIFE-wrapped and evaluated in a fresh
node:vmcontext, with the same “no default export” check and the same module-scope execution timeout (0.12.2+), whose value is in the limits index. - The same
requirewhitelist — all seven specifiers, including@homerunner-next/widget-core/runtime(0.12.2+) — refusing everything else with the same message. An import your build did not bundle fails here. - The same browser-global stubs for
window,document,self,navigator,location,localStorageandsessionStorage— inert proxies, not real objects. A top-levelwindow.fooread survives; a call that expects a real DOM does not. And the same absences: noTextEncoder, noTextDecoder, nocrypto(0.12.2+ removed the two encoders the harness used to seed and the renderer never had). - The same
process.envwhitelist (three keys), the same__HR_PLUGIN_ASSET_BASE__global for bundled assets (0.12.2+), and the same call order: evaluate →getInitialData→dehydrateState(freshQueryClient, retries off) →getStaticAssets→renderToString, samectxshape. Component and SSR module owns that contract. - The same
externalFetchenforcement (0.12.2+) — the widget’s declared hosts are read out ofmanifest.jsonand the sandboxfetchis wrapped in a port of the renderer’s allow list, so a call to an undeclared host fails here instead of first in review. An absent field declares nothing, exactly as in production. - The same hydration tree (0.12.2+) — the server render runs inside
WidgetHydrationTreewithidentifierPrefixset to the SSR id, and the preview envelope carries that id indata-hr-ssr-id, somount()hydrates with the same tree and the same prefix (Runtime, mount and the DOM contract). - Mocked server-side
fetch.msw/nodepatchesglobalThis.fetchand the sandbox reads through to it at call time. Unmatched requests are bypassed to the real network, not failed. - The same stylesheet fallback (0.12.3+) —
getStaticAssetsis optional, and the renderer serves the widget’s manifestassetswhen a bundle omits it. The harness takes the same branch: it links the widget’s declaredassets.css(or, absent one, the conventionaldist/name) when that file is actually indist/, links nothing when it is not, and defers togetStaticAssetsverbatim whenever the export is there — including when the export returns an empty list, which production also honours. The fallback reachesdata-hr-csstoo, so hydrate mode is styled as well. Below 0.12.3 there was no fallback, so a hook-less widget — the shape--template suitescaffolds — previewed with an empty<head>and no CSS in either mode.
Where the preview differs from production
Take this seriously: code can pass here and fail on the renderer, and the reverse.
Component and SSR module carries the summary
table; what follows is what each difference does to you. Everything here is measured
against widget-core 0.12.3 — five divergences that used to live on this list (the
missing /runtime shared module, seeded TextEncoder/TextDecoder, an undefined
__HR_PLUGIN_ASSET_BASE__, unenforced externalFetch, no execution timeout) were closed
in 0.12.2, and a sixth (no stylesheet for a widget without getStaticAssets) in 0.12.3.
On an older SDK they all still apply.
__HR_PLUGIN_ASSET_BASE__points at your laptop. It is defined, but ashttp://localhost:{port}/dist/— the renderer derives it from the bundle’s CDN URL ({origin}/{env}/{slug}/{version}/dist/). Bundled-asset URLs therefore have the right shape here and the wrong origin. Check the real ones against Keywords, assets and URLs.- The
externalFetchallow list has one local loophole. Private, loopback and metadata hosts are refused even when declared — except when yourapiBaseUrlis itself loopback, because a fully local rig’s whole world is loopback. Point the harness at a local Central and a private host you declared will succeed here and be blocked in production. - Options are pre-parsed. The scaffold seeds settings from
configZod.parse({}), so every field is present and correctly typed. Customers’ stored settings arrive raw. A component or data hook that skipsparseWidgetConfigcannot fail here and will fail there. Paste real settings JSON intomock.widgetSettingsto reproduce. - The envelope is close, not identical. It carries
data-hr-ssr-id— the part hydration depends on — but ships the props as adata-hr-ssr-propsattribute where production writes a page-level props registry, and it never emits a declarative shadow root. Server-only mode has no shadow root at all and links your stylesheet into the harness page’s own<head>, so:hostrules match nothing and your styles can bleed onto the harness chrome. None of that is a bug in your widget — and that last part is useful rather than misleading: it is the closest local view of the unisolated, document-level condition every server-rendered page puts your CSS in (Styling and theming). - Data is fixtures, not a feed. Unmatched requests bypass to the real network, and in hydrate mode the browser’s own fetches are not mocked at all (see above). A shape your fixtures get wrong is a shape you have not tested.
- There is no server-side slot composition. A layout renders its shell with empty
slots — the platform, not this harness, renders the bound children and passes them as
renderedSlots. See Suites: one widget at a time.
Suites: one widget at a time
A suite puts each widget’s bundle at dist/{widget}/{widget}-ssr.umd.js under the
namespaced keyword plugin:{slug}:{widget}, and createSSRDevServer resolves both from the
widget you name (widget-core 0.12.2+; the scaffold’s suite dev:ssr script needs
create-hr-plugin 0.8.1+):
# packages/create-hr-plugin/template-suite/scripts/dev-ssr.mts — what the script accepts
npm run build
npm run dev:ssr -- --widget intro-card # → http://localhost:3003
npm run dev:ssr -- --widget=page-frame # the layout shell, slots empty
HR_SSR_WIDGET=intro-card npm run dev:ssr # or set it in .env.local
Run it once per widget. There is no “preview the whole suite” mode, and there does not need to be: every SSR question except composition is per-widget.
The one thing you cannot preview is a composed page. renderPluginSSR builds the props
itself and passes no renderedSlots, so page-frame renders its shell with every slot
empty. That is still a useful smoke test — a layout that throws during render 500s the
whole customer page (Layout widgets) — but it is not a preview
of the composed result. npm run dev exercises the client half of layout composition
(Dev sandbox and mocking); the server half needs a real feed.
If you want a composed render locally, build it yourself, in two steps. renderPluginSSR
gives you each child’s HTML; evaluatePluginSSR gives you the layout’s module, which you
render with react-dom/server yourself, passing those children as your own renderedSlots.
Only the second step needs the hand-rolled call — renderPluginSSR builds its props
internally and has no renderedSlots field, so it can never do this. The two lookups the dev
server boots with are exported for exactly this (widget-core 0.12.2+):
// packages/homerunner-widget-core/src/testing/ssr.ts (renderPluginSSR, evaluatePluginSSR),
// src/testing/dev-server.ts (resolvePluginSSRTarget) and src/contracts.ts, which types
// `renderedSlots` as Record<string, ReactNode>. Save as scripts/ssr-compose.mts and run it
// with tsx after `npm run build`.
import fs from "node:fs";
import React from "react";
import { renderToString } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
renderPluginSSR,
evaluatePluginSSR,
resolvePluginSSRTarget,
createNodeMockServer,
} from "@homerunner-next/widget-core/testing";
import { setMockState } from "@homerunner-next/widget-core/mock";
const SLUG = "acme-suite";
const LAYOUT = "page-frame";
const FEED_ID = 1;
// Your own binding table. On a real page this comes from the customer's stored page
// schema; nothing here checks it, so a slot name your layout does not declare renders
// nothing at all.
const BINDINGS: Record<string, string[]> = { main: ["intro-card"] };
// Same lookup createSSRDevServer boots with: keyword, dist paths, declared hosts.
function resolve(widget: string) {
const { target, error } = resolvePluginSSRTarget({
rootDir: process.cwd(), slug: SLUG, widget,
});
if (!target) throw new Error(error); // CSR-only, undeclared, or no widgets[]
return target;
}
// 1. Render every slot child on its own — the full production order, per widget.
const renderedSlots: Record<string, React.ReactNode> = {};
for (const [slot, widgets] of Object.entries(BINDINGS)) {
const nodes: React.ReactNode[] = [];
for (const widget of widgets) {
const target = resolve(widget);
const mock = createNodeMockServer(target.keyword, {
widgetId: widget, feedId: FEED_ID, delayMs: 0,
});
setMockState({ scenario: "default" }); // never "loading" — that handler never resolves
const { html, ssrId } = await renderPluginSSR({
ssrCode: fs.readFileSync(`dist/${target.ssrFile}`, "utf-8"),
filename: target.ssrFile,
ctx: { options: {}, feedId: FEED_ID, widgetId: widget },
resolvedTheme: "light",
externalFetch: target.externalFetch,
});
mock.close();
// Each child reaches the layout as its own host element, not a bare string.
nodes.push(React.createElement("div", {
key: widget,
"data-hr-widget": target.keyword,
id: `${widget}:${FEED_ID}`,
"data-hr-ssr-id": ssrId,
dangerouslySetInnerHTML: { __html: html },
}));
}
renderedSlots[slot] = React.createElement(React.Fragment, null, ...nodes);
}
// 2. Evaluate the LAYOUT in the same sandbox and render the shell yourself.
const layout = resolve(LAYOUT);
const mod = evaluatePluginSSR(
fs.readFileSync(`dist/${layout.ssrFile}`, "utf-8"),
"https://beta.homerunner.io",
"https://assets-dev.homerunner.io",
{ filename: layout.ssrFile, externalFetch: layout.externalFetch, pluginAssetBase: "" },
);
const ctx = { options: {}, feedId: FEED_ID, widgetId: LAYOUT };
const layoutMock = createNodeMockServer(layout.keyword, {
widgetId: LAYOUT, feedId: FEED_ID, delayMs: 0,
});
setMockState({ scenario: "default" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const data = mod.getInitialData ? await mod.getInitialData(ctx) : undefined;
await mod.dehydrateState?.(queryClient, ctx);
// No WidgetHydrationTree and no identifierPrefix here, deliberately: on a server-rendered
// page the shell is static light DOM whose mount is a no-op, so there is no hydration to
// line up with (Layout widgets covers that).
console.log(renderToString(
React.createElement(QueryClientProvider, { client: queryClient },
React.createElement(mod.default, { ...ctx, resolvedTheme: "light", data, renderedSlots }),
),
));
layoutMock.close();
Every divergence above still applies, and the settings are whatever you pass in ctx.options
— {} is not what a customer sends. Three things this deliberately does not reproduce: the
keys a layout pushes down onto each child, and the Declarative Shadow DOM template and
props-registry entry every real slot child gets
(Layout widgets).
An SSR checklist
Run before you zip — Preflight and submit has the rest.
A content widget with an SSR bundle — Both. Every scenario except loading renders
non-empty markup; error paints your error state rather than throwing. The inspector’s
Props panel shows the options you expect, and the dehydrated cache is non-empty if you
export dehydrateState. Nothing in the terminal says window is not defined. Fetch
/__render.json?scenario=default twice and diff: any difference means a non-deterministic
render (a clock read, a random value), which costs you the SSR DOM on hydration.
A CSR-only widget ("ssr": false) — Suite. Nothing to preview and no ssr-entry.ts to
build; naming it exits 1 and that is the expected outcome. Verify it in
the sandbox instead, and set expectedHeight
(Manifest: widget summary) so the empty server host
reserves the right space.
A layout widget — Suite. SSR is mandatory, and --widget {layout} previews the shell
with empty slots — enough to prove it evaluates, renders and does not throw, which is the
failure that 500s a whole customer page. It is not a preview of the composed result. Give
every slot and every config field a default, keep the shell non-interactive, and
class-namespace the CSS: on a server-rendered page the shell is light DOM and its
stylesheet is document-level.
Seeing what the server actually did on a real page
Once your plugin is live on a feed, three signals answer “which layer served this, and when”.
Development mode turns off caching for one feed. The feed owner enables it from the
dashboard for a time-boxed window; the Cloudflare worker then appends ?dev=1 to its
render request, and the renderer serves fresh, writes nothing to cache and returns
Cache-Control: no-store… plus X-HR-Dev-Mode: 1. Ask for it while you iterate —
otherwise you are looking at a cached page and cannot tell a stale render from a broken
one. The sandbox’s server-side GET cache is bypassed on those renders too (still enforced),
per the limits index.
The stamp. Every fresh render appends an HTML comment to the compiled page —
<!-- hr-render page=… build=… manifest-age=…s rendered-at=<ISO> key=… -->. View-source
and read the last one; a page-level stamp follows every widget. A cached page
deliberately keeps the stamp of the render that produced it, so an old rendered-at is
the staleness evidence.
The headers, on the render API response (the JSON the worker fetches, not the customer
page): x-hr-render-cache is hit-fast, hit, miss or bypass (bypass = development
mode), x-hr-render-key is the compiled-page key, and x-hr-render-build /
x-hr-render-at are read out of the stamp inside the payload being served — so on a hit
they describe the original render. curl -sD - -o /dev/null answers all four.
If a widget is missing from the page entirely, search the source for
data-hr-widget-error: the renderer leaves a hidden node carrying an HTML-comment reason
where the widget should have been. Troubleshooting maps those
reasons to causes.
Related
- Dev sandbox and mocking — scenarios, fixtures, the dev panel
- Component and SSR module — the sandbox contract
- Runtime, mount and the DOM contract — hydration and shadow DOM
- SDK reference —
/testingsignatures - Limits and error index — every bound and message quoted here
Preflight and submit
Your plugin passes three gates. You watch the first two happen; the third runs in a reviewer’s browser, days later, against a build you never see — and a failure there costs you a resubmission with a bumped version. So the work on this page is front-loading gate three into your own terminal before you zip anything.
| Gate | Runs | You see it |
|---|---|---|
| Source-zip audit | In your browser, before a single byte is uploaded | Immediately |
| Central’s submit API | After the bytes are in the bucket | As a coded error in the uploader |
| Built-zip audit | In the reviewer’s browser, at publish, on their build of your source | Never |
The three gates and every message they can emit are indexed in Limits and error index; the rules themselves are in Packaging and publishing rules. This page is the checklist, the zip, the upload, and what happens after.
The preflight checklist
Every item names the gate it satisfies. Items marked gate 3 are the expensive ones: they pass your submission, pass review, and fail at publish.
Every plugin — Both shapes
- The build succeeds from a clean checkout of what you committed, not just from your
working tree —
git clone . /tmp/preflight && cd /tmp/preflight && npm ci && npm run build. That is exactly what the reviewer does, and it is the only way to catch a source file you forgot to commit. (npm run buildclearsdist/itself, so a stale artefact cannot survive it.) (gate 3) -
dist/manifest.jsonexists and lists a hashed twin for every asset your manifest declares, with that twin present on disk. A missing twin means the tree was edited after the build — rebuild, never patch it. (gate 3) -
manifest.jsonis dirty in git — commit it. The build rewrites your tracked manifest twice: schema injection, then theruntime.widgetCorestamp. That stamp is the evergreen-runtime gate and must never be hand-written. (gate 3) - Nothing under
dist/is more than one directory deep.dist/{widget}/chunks/x.jsis dropped silently at publish — no error, no warning, a 404 at runtime. Keep the SDK’s output layout. (gate 3, silent) - The version is bumped — three plain numeric parts, strictly greater than the version that is live, and no pre-release suffix. Publishing, versions and rollback explains what a version number costs you and why a rejected one is not spent. (gate 2)
- Every
media/file your manifest declares is committed. Nothing generatesmedia/— not your build, not the reviewer’s. A declared icon, cover, screenshot or README that is missing from your source fails the publish. (gate 3) - The shape has not changed. If your plugin is already live, it must stay single-widget or stay a suite. This one is not checked at submit or at review — see The two manifest shapes. (publish only)
- The zip carries no
dist/,node_modules/or.git/, andpackage-lock.jsonis committed so the reviewer’s install resolves what you tested. (gate 1; the lockfile is a warning)
Single-widget plugins only
-
widgetType,ssr.urlandassets.jsare present at the manifest root. (gate 1) - The root
manifest.jsoncarries aconfigSchema— that is yourbuild:manifeststep’s output, and without it the customer’s settings form is empty. (gate 3, warning only)
Suites only (widget-core 0.11.0+)
- Every
widgets[].manifestsub-manifest exists at the path you declared, is committed, and contains a non-emptyconfigSchema. A declared-but-absent sub-manifest is a hard publish failure; a schemaless one is a silent empty form. (gate 3) - Widget slugs are unique, and none of them is
dist,widgetsormedia. (gate 1) - Per-widget
iconandreadmefiles exist undermedia/. (gate 3) - Every
presets[]entry names a declared layout and children that are either your own content widgets or bare system keywords. (gate 1)
By widget kind
- A content widget with SSR — Both.
ssr-entry.tsexists and re-exports the component, andnpm run dev:ssrrenders every scenario withoutwindow is not defined, with two renders of the same scenario byte-identical. Suite: a suite has no default widget, so name one —npm run dev:ssr -- --widget {slug}(widget-core 0.12.2+,create-hr-plugin0.8.1+) — and run it once per server-rendering widget. Previewing SSR locally. - A CSR-only widget — Suite. The summary says exactly
"ssr": false, there is nossr-entry.tsfor it, andexpectedHeightis set so its empty server-rendered host reserves the right space. Verify it in the dev sandbox. - A layout — Suite. Walk the layout checklist in
Layout widgets — an SSR bundle is mandatory,
the
slotszod field must exist, and the CSS must be class-namespaced because the shell renders as light DOM.npm run dev:ssr -- --widget {layout}previews the shell with every slot empty — enough to prove it evaluates and does not throw, which is the failure that 500s a whole customer page, but the composed page has no local preview (Previewing SSR locally).
Run the reviewer’s gate yourself
Not supported yet. You cannot run the built-zip audit, and its warnings are never forwarded to you. Unless a reviewer copies them into review notes, you will not learn that your plugin shipped with an empty settings form.
This script reproduces the gate-3 checks that depend only on your build. Run it from the
project root after npm run build. No FAIL: line means clean.
# Reproduces the publish gates in src/lib/plugin-zip.ts (auditBuildZip) that you can check
# locally: declared assets, hashed twins, the HRPlugins marker, base-schema extension and
# declared media. Verified against hr-plugins/pdp-suite 1.3.3, which passes silently.
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("manifest.json", "utf8"));
const twins = JSON.parse(fs.readFileSync("dist/manifest.json", "utf8")).files;
const say = (...a) => console.log("FAIL:", ...a);
const media = [m.icon, m.cover, m.readme, ...(m.screenshots ?? [])];
for (const w of m.widgets ?? [{ slug: m.id, ...m }]) {
media.push(w.icon, w.readme);
for (const p of [w.assets?.js, w.assets?.css, w.ssr && w.ssr.url].filter(Boolean)) {
const key = p.replace(/^.*?dist\//, "");
if (!fs.existsSync(p)) say("declared asset not built:", p);
else if (!twins[key]) say("no hashed twin for", key);
else if (!fs.existsSync("dist/" + twins[key])) say("twin missing:", twins[key]);
}
if (w.assets?.js && fs.existsSync(w.assets.js) &&
!fs.readFileSync(w.assets.js, "utf8").includes("HRPlugins"))
say("never registers on window.HRPlugins:", w.assets.js);
const sub = w.manifest ? JSON.parse(fs.readFileSync(w.manifest, "utf8")) : m;
const props = sub.configSchema?.properties ?? {};
if (!props.colorScheme || !props.spacing)
say("configSchema does not extend widgetSchema:", w.manifest ?? "manifest.json");
}
for (const ref of media)
if (typeof ref === "string" && ref.startsWith("media/") && !fs.existsSync(ref))
say("declared media missing:", ref);
console.log("preflight complete —", m.id + "@" + m.version, "widgetCore", m.runtime?.widgetCore);
'
It does not cover the size thresholds. Check those by eye against the built-zip limits in Packaging and publishing rules — the one worth acting on is the SSR bundle, past which the renderer stops caching your bundle and every cold server render pays the load cost.
Zip it
Zip the source, not the build. Two forms both pass the audit; the first is better.
# Verified: auditPluginZip (src/lib/plugin-zip.ts) returns ok with 0 errors and 0 warnings
# for both commands, run against hr-plugins/pdp-suite 1.3.3.
# Preferred — archives exactly what is committed, at HEAD.
git archive --format=zip -o ../my-plugin-1.0.0-source.zip HEAD
# Or, from the project directory, if you are not using git.
zip -qr ../my-plugin-1.0.0-source.zip . \
-x 'node_modules/*' 'dist/*' '.git/*' '*/.DS_Store'
git archive is the better default for one reason: it can only include committed files, so
it enforces the rule that bites people after approval. If media/ or widgets/ are
gitignored, they vanish from the zip and you find out now instead of at publish. The
trade-off is the same property in reverse — uncommitted local fixes are silently left out, so
commit first and archive second.
zip -r honours no ignore file. It will happily package .env.local, and a human reads your
source. Delete your secrets or use git archive.
Two shapes of archive are accepted: files at the zip root, or everything inside a single
wrapper folder (what macOS Finder’s Compress produces). __MACOSX/ entries and
.DS_Store files are ignored. Zip64 archives are rejected outright, so use zip -r, Finder,
or your platform’s archiver rather than a tool that forces zip64.
Upload it
Dashboard ▸ Plugins ▸ My plugins ▸ Upload plugin. Drag the zip onto the drop zone or click to choose one.
Nothing is transmitted while the audit runs. On failure you get a list of exactly what is
wrong and no upload happens. On success you get a green summary card — plugin name, version,
slug, and either the widget list or the widgetType, with any warnings in amber underneath.
Read the widget list. It is parsed from the manifest.json inside the zip you just
selected, so it is the cheapest check that you zipped the tree you think you did: a widget
you added but do not see there is a widget the archived manifest does not declare.
Then the optional notes box, then Submit for review. Use the notes: they are the only channel you have to the reviewer, and review notes are the only channel back.
Checked in your browser, before anything transmits
The archive’s structure and entry rules, its size, entry count and compression ratio, the
presence and parseability of manifest.json and package.json, and the shape of the
manifest — required root fields, the id slug pattern, the version semver pattern, every
widgets[] summary and every preset.
Two of the uploader’s own pre-checks fire before the audit even reads the archive:
Please choose a .zip file. and The zip is over the 20 MB limit.
Then, still before the bytes move, the dashboard mints a presigned upload URL and makes a best-effort ownership check — so a slug that belongs to someone else can stop you here rather than after the transfer.
Checked after the bytes are in the bucket
Your browser cannot know these; only central can. Each is a coded 4xx surfaced in the uploader, with the exact messages in Packaging and publishing rules.
| Code | What it means for you |
|---|---|
SLUG_TAKEN | Another author owns this manifest.id. Pick a different slug. |
SLUG_RESERVED | The slug exists with no owner — a legacy admin-registered row. Ask HomeRunner to hand it over. |
VERSION_NOT_GREATER | Not strictly greater than your live version. The browser only checks the version’s shape, never its ordering. |
INVALID_VERSION | Central demands exactly three numeric parts, so 1.0 and 1 are refused here even though a looser parser accepts them. |
INVALID_MANIFEST | A server-side manifest rule the browser does not apply — length caps on id, description and author, plus the ceiling on how many widgets one plugin may declare. (the browser checks the version length first, so you should not reach Central with that one.) |
VALIDATION_ERROR | The submission envelope itself: a bad checksum, a size outside the allowed range, or notes over the length cap. |
INVALID_ZIP_KEY | The upload key does not sit under your slug’s folder. The dashboard mints that key, so you should never see this. |
Before the metadata call, the dashboard checks the bucket itself: the object must be there, its size must be the one you submitted, and the SHA-256 the bucket stored from your signed upload must match. A mismatch is refused with both values in the message. If storage reports no checksum at all, the submission is refused rather than registered on trust — the presigned URL is not remembered between the two calls, so the bucket’s stored checksum is the only thing tying the hash you register to the bytes you uploaded.
If the bucket’s copy does not match, or Central refuses the registration, the object you just uploaded is deleted again. Two cases deliberately delete nothing: if the dashboard cannot reach storage — or storage will not say what it holds — it registers nothing and removes nothing, because it will not destroy an object it could not inspect; and a key that was not minted for you is refused before Central is told anything. In both cases the bytes you already uploaded stay in the bucket, but you cannot reach them: retrying means choosing the zip again, which mints a fresh key and uploads from scratch, and the preserved object is cleaned up on our side. Only the object your own upload created is ever deleted — the upload key names the account it was minted for.
After you upload
Your plugin row now shows a pending badge and a timeline. A reviewer downloads your zip, verifies its checksum against the one your browser recorded, and reads your source.
The three outcomes
| Outcome | Notes | What it means for you |
|---|---|---|
| approved | optional | Nothing is live yet. Your submission is waiting for a publish, and public-versus-private visibility was fixed in the approve dialog. |
| changes_requested | required | The notes appear on your row, followed by “— fix and re-submit the zip above.” |
| rejected | required | Same, and your uploaded zip is deleted from the bucket. |
The five timelines you can be in — pending, changes_requested, rejected, approved and published — are rendered in From your laptop to a customer page, along with who is blocked in each.
What the reviewer is looking at
For a first submission, your source. For an update, the dashboard also diffs the live
manifest against yours and shows: config option keys added or removed (base keys excluded),
a changed description, a changed widgetType, widgets added or removed, and — per widget
present in both versions — changes to category, slot names, pages, SSR on/off,
deprecated and declared fonts, plus icon and cover changes.
Per-widget configSchema diffs are not in that list. Sub-manifests only exist as built
artifacts, so they are diffed later, at publish, against the live version’s copies on the
CDN. An option key you quietly removed is reviewed after the human decision, not before it.
Nobody will tell you
Not supported yet. Nothing notifies you of a review outcome, or of a publish. The only channel is opening Dashboard ▸ Plugins ▸ My plugins and reading the badge, the timeline and the review notes yourself — so poll it, and do not build a release process that waits for a message. The gap is described in full in From your laptop to a customer page.
Re-submitting
Uploading a new zip for the same slug replaces whatever is in flight, from any state — including approved-but-not-yet-published. The status returns to pending and the reviewer, notes and timestamp are cleared.
Your published version is never touched by this. It keeps serving throughout review, rejection and resubmission; only a publish or a rollback changes what customers load. So if an approved submission has not shipped yet and you spot a bug, just submit the fix.
Related
- Packaging and publishing rules — every zip rule, audit gate and verbatim message
- From your laptop to a customer page — the whole pipeline and who acts at each step
- Publishing, versions and rollback — what happens once a reviewer publishes your build
- Install, customers and kill switches — what a customer does with your plugin next
- Limits and error index — paste a message here
- Troubleshooting — symptom-first index
Publishing, versions and rollback
Publishing is the only event that changes what a customer’s page loads. Review does not. Approval does not. Uploading a built zip does not — not until central repoints its row at the new version prefix. Everything before that moment is preparation; everything after it is cache propagation.
This page covers that moment: what the publish transaction writes, how the change reaches pages that are already cached, and how to undo it. The pipeline that leads up to it is From your laptop to a customer page; the rules your zip must satisfy are Packaging and publishing rules.
What publishing does
An administrator drives four legs, in order. Only the third changes anything a customer sees.
- Upload. The built zip’s publishable files are PUT one at a time into the immutable
key prefix
{env}/{slug}/{version}/, rootmanifest.jsonlast. Until that last object exists, nothing is live and the same zip can be retried safely. - Verify. The dashboard fetches the published
manifest.jsonback from its public URL with a cache-buster and refuses to continue unless it serves and itsid/versionmatch the submission. “Published” means observed serving, not uploaded. - Repoint. Central records the verified URL and manifest in one database transaction. This is the switch.
- Propagate. The dashboard warms the renderer and purges cached pages. Best-effort — see How an update reaches a customer.
The repoint writes exactly this, and each field has a consequence for you:
| What central writes | Read from | What it changes for you |
|---|---|---|
manifest_url, current_version | The verified URL | Every new render resolves your keywords against the new version prefix |
manifest_cache | The manifest itself | The widget picker, per-widget summaries, slots, presets, fonts |
name, description, author.name, author.url | The manifest’s root fields | Your marketplace listing text — it only ever updates at publish |
status = approved | Fixed | A suspended plugin is un-suspended by publishing a fix |
submission_status = null | Fixed | Your submission is cleared; the stepper reads Published |
published_at, build_meta | Now, and the build hash + file list | Forensics only; no serving path reads build_meta |
Three things publishing does not do, and all three surprise people:
- It does not re-fetch anything later. Central serves its stored copy of your root manifest to every consumer. There is no manifest-refresh step you or anyone else has to trigger — the repoint is the refresh.
- It does not touch customer data. Widget settings live on the customer’s widget row, not in your plugin. Nothing is migrated, defaulted or rewritten on their behalf. See Settings and options.
- It does not delete the old version. The previous prefix stays on the CDN, byte for byte, which is what makes both cached pages and rollback safe.
Your own confirmation is the row in Dashboard ▸ Plugins ▸ My plugins: the badge flips to
live v{version} and a Published manifest link appears, pointing at the exact URL
central recorded. Nothing emails you — see the notification gap in
From your laptop to a customer page.
Version rules, in practice
(Both shapes.) One version number covers the entire plugin. A suite of twelve widgets has one version, ships as one review and one publish, and cannot release a widget on its own.
The three hard rules — three-part MAJOR.MINOR.PATCH, strictly greater than the published
version, and never re-publishable once it exists on the CDN — are stated with their exact
messages in
Packaging and publishing rules. Two practical
consequences are worth spelling out here:
- A version number is spent the moment it publishes. There is no re-publish. A one-line
CSS fix to
1.2.0is a submission of1.2.1, with a full review. - A version number is not spent by being rejected. The greater-than check compares
against the version that is live, never against your last submission. If
1.4.0came back aschanges_requested, resubmit the fix as1.4.0again.
Pick numbers that describe the config schema, because that is the only part of your plugin a customer’s stored data depends on: patch for fixes, minor for additive optional fields and new widgets, major when you break or remove a field. Nothing enforces this — advisory (unvalidated) — but see Evolving a plugin for what actually breaks.
Not supported yet. Pre-release versions such as
1.0.0-rc.1pass the browser audit, pass central, and publish — and then silently lose every immutability optimisation. Each detector (the proxy’s, the renderer’s, the SSR bundle cache’s) requires a bareX.Y.Zpath segment, so a pre-release prefix falls back to proxied asset URLs and short-TTL caching everywhere. Nothing warns anyone. Ship plainMAJOR.MINOR.PATCHversions. See Keywords, assets and URLs.
How an update reaches a customer
Customer pages are composed once and cached hard, so the platform does not wait for TTLs to expire. Publishing (and rollback, and every kill-switch change) pushes:
// Source: src/app/(protected)/plugins/cache-propagation.ts
central repoint ──▶ warm POST {renderer}/api/cache/warm-plugin { slug }
│ loads EVERY widget's SSR bundle for the slug; the
│ slug-keyed Redis entry it writes overwrites the
│ previous version's — warm and eviction are one call
│
└──▶ purge GET /plugins/{id}/feeds (feeds with the plugin ENABLED)
per feed: renderer Redis page keys + the edge KV page
cache, then one re-render of that feed's listings page
concurrency 4 · 30 s in-request · 150 s after the response
Neither leg can fail the publish. If the warm fails — a broken SSR bundle returns a 502 and stops the loop — the release still goes live and you find out on the first customer render. If the purge budget runs out, the feeds it did not reach fall back to their page TTLs.
What holds a copy of your old build
| Layer | Holds | What releases it |
|---|---|---|
| Browser, hashed asset URL | One immutable file | Nothing. A new version is a new filename. |
/p/ proxy pointer | Which plugin/version a logical filename maps to | 60 s TTL — see Keywords, assets and URLs |
| Renderer, evaluated SSR module | Your executed bundle, in process | A versioned URL never expires in process; a new version is a different URL |
| Renderer, Redis bundle text | Your bundle’s source | The publish warm overwrites the slug-keyed entry; a 30-day TTL is only a backstop |
| Renderer, compiled page | The customer’s rendered HTML | The per-feed purge, else 24 h |
| Edge KV, composed page + renderer payload | The customer’s served HTML | The per-feed purge, else the page TTL |
The page TTL is 24 h on property-detail, checkout and confirmation pages, and 300 s on listings and collection pages, with stale-while-revalidate on top: past the fresh window one request is served the old copy while a refresh runs behind it. So the honest bounds are seconds when the fan-out reaches a feed, and up to 24 h for a detail page on a feed it did not. Every number here is also indexed in Limits and error index.
The old guide claimed a “~5 minute worst case”. That was the legacy SSR-bundle TTL, which no longer applies to pipeline-published plugins at all. The real bound is the composed-page TTL above.
A stale page is stale consistently
On server-rendered pages the renderer writes direct content-addressed CDN URLs into the HTML, so a cached page keeps loading the exact bundles its HTML was rendered with — from the old prefix, which still exists. There is no window in which new markup meets an old bundle, or vice versa. That is the whole reason old prefixes are never overwritten.
It is also why deleting an old version prefix is the one operation that can break a live page. The dashboard refuses to delete the live version outright, and its bulk prune keeps the live version plus the newest previous one; anything older can still be referenced by a page cached within the last day.
What the customer does
Nothing. There is no per-feed version pinning: central holds one current-version pointer per plugin, and every feed, widget instance, SSR render and CSR embed follows it. Customers cannot stay on an old version, and cannot opt into a new one early.
The only visible change on their side is the config panel. A suite’s per-widget schemas
live at widgets/{slug}.manifest.json under the version prefix, so a new version is a new
URL and the dashboard picks up your new form on the next page load. (Suite.)
Rolling back
Rollback is the first-line fix for a bad release, and it is a single administrator action —
not a new review. On the admin plugin page, Versions on the CDN lists every
{env}/{slug}/{version}/ prefix R2 still holds (R2, not central, is the record of what was
ever published) with its file count, size, publish time and runtime.widgetCore stamp.
Any non-live row with a root manifest offers Roll back to this version.
Central then re-verifies the older build before trusting it — it re-fetches that manifest itself over https from a public address with no redirects, and re-applies the manifest validator, the id/version match, the frozen-shape gate and the widget-core floor (widget-core 0.10.0+). The refusals are indexed in Limits and error index; the ones you can actually cause are:
| You will hit | When |
|---|---|
ROLLBACK_MANIFEST_UNREACHABLE | The prefix was pruned from the CDN. Rollback needs the files to still be there. |
MANIFEST_WIDGET_CORE_MISSING / _TOO_OLD | The target predates the evergreen runtime floor. Old releases can age out of being rollback targets. |
MANIFEST_SHAPE_CHANGED | The target is the other manifest shape — see The two manifest shapes. |
ALREADY_LIVE | It is already the live version. |
What rollback changes, and what it deliberately leaves alone:
- Changes:
manifest_url,current_version,manifest_cache. Then the same warm and purge fan-out as a publish, so it lands in seconds. - Leaves alone:
status— rolling back does not un-suspend a suspended plugin, and cannot be used as a kill switch. Use suspension for that (Install, customers and kill switches). - Leaves alone: any in-flight submission. A fix you have already submitted keeps its place in review while the rollback protects customers.
- Leaves alone: customer settings, again. Which is where rollback can still hurt you: if the bad version added a widget or a required config field, rolling back makes those placements resolve against a manifest that no longer declares them, and they fail closed — see the resolution table in The two manifest shapes.
Rolling forward instead — submitting 1.4.1 — is the right move when the breakage is
small, the review queue is fast, or the bad version introduced a widget customers have
already placed. Rollback is right when the breakage is on a customer’s live page now.
Evolving a plugin without breaking customers
Four rules cover almost every release.
1. The manifest shape is frozen at your first publish. (Both shapes.) A live plugin can never move between the single-widget shape and the suite shape; the escape hatch is a new plugin slug, not a new version. Because a suite may contain one widget, starting there costs nothing — The two manifest shapes.
2. Deprecate a widget; never delete its slug. (Suite.) Setting deprecated: true on
a summary removes it from the picker while every existing placement keeps rendering
exactly as before. Deleting the entry instead strands every customer widget that stores
that keyword: the keyword stops resolving and the widget fails closed on their page. The
field’s full behaviour is in
Manifest: widget summary. Note this is not a kill
switch — a deprecated widget’s assets keep serving.
3. Treat your config schema as a stored data format. Nothing migrates settings, so what you do to a field decides what happens to the value a customer already saved:
| Schema change | What happens to existing widgets |
|---|---|
Add a field with .default() | Safe. The key is missing from storage, so parseWidgetConfig supplies the default at render. |
Change an existing .default() | Invisible to existing widgets. Their value was written at creation and stays. |
| Rename a field | The old value is stranded under the old key. Read the old key off raw props.options and fall back, for at least one release. |
| Remove a field | The value stays in storage and is dropped from your parsed config. Harmless. |
Tighten validation (.min(), a narrower enum, a type change) | Dangerous. A stored value that no longer parses makes parseWidgetConfig throw, which takes the widget down on a live page. Widen instead, or accept both shapes and normalise. |
Nothing validates stored settings at any layer — advisory (unvalidated) — so your own parse is the only gate, and it runs on the customer’s page. See Settings and options and Config schema and UI schema.
4. Adding is cheap; renaming is not. New widgets, new slots and new presets appear the moment you publish and affect nobody who has not used them. Slugs, slot names and config keys are the identifiers customers’ stored rows point at — change one and you have made a breaking release whatever the version number says.
Checking a release yourself
The dashboard already verified the manifest before recording it, so this is for answering “is what I think I shipped what is live?”.
# Source: src/app/(protected)/plugins/actions.ts — the same three facts the publish checks.
# Take the URL from the "Published manifest" link on your row in Plugins ▸ My plugins;
# the {env} segment (prod | dev | local) is never optional.
BASE=https://plugins.homerunner.io/prod/acme-weather/1.4.0
curl -s "$BASE/manifest.json?v=$(date +%s)" \
| jq '{id, version, core: .runtime.widgetCore, widgets: [.widgets[]?.slug]}'
curl -s "$BASE/dist/manifest.json" | jq '.files | keys'
If the manifest is right but a customer page still shows the old build, it is cache, not
publishing: append ?dev=1 to the page to bypass every plugin cache and render fresh, and
read the x-hr-render-* headers to see which layer answered. Both are covered in
Previewing SSR locally and
Troubleshooting. The URL layout itself, and what each object’s
cache header is, belong to
Keywords, assets and URLs.
Related
- Preflight and submit — getting to an approved submission.
- Install, customers and kill switches — suspension, per-widget kill switches, and what a customer can switch off.
- Packaging and publishing rules — version rules, the immutability refusal and both audits, verbatim.
- Limits and error index — every number and message on this page.
- From your laptop to a customer page — the whole pipeline in one diagram.
Install, customers and kill switches
Publishing puts your plugin in a catalogue. It does not put it on a page. A customer has to find it in their feed’s Marketplace, install it, activate it and create a widget from it — and they can undo every one of those steps. Above them sit two switches HomeRunner controls and your customer cannot.
You drive none of this. There is no author-facing API for installing, activating or suspending, and nothing notifies you when any of it changes (From your laptop to a customer page). Your only levers are shipping a new version and asking for a rollback (Publishing, versions and rollback).
Who can see your plugin
A plugin appears in a customer’s catalogue when it is status = approved and either
visibility = public or granted to their customer account. Grants are account-level on
purpose: granting one account gives its whole team the plugin. You always see your own rows at
any status — that is your submission dashboard, not the marketplace.
Visibility is chosen by the reviewer in the approve dialog (Public — every account can enable it / Private — only granted customer accounts) and an administrator can flip it, or add and
remove grants, at any time afterwards. You cannot set or read it yourself.
There is no global plugin browser. Customers reach plugins only from Feed → Plugins, which has two tabs: Installed and Marketplace. Authors get Dashboard → Plugins → My plugins and nothing else. So “your plugin is live” always means live for a feed.
Two consequences that surprise authors:
- The catalogue row a customer receives is already kill-switch filtered. Central replaces
manifest_cachewith the withheld-widget version for anyone who is not the owner or an admin, and hides every pipeline column (review notes, zip pointer, submitted manifest). You, the owner, get the raw row — so a widget HomeRunner has disabled is visible to you and simply absent for them. Debug against a second account, not your own. - The install count is an active count.
active_installs, shown on the marketplace card and in the plugin-page sidebar, counts feeds where the plugin is currently enabled. A customer who deactivates without uninstalling silently decrements it.
What a customer actually does
| Step | Where | What it changes | What your code sees |
|---|---|---|---|
| Install | Marketplace card → Install | Creates the (feed, plugin) row with enabled = true | Your widgets appear in that feed’s picker |
| Activate / Deactivate | Installed tab, or the plugin page | Flips enabled only | Deactivating stops every instance rendering |
| Per-widget toggles | Plugin page → Widgets tab, one switch per widget | The feed’s own withheld-slug list | Suite only; see the kill-switch table |
| Configure | Installed row → Configure | Per-feed plugin config JSON | Nothing — see the gap below |
| Create widget | Feed → Widgets → Create New Widget | A widget row storing your keyword | Your component renders |
| Uninstall | Installed row → Uninstall | Deletes the row: config, sort order and toggles go with it | Guarded — see below |
Install and Activate are the same call. Installing already activates; the Marketplace button
reads Install when there is no row and Activate when there is a disabled one. The upsert is
idempotent and preserves an existing row’s stored config and sort order, so re-activating
restores whatever the customer had. Central refuses the call with PLUGIN_NOT_APPROVED for a
plugin that is not live and PLUGIN_NOT_AVAILABLE (403) for an approved plugin the account is
not entitled to.
Deactivate is the customer’s own whole-plugin switch. It keeps the row, the config and the per-widget toggles, and it is not a delete — but the public widget list only attaches your manifest for a plugin that is approved and enabled on that feed, so on a deactivated plugin every widget instance stops resolving and renders the same hidden failure breadcrumb a suspension produces (The two manifest shapes). The rows survive; reactivating brings them all back.
Uninstall is guarded. Central refuses with 409 PLUGIN_IN_USE while any widget built from
your plugin still exists on the feed, and returns the count in details.widget_count
(Limits and error index). The dialog then switches to a
guided path: it lists those widgets with their keywords, makes the customer type your slug to
confirm, deletes them one at a time and only then uninstalls. There is no silent cascade, and
paused widgets count too.
Not supported yet. Per-feed plugin config (
Configure) is offered only when the root manifest carries aconfigSchema— a v1 field a suite must not have (Manifest: root fields), so for a suite the button never appears. And nothing on any render path delivers the stored value to your component: it is returned byGET /{feed_id}/feed/pluginsand read by nothing else (Public API). Treat widget settings as the only configuration surface (Settings and options).
Creating a widget from your plugin
The Create New Widget picker is fed by the public feed-plugin list, so it already has every switch applied: approved, enabled on the feed, and both kill switches subtracted. What is left becomes cards (Both):
- Single-widget: exactly one card, keyword
plugin:{slug}. - Suite: one card per
widgets[]entry that is notdeprecated, keywordplugin:{slug}:{widget}, plus one card per survivingpresets[]entry listed ahead of them.
Icons resolve widgets[].icon → the plugin’s root icon → a glyph, and a URL that fails to
load falls back to the glyph too. The glyph is a Layers icon for a layout and a Puzzle
piece for a content widget — the same pair the plugin page uses. Your plugin’s own avatar falls
back to a slug-coloured letter tile, and the cover band to a slug gradient, so an icon-less
plugin still looks deliberate. The picker, presets and slot editor are covered end to end in
Composing a page.
Your plugin page carries the same shortcut: a Create widget button per widget, enabled only
when the plugin is installed and activated on this feed, the widget is not withheld by
either kill switch, and the summary is not deprecated.
What your plugin page shows
Feed → Plugins → any plugin name opens a full page — the customer’s whole impression of your
work, built from media/. It opens for installed and not-yet-installed plugins.
| Surface | Source | Notes |
|---|---|---|
| Cover band + icon + author + version | root cover, icon, author, current_version | Falls back to a slug gradient and letter tile |
| Description tab | description, then root readme | With no README: This plugin ships no long description. |
| Widgets tab | widgets[], each with widgets[].readme | Badges: category, SSR/CSR only, deprecated; layouts also list their slots, prefills, accepts and pages |
| Screenshots tab | screenshots | Tab is hidden when empty; click opens a lightbox |
| Changelog tab | media/CHANGELOG.md | Undeclared — shipped by convention, tab hidden when absent |
| Sidebar | version, published_at, runtime.widgetCore, installs, widget names, homepage/support/docs, license, tags | Links render only when they match ^https?:// |
Every markdown file is fetched server-side from your immutable version prefix, size-capped
again on read, and rendered through a sanitiser — no raw HTML, no scripts. Because they resolve
against manifest_url, nothing under media/ renders until your first publish: absolute
icon/cover URLs show up in review, media/ paths do not, and screenshots (which only
accept media/ paths) never appear before then
(Manifest: root fields).
Not supported yet. A single-widget plugin gets a synthetic card on the Widgets tab — plugin icon, name,
contentbadge,plugin:{slug}keyword, description and a Create widget button — but no per-widget README, no SSR/CSR badge, no instance count, no per-feed toggle and no count in the tab label. Every one of those is suite-only (The two manifest shapes).
The three kill switches
Three switches can withhold your code, at three scopes. They compose by subtraction only: each one narrows the manifest the one below it starts from, so nothing a customer does can restore something the platform withheld.
// Source: homerunner-central/app/Models/Plugin.php:145-172 (publicManifest,
// publicManifestForFeed) + app/PublicApi/V1/Controllers/WidgetController.php:64-71
manifest_cache the published manifest
└─ status must be `approved` (1) suspension — else nothing resolves at all
└─ minus plugins.disabled_widgets (2) platform per-widget
└─ minus feed_plugins.disabled_widgets (3) per-feed, on top
└─ requires feed_plugins.enabled the customer's Deactivate
| # | Switch | Scope | Who | Effect on assets | Effect on SSR | Effect on the picker |
|---|---|---|---|---|---|---|
| 1 | status = suspended | The whole plugin, everywhere | HomeRunner admin | /p/ 404s every file — the public lookup is approved-only | Every instance becomes a hidden failure breadcrumb; the page degrades | Gone from Marketplace; Installed rows keep a Suspended by HomeRunner badge and offer only Uninstall |
| 2 | disabled_widgets on the plugin | Named widgets, every feed | HomeRunner admin | 404s that widget’s entire dist/{widget}/ directory — bundles, images, fonts | Those instances stop resolving | The widget vanishes; so does any preset that references it |
| 3 | disabled_widgets on the install | Named widgets, one feed | The customer | No effect — /p/ is feed-agnostic | Those instances stop resolving on that feed only | Gone from that feed’s picker; the switch stays visible so it can be turned back on |
Rules and caps for the two per-widget lists (Suite only — a single-widget plugin has nothing
to name, and central rejects the attempt with NOT_MULTI_WIDGET):
| Rule | Platform list | Per-feed list |
|---|---|---|
| Maximum slugs | 24 | 24 |
| Slug shape | string, ≤ 64 chars | must be a declared widget slug of the live manifest, else 422 INVALID_WIDGET_SLUG |
| Set by | PATCH /plugins/{id} (admin) — chips on the admin registry row | The plugin page’s per-widget switches |
| Cleared by | Removing the slug | Sending an empty list |
Three consequences worth designing for:
- A withheld widget is invisible, not broken. Its summary is gone from the manifest a customer’s dashboard receives, so they see no widget, no badge and no explanation — only you and admins can see it was withheld. Existing widget rows survive untouched and come straight back when the slug is removed.
- Presets are collateral. Central drops any preset whose
layoutor whose slot children include a withheld slug, so a one-click composition disappears the moment one of its parts is switched off. Design presets out of widgets you expect to keep (Layout widgets). - Assets stop with switch 1 and 2, not 3. A CSR embed of a per-feed-disabled widget on some
unrelated page keeps loading its bundle from
/p/— the proxy resolves against the platform-filtered manifest and knows nothing about feeds (Keywords, assets and URLs).
Nothing is deleted from the CDN. Every switch works by withholding the manifest, and your
published objects stay exactly where they were. That is why the dashboard fires a purge across
every feed with the plugin installed on each of these changes: a suspension lands in seconds
instead of waiting out the composed-page cache
(Publishing, versions and rollback). Publishing a new
version sets status = approved again, so a publish also un-suspends.
deprecated is not a kill switch
deprecated: true is yours; the kill switches are not. They look similar in the picker and are
opposites everywhere else.
deprecated: true | Kill switch | |
|---|---|---|
| Who sets it | You, in widgets[] | HomeRunner (1, 2) or the customer (3) |
| Picker | Gone | Gone |
| Existing placements | Keep rendering, unchanged | Stop rendering |
| Assets | Still served | 404 (switches 1 and 2) |
| Visible to the customer | Yes — a deprecated badge on the plugin page | No (1 and 2); yes (3) |
| Reversible by | Publishing a new version | Flipping the switch |
Deprecating is how you retire a widget from a suite. Deleting its slug instead breaks every
customer widget that stored the keyword (Manifest: widget summary).
Related
- From your laptop to a customer page — the pipeline that got you here
- Preflight and submit · Publishing, versions and rollback
- Composing a page — the picker, presets and page assignment in full
- Public API — the endpoints that expose install state
- Limits and error index — every code and message named here
- Troubleshooting — “my widget stopped rendering on one feed”
Troubleshooting
Organised by what you observe, not by the phase you were in. Every entry is symptom → cause → fix → the page that owns the rule.
Nothing on this page is normative. Every hard number, every enforcement level and every message the platform can emit lives once in Limits and error index; this page routes you into it and into the contract that explains the fix.
Paste your error here
Search this table for a fragment of what you actually saw. {…} marks an interpolated value.
| Message contains | Emitted by | Go to |
|---|---|---|
No matching version found for @homerunner-next/widget-core@ · npm error code ETARGET | npm | Install and scaffold |
Incompatible React versions | react-dom | Install and scaffold |
Error: directory "{slug}" already exists. · npm install failed — you can run it manually. | create-hr-plugin | Install and scaffold · index §11 |
Error: invalid plugin slug · is reserved — it collides with · Error: unknown template · Error: expected a plugin slug first | create-hr-plugin | Install and scaffold · index §11 |
hr-widget-build: {anything} | the SDK build CLI | Build · index §11 |
[viteHomerunnerWidget] This project declares manifest.widgets[] | the Vite preset | Build |
Cannot find module '@homerunner-next/widget-core/schema' | TypeScript | Build |
__HR_WIDGET_DEV__ is not defined | your bundle at runtime | Build |
dist/manifest.json (build manifest with {buildHash, files}) is missing | built-zip audit | Build · index §3 |
Missing {file} — run `npm run build` first. · a red SSR render failed: panel | the local SSR preview | Server rendering |
No widget selected — manifest.json declares · Unknown widget "{w}" — manifest.json declares · is CSR-only (`"ssr": false`) | the local SSR preview, at boot | Server rendering · index §11 |
ReferenceError: window is not defined · document is not defined | the SSR sandbox | Server rendering |
ReferenceError: crypto is not defined · TextEncoder is not defined · Response is not defined | the SSR sandbox | Server rendering · index §7 |
Plugin cannot require "{mod}" (not in shared modules whitelist) | the SSR sandbox | Server rendering · index §7 |
[plugin] fetch to "{host}" blocked · [plugin] fetch blocked — more than 5 redirects. | the SSR sandbox | Server rendering · index §7 |
[plugin] SSR bundle has no default export: {url} | the renderer | Server rendering |
HTTP {status} redirect refused — SSR bundles must be served directly from their published URL | the renderer | Server rendering |
data-hr-widget-error= in the page source | the renderer | Blank widgets · index §6 |
SSR data prep failed: {msg} | the renderer | Server rendering · index §6 |
layout SSR data prep failed: {msg} · plugin layout widget rendered as content | the renderer | Layout problems |
keyword does not resolve against the plugin manifest | the renderer | Blank widgets |
Widget ID is required | mount() | Blank widgets |
[widget-core] window.HRWidgetRuntime is not available. | the SDK runtime | Blank widgets |
[hr-widget] "{type}" mount failed for one embed · deferred mount step failed | mount() | Blank widgets · index §8 |
[hr-widget] "{type}" render failed · data-hr-error="render-failed" | the render boundary | Blank widgets |
[widget-core] widget "{type}" did not register within 10000ms · [layout-csr] … | a CSR layout | Layout problems |
[page-layouts] feed {id} {page}: {reason} — using Automatic | the renderer | Layout problems |
Unsupported schema type: [object Object] | the dashboard config panel | Config panel |
No configSchema for this widget. · sub-manifest fetch failed (HTTP {status}) | the dashboard config panel | Config panel · index §10 |
Plugin "{slug}" not enabled for this feed. · Widget "{keyword}" is not declared by plugin "{slug}". | the dashboard | Config panel |
{"error":"Asset not in plugin manifest"} · {"error":"Plugin not found"} | the /p/ proxy | Works locally, not live · index §9 |
VERSION_NOT_GREATER · SLUG_TAKEN · SLUG_RESERVED · INVALID_VERSION · INVALID_MANIFEST | central, at submit | Submit and publish · index §2 |
MANIFEST_SHAPE_CHANGED · changes the plugin between single-widget and multi-widget shapes | central, at publish | Submit and publish |
MANIFEST_WIDGET_CORE_MISSING · MANIFEST_WIDGET_CORE_TOO_OLD · predates the evergreen runtime | publish gates | Submit and publish |
already exists on the CDN — published versions are immutable | the dashboard uploader | Submit and publish · index §5 |
Two attributes are easy to confuse when you grep: data-hr-widget-error is a hidden
breadcrumb the server leaves where a widget should have been, and data-hr-error is a
marker the browser runtime puts on a live host element. Look for both.
Install and scaffold
| Symptom | Cause | Fix |
|---|---|---|
npm install fails with npm error code ETARGET / No matching version found for @homerunner-next/widget-core@… | A pin your project asks for does not exist on the registry — usually a hand-edited range, or a private/stale registry in .npmrc. Every version this book documents is published | Install and versions — check npm view @homerunner-next/widget-core versions against your pin |
hr-widget-build: command not found, or ls node_modules/.bin/hr-widget-build is empty | Your project resolves a widget-core older than 0.11.0 — before 0.11 the package declared no bin at all | Bump the pin and reinstall. On a pre-0.11 SDK the scaffold’s three-step build:manifest && build:iife && build:ssr is the only build path |
Error: directory "{slug}" already exists. (exit 1) | The scaffold refuses to write into an existing directory | Remove it or pick another name |
npm install failed — you can run it manually. and the CLI still exits 0 | Install failure is deliberately non-fatal | cd {slug} && npm install |
Error: expected a plugin slug first — got "{arg}". | argv[0] is always the slug; flags must come after it | Scaffold a project |
Error: invalid plugin slug "{slug}" — lowercase letters, digits and hyphens only… · Error: plugin slug "{slug}" is reserved… | The slug goes verbatim into manifest.id, every keyword and every CDN path, so the scaffolder applies the publish audit’s own rule up front | Rename — Manifest: root fields |
Error: unknown template "{t}" — expected "single" or "suite". | --template (or --template=) with anything else, or with nothing usable after it | The two shapes are single and suite — The two manifest shapes |
Incompatible React versions: The "react" and "react-dom" packages must have the exact same version. | A caret range on react / react-dom. Your client bundle externalises React against the page’s runtime, and the SSR sandbox hands you the renderer’s copies | Pin react, react-dom and @tanstack/react-query exactly — The React-family pins |
npm run dev:ssr starts fine but ignores every variable in .env.local | Node below 20.12 has no process.loadEnvFile, and the harness swallows the TypeError | Upgrade Node — Node |
| You wanted a suite and got one flat widget | You omitted --template suite (the default is single), or you passed --template=suite to create-hr-plugin 0.8.0, which parsed only the space-separated form and silently scaffolded single | Re-scaffold with --template suite on 0.8.1+, or convert before your first publish — Build a suite. The shape is frozen after that (The two manifest shapes) |
Build
Run npm run build. It is two steps — schema injection, then the SDK’s hr-widget-build
bin — not the three-step chain older guides describe; hr-widget-build runs the iife and
BUILD_SSR=1 passes for you and finalizes exactly once. Single-widget: the scaffold still
declares build:iife and build:ssr as escape hatches, and a hand-run pass never reaches the
finalize step. Suite: --template suite ships neither script, and a bare vite build
fails by design. See Build for the
single-widget shape and Build for a suite.
| Symptom | Cause | Fix |
|---|---|---|
hr-widget-build: no manifest.json in the current directory. | Run from somewhere other than the project root | cd to the root |
hr-widget-build: vite is not installed in this project. | Missing dev dependency | npm install |
hr-widget-build: invalid widget slug in manifest.widgets: {json} · widget slug "{s}" is reserved. · is declared more than once. | A widgets[] slug fails the slug pattern, is dist/widgets/media, or repeats | Manifest: widget summary |
hr-widget-build: missing entry for widget "{s}" ({path}). | No src/widgets/{slug}/index.tsx for a declared summary | Create the entry, or drop the summary — Build a suite |
[viteHomerunnerWidget] This project declares manifest.widgets[] — build it with `hr-widget-build` (which loops BUILD_WIDGET per widget), not a bare `vite build`. | A suite built through a bare vite build — usually the leftover build:iife / build:ssr scripts | Use npm run build; delete those two scripts from a suite |
dist/manifest.json missing, or the publish gate says dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build. | Only the last Vite pass hashes dist/, writes the build manifest and stamps runtime.widgetCore. A hand-run single pass never gets there | Re-run the whole npm run build. Never patch dist/manifest.json by hand |
| A rebuilt widget still serves last build’s file | Per-widget passes run with emptyOutDir: false, so a hand-run pass leaves stale output beside the new files | Full npm run build — it clears dist/ once, up front |
manifest.json shows up dirty in git after every build | By design: schema injection then the runtime.widgetCore stamp both rewrite the tracked file | Commit it. The stamp is a publish gate — Packaging and publishing rules |
Cannot find module '@homerunner-next/widget-core/schema' (or /runtime, /plugin-schema, …) from tsc, while Vite builds fine | Your tsconfig.json resolves modules the pre-exports way, so the package’s subpath map is invisible | Set "moduleResolution": "bundler" (the scaffold’s value). The subpath list is in SDK reference |
__HR_WIDGET_DEV__ is not defined at runtime | The bundle was built without viteHomerunnerWidget, which defines that literal | Build through the preset — /vite and hr-widget-build |
mockServiceWorker.js appears in your published dist/ | Vite copies publicDir into outDir on every build. Both scaffold templates turn it off for builds since create-hr-plugin 0.8.0; a project scaffolded earlier does not have that line | Add publicDir: command === "build" ? false : "public" to vite.config.ts — but do not delete the file, the dev sandbox needs it. The worker file |
npm run preview shows a heading and an empty box | The dev page’s only script was the Vite dev entry, which the static server 404s, so nothing ever evaluated. Fixed in create-hr-plugin 0.8.0: the entry now carries an onerror that boots the runtime IIFE and your built bundle instead | Re-scaffold, or copy the bootPreview() block and the /p/{slug}/{file} route out of the current template’s index.html / serve.js. Note preview talks to live Central with no mocks, so point the host id="{widgetId}:{feedId}" at a real pair |
Before you zip, run the publish gates you can run locally: the script in
Run the reviewer’s gate yourself
checks declared assets, hashed twins, the HRPlugins marker, base-schema extension and
declared media/ files.
Dev server
Everything here is npm run dev — the offline Vite sandbox on :3001 with MSW-mocked data.
It is documented in Dev sandbox and mocking.
| Symptom | Cause | Fix |
|---|---|---|
Vite reports Port 3001 is in use, trying another one | Another dev server holds the port | Harmless — the dev CSS routes are root-relative, so they follow the new origin. Just use the port Vite prints |
| The widget mounts unstyled | Your CSS import is redirected to a no-op virtual module in dev and served at a URL instead; check /dist/{slug}/{slug}.css (single-widget) or /dist/{slug}/{widget}/{widget}.css (suite) actually returns your rules | Dev-server URLs |
Saving a .css file changes nothing | The server emits a custom hr-widget-css-update event that cache-busts the <link> inside the shadow root; a hard reload confirms whether the build is stale | Dev-server URLs |
| Every request escapes to the network | The MSW worker did not install, usually after an msw upgrade | npx msw init public. Never delete public/mockServiceWorker.js — The worker file |
| The scenario buttons do nothing and none of them lights up | ?scenario= is cast, never validated: an unknown value behaves like default and leaves the row unlit | Scenarios |
| Scenario buttons switch but the old rows stay on screen | The panel resets every query whose key does not start with "widget". Your own keys starting with "widget" are spared | Namespace query keys with your plugin slug — Query-key discipline |
?mock=off gives data-hr-error="widget-fetch-failed" | dev-widget:1 exists on no real feed | Put a real {widgetId}:{feedId} in index.html — Real staging data |
| In a suite, every layout slot child resolves to the layout itself | startMockWorker is a page singleton bound to ONE keyword, and the built-in /widget/{id} handler echoes it for every id | Use the widget fixture factory, keyed by the requested id — what --template suite ships. Suites |
| In a suite, two widgets show the same value for a setting | One page, one mock settings object — the suite sandbox seeds it with the union of every widget’s schema defaults, so same-named fields collide | Rename the field, or check that widget on its own in npm run dev:ssr -- --widget {slug}, which seeds only its schema — Suites |
The error scenario shows your fixture, not an error | Scenario gating short-circuits before your fixture runs, and only the data endpoints are gated | Why the widget always mounts |
Server rendering
The renderer evaluates your built SSR bundle in a node:vm realm with no DOM. The
contract is Component and SSR module; the local
harness and its divergences are Previewing SSR locally.
ReferenceError: window is not defined (or document, navigator, localStorage).
The sandbox seeds those seven globals as inert Proxy stubs, and a property read off a stub
returns another stub, which is truthy. So if (document) and if (window.matchMedia) both
pass on the server and then blow up on the real call. Only one guard form works:
// Verified in packages/homerunner-renderer/lib/plugin-federation.ts (makeBrowserGlobalStub)
// and the SSR build's typeof rewrite.
if (typeof window === "undefined") return; // rewritten to "undefined" at build, then DCE'd
The SSR build rewrites typeof on window, document, self, navigator, localStorage
and sessionStorage to the literal "undefined", so esbuild deletes the branch and the
imports inside it. Truthiness guards keep the dependency in the bundle. Full table:
Browser-global stubs, and the guard that actually works.
| Symptom | Cause | Fix |
|---|---|---|
ReferenceError: crypto is not defined (also TextEncoder, TextDecoder, Buffer, Response, structuredClone, queueMicrotask, performance) | Not seeded. There is no Web Crypto and no crypto.randomUUID() in the sandbox | Bundle a pure-JS replacement, or move the work into the browser. Inventory: Globals |
It works in npm run dev:ssr and throws TextEncoder is not defined on the first customer page | You are on widget-core below 0.12.2, which seeded TextEncoder/TextDecoder in the harness while the renderer never had them. 0.12.2 removed both, so the throw now happens locally | Upgrade the SDK, then bundle a pure-JS replacement — Where the preview differs from production |
Plugin cannot require "{mod}" (not in shared modules whitelist) | Exactly seven specifiers resolve; everything else must be bundled into your SSR file | Shared modules (7) |
That same throw for @homerunner-next/widget-core/runtime, but only locally | You are on widget-core below 0.12.2, which omitted /runtime from the harness’s whitelist while the renderer shared it. Fixed in 0.12.2 | Upgrade the SDK. Keeping server code off /runtime — a browser surface — is still the safer habit |
[plugin] fetch to "{host}" blocked — not declared in the widget's externalFetch manifest field. | Server-side fetch to a host outside Central ∪ your declared list. Nothing validates that list at publish, so a typo only shows up on a live page | externalFetch |
[plugin] fetch to "{host}" blocked — private/metadata hosts are never allowed. | Loopback, private ranges, *.local, link-local and metadata hosts are refused even when declared | Same |
[plugin] fetch blocked — more than 5 redirects. · [plugin] fetch blocked — unparseable URL: {raw} | Redirect chain too long, or a non-URL passed to fetch | Same |
| A widget renders stale server data for up to a minute | ok GET responses are cached across renders outside development mode | Bound in Runtime bounds; bypass with ?dev=1 |
HTTP {status} redirect refused — SSR bundles must be served directly from their published URL · a bare HTTP {status} | Your ssr.url 3xx’d or returned non-2xx | Execution bounds |
[plugin] SSR bundle has no default export: {url} | ssr-entry.ts exports no default. Mandatory for every widget, layouts included | The entry file |
SSR bundle failed to load from {url} with nothing else in the log | Often the module-scope execution timeout: synchronous top-level work exceeded the vm bound. Async work is not bounded by it | Move work into getInitialData; give every request an AbortSignal.timeout(…). Bound: Runtime bounds |
SSR data prep failed: {msg} | A throw in getInitialData, dehydrateState, getStaticAssets or your component’s server render — all four sit in one try | Parse ctx.options with parseWidgetConfig and default everything: Failure semantics |
[widget] SSR prefetch FAILED — shipping loading markup. and the widget renders a spinner | prefetchQuery swallows errors by design; the page is marked degraded | Render a real empty state, not a spinner, when a query has no data — Prefetch errors only warn |
Missing {file} — run `npm run build` first. or a red SSR render failed: panel locally | npm run dev:ssr reads dist/ and compiles nothing. {file} is dist-relative, so a suite’s reads {widget}/{widget}-ssr.umd.js | Build first — Running it |
npm run dev:ssr fails at boot with No widget selected… or Unknown widget "{w}"… | A suite has no default widget, and the selection is validated against manifest.json before the server starts | Name one: npm run dev:ssr -- --widget {slug} — Naming a widget |
npm run dev:ssr fails at boot saying the widget is CSR-only | The correct outcome: a widget whose summary declares "ssr": false has no SSR bundle and no ssr-entry.ts. Exact string in index §11 | Check it in the browser sandbox instead — Dev sandbox and mocking |
| A layout previews with all its slots empty | Not a bug and not fixable locally: renderPluginSSR passes no renderedSlots, and only the platform renders a layout’s bound children | Verify composition on a real feed — Suites: one widget at a time |
When a widget is simply absent from a rendered page, view-source and search for
data-hr-widget-error. The renderer leaves a hidden node whose HTML comment carries the
reason; each reason maps to a cause in
index §6. A page containing one is
flagged degraded and its cache TTL collapses, so it is not stuck — it re-renders shortly.
Hydration mismatches
React discards the whole server DOM on a mismatch and re-renders client-side. The symptom is almost never an error: it is a visible flash, an interaction that only works after a beat, or SSR markup that is silently thrown away. The contract is The hydration contract.
| Symptom | Cause | Fix |
|---|---|---|
Server HTML is replaced wholesale on hydrate; useId-derived ids (Radix aria-*, form ids) differ | The server renders each widget as an isolated root inside WidgetHydrationTree with identifierPrefix = the host’s data-hr-ssr-id. Any drift in wrapper shape or prefix regenerates the tree | Never call hydrateRoot yourself — Why you must not call hydrateRoot yourself |
| Output differs between server and client for no obvious reason | props.widgetType is absent on the server and on the hydrate path, and present only on pure CSR. Same for Math.random(), Date.now(), any window read | Render deterministically — What each path actually passes |
| Dark mode is correct on CSR and light forever on a server-rendered page | resolvedTheme derives from a cookie the edge worker never forwards, so it is "light" on every production SSR — and mount() hydrates with the server’s props, so it stays light | Branch in CSS off the host attribute — The theme contract |
| Visible server content that never becomes interactive, sitting over an invisible second render — and it looks fine in some browsers | The manifest says shadowDOM: true and your mount() passes false. The browser attaches the server shadow root at parse; your light-DOM render is never displayed | Make the two agree — When shadowDOM disagrees |
| The widget client-renders even though the server clearly emitted markup | A component whose entire server output is a hoistable tag (<style>, <link>, <script>, <meta>, <title>, <base>) reads as “no server markup” | Nest server output inside a real element — Shadow DOM lifecycle |
[widget] SSR state could not be inflated; widgets fetch client-side. | The page’s dehydrated-state block was malformed or blocked; the page degrades to client fetching rather than breaking | Check the page’s inline scripts are not being rewritten or CSP-blocked — The DOM contract |
data your getInitialData returned arrives mangled | It travels as JSON in an inline script; Date, Map and functions do not survive | Return JSON-serialisable values only |
| You cannot reproduce a hydration bug locally | npm run dev never takes the SSR branch at all. npm run dev:ssr in hydrate mode does apply WidgetHydrationTree and the identifier prefix since widget-core 0.12.2 — below that it rendered bare with no prefix, so useId drift was neither reproducible nor trustworthy | Upgrade the SDK and run HR_SSR_HYDRATE=1 npm run dev:ssr; the remaining gap is Declarative Shadow DOM and the props registry — Where the preview differs |
Blank widgets and error states
Start by reading the host element. data-hr-error is set by the browser runtime; its complete
vocabulary and what clears each value is
The data-hr-error vocabulary.
| Value on the host | What it means | Where to look |
|---|---|---|
render-failed | Your component threw during render. By far the most common cause is reading props.options without parseWidgetConfig | Options arrive RAW |
mount-failed | A throw inside mountOne — classically a missing or non-colon-joined id — or a rejected deferred hydrate/render step | Idempotency and per-embed isolation |
widget-fetch-failed | The widget-config fetch failed. A non-colon-joined id makes feedId NaN and 404s the fetch | Attributes mount reads |
rate-limited | The config fetch returned 429 | Rate limit |
property-not-found | A CSR layout was pointed at a property slug that does not exist on the feed | Layout problems |
The raw-options trap, spelled out. props.options arrives unparsed on both the server
and the client — plugins are deliberately excluded from the renderer’s settings parse. Raw
settings carry null for a cleared field, [] for an emptied map, stringified numbers and a
legacy width object. Calling configZod.parse() on that throws; parseWidgetConfig strips,
coerces and lifts first. Call it in the component and in every SSR export:
// pdp-suite/src/widgets/stay-hero/widget.tsx (real, published)
const cfg = parseWidgetConfig(configZod, props.options ?? {});
Reproduce it locally by clearing a field to null or "" in the dev panel’s settings editor
— see Testing options-updated.
| Symptom | Cause | Fix |
|---|---|---|
Nothing happens at all: no error, no data-hr-mounted, no console line | mount selects on [data-hr-widget="{widgetType}"] inside the container you pass. A wrong keyword matches nothing and returns silently | Check the keyword on the host equals config.widgetType exactly — mount(container, config) |
[widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry. | Script order on a hand-written embed | Runtime first, then your bundle — Boot order |
| The bundle 200s in the network tab and never executes | crossorigin="anonymous" on a /p/ script tag. /p/ 302s off-host to a target with no ACAO, so the browser rejects the final response in CORS mode | Drop the attribute — The crossorigin trap |
| The bundle executes but the component never renders | The registry key must be the keyword minus plugin:. The publish audit only greps for the literal HRPlugins, so a bundle registered under the wrong key passes and fails silently | Registration |
Widget ID is required | The host has no id. It must be the colon-joined "{widgetId}:{feedId}" | The embed shape |
Fixing the DOM and calling mount again does nothing | data-hr-mounted is written before the id check, so a failed embed keeps the marker | Remove the attribute, or call el.__hrWidgetUnmount() — The unmount handle |
The failure node’s reason is keyword does not resolve against the plugin manifest | The stored keyword does not match the manifest shape: a bare plugin:{slug} against a suite, a widget slug against a v1 manifest, or an unknown/kill-switched slug | Shape mismatches fail closed |
| A live edit in the dashboard preview does nothing | Only the CSR paths listen for options-updated; a hydrated SSR widget installs no listener | Live preview |
| One embed broke and the others are fine | Working as designed — each element mounts inside its own try | Idempotency and per-embed isolation |
| A CSR-only widget leaves a tall gap, or none at all | preloadMinHeightHint returns null for every plugin:* keyword, so nothing is reserved unless you supply a height | Set expectedHeight (Manifest: widget summary) or data-hr-min-height — Anti-CLS preload reservation |
Empty or wrong config panel
| Symptom | Cause | Fix |
|---|---|---|
An amber banner: No configSchema for this widget. Ask the plugin developer to ship one via zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts. | The sub-manifest is missing from the CDN, 404’d, timed out, or carries no configSchema. It also appears when the browser cannot rebuild a zod object from your JSON Schema ([plugin-playground] jsonSchemaToZod failed: in the console). This degrades; it is never an error | Per-widget sub-manifests |
sub-manifest fetch failed (HTTP {status}) in the console | The declared widgets/{slug}.manifest.json is not at that path under the published version prefix | It must be committed and shipped in both zips — What must be committed |
The whole config page crashes with Unsupported schema type: [object Object] | z.any(), z.unknown(), z.literal(), z.tuple(), z.intersection() or z.discriminatedUnion() survived the JSON-Schema round trip into the dashboard’s unguarded default-value walker | Never ship those six. Model variants as a z.enum() discriminator plus flat optional fields — Breaks the whole panel |
Plugin "{slug}" not enabled for this feed. | The plugin is not installed on that feed | Install, customers and kill switches |
Widget "{keyword}" is not declared by plugin "{slug}". | A stored keyword the current manifest no longer resolves — usually a slug you renamed or removed | Deprecate instead of deleting — deprecated and replacedBy |
| A built-in panel (Filter, Spacing, Section…) lost its controls | You re-declared a base key in your uiSchema. The adapter replaces a top-level key wholesale — it does not deep-merge | Never re-declare a base key |
Fields inside a panel are labelled translations.viewDetails | The default label is the full dotted path; there is no humanisation | Declare label on every nested field — The uiSchema |
Your uiSchema key order is ignored | Plugins cannot reorder anything; order is dropped | Move the field in the zod schema — The uiSchema |
| Everything the customer types into a record field fails your runtime parse | The key/value editor writes strings | Keep record values z.string() — Degrades silently |
A cross-field .refine() never fires in the dashboard | zodToJsonSchema erases refinements; the form’s validation is cosmetic anyway | Enforce it in parseWidgetConfig and degrade rather than throw — Reading options back |
| A changed schema default does not reach existing widgets | The seeded object is autosaved on the customer’s first edit, so your defaults became stored values | Defaults at widget creation |
| The panel is fine but standard theme/spacing controls are missing | Your configSchema does not extend the base widgetSchema. This is a publish warning, visible only to the reviewer | Build config.ts as widgetSchema.extend({...}) — The base schema |
Layout problems
Layouts are suite-only and need widget-core 0.11.0+. The full contract is Layout widgets.
| Symptom | Cause | Fix |
|---|---|---|
| Your layout is not offered under Feed → Page URLs → Page layouts | It appears only when the plugin is approved and enabled on that feed, the widget is not kill-switched, and the summary declares that page in pages | pages and page assignment |
| The customer’s slot picks vanish on save | There is nowhere to store them: your zod config has no slots object field | Add it, keys matching the manifest slot names exactly — The mandatory slots config field |
| One slot renders and another never does | Slot names must match character for character between the manifest summary, the zod field and (for presets) the preset’s slots keys. Preset slot keys are never validated | settings.slots · Presets |
A slot is empty and renderedSlots.hero is undefined | An unbound slot is absent, not empty. On an assigned page only the keys present in the stored bindings are rendered — nothing is backfilled for a plugin layout | Always props.renderedSlots?.[name] ?? null — The component contract |
| The layout shell is inert on a server-rendered page: no clicks, no state | Known gap. The renderer emits no data-hr-ssr-id and no props-registry entry for a shell, and the client mount is a deliberate no-op — this is correct behaviour, not a broken mount | Put interactivity in a content widget bound into a slot — SSR: the server composes your children |
| The whole customer page 500s | A layout component that throws during render is not contained: the shell is rendered later, in the page-level renderToString, which has no try | Never throw in a layout’s render path. Parse defensively, default every field and every slot — Failure semantics |
A breadcrumb reason of plugin layout widget rendered as content — needs a layout entry with slots | A category: "layout" widget was placed as ordinary content | Manifest: category |
layout SSR data prep failed: plugin layout widgets require an SSR bundle (`ssr: false` is content-only) | The summary declares "ssr": false. The publish audit lets that through; the renderer does not | An SSR bundle is mandatory for a layout — Requirements at a glance |
| The server render replaces itself with an empty client render | category: "layout" was omitted from both registerPluginWidget and mount | Registration and mount |
| The layout stylesheet restyles the customer’s whole page | On a server-rendered page the shell is light DOM and its CSS is loaded document-level | Class-namespace every selector, never rely on :host, never paint a background — Layout CSS |
A CSR slot child stays empty; [widget-core] widget "{type}" did not register within 10000ms | Its IIFE 404’d or never registered. The layout still renders | CSR: the layout composes its own children |
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest. | A layout was bound into a slot. Refused on both render paths | Layouts cannot nest |
[layout-csr] Property "{slug}" was not found in feed {id}. and data-hr-error="property-not-found" | The one whole-layout failure: a permanent 4xx on the property check | Fix the layout’s Property setting or the embed’s data-hr-options filter |
accepts: ["layout"] matches nothing in the slot picker | Known gap — the picker excludes layouts before accepts is evaluated, and accepts is unvalidated at publish | Known gaps, collected |
A listings / checkout / confirmation page 404s outright | Those pages pass only a slot named content, and 404 when it resolves to no live widget | Name a slot exactly content — What the renderer does with an assignment |
[page-layouts] feed {id} {page}: {reason} — using Automatic | The renderer could not honour the assignment and fell back rather than failing | Read the reason — Degrading to Automatic |
| You deprecated an assigned layout and it keeps rendering | Known gap: the dashboard flags it, the renderer does not check deprecated | Ask the customer to switch that page back to Automatic |
| A dialog inside a layout shell never opens | On a server-rendered page there is no shadow root and no portal container in a shell | Layout widgets have no portal container |
Styling and dark mode
| Symptom | Cause | Fix |
|---|---|---|
| The widget renders with no styles at all on a live page | Your assets.css never resolved — a /p/ 404, a wrong dist-relative path, or a crossorigin attribute on the tag | Two serving paths · The crossorigin trap |
| A widget briefly renders unstyled, then styles | The shadow CSS gate resolves regardless after its timeout; CSS never blocks hydration. A failed sheet logs [HRWidget] Failed to load CSS in shadow DOM: <href> | Shadow DOM lifecycle |
| The customer’s page loses all its margins, padding and borders | Tailwind preflight. On a server-rendered page your stylesheet is also appended to <head>, so its universal selector resets their whole document | Import Tailwind without preflight — The Tailwind stack |
Your .card / .title rules collide with the host page | Same reason: on a composed page every selector you ship is a global selector | Namespace every class — Where your CSS actually lands |
| Dark mode follows the visitor’s OS instead of the configured colour scheme | Tailwind’s dark: variant compiles to @media (prefers-color-scheme: dark) | Key off the host attribute instead — The theme contract |
| Your dark arm silently never matches | :host([data-hr-scheme="auto"]):not([data-hr-theme]) parses fine and matches no shadow host. The :not() must be inside :host(...) | Four footguns |
| The dark rule loses to the light rule | :where() holds specificity at zero, so the dark selector has to string-match the light one | Same |
auto still flashes light before hydrating | You did not declare mediaSafeAuto, or the request was not in a DSD bucket | Dark at first paint · mediaSafeAuto |
| A dialog opens as unstyled browser-default HTML over the page | The portal target fell through to document.body, which is outside your shadow root | Two portal targets |
| A dialog is almost right — a white panel over a dark widget | The elevated portal clones only link[rel="stylesheet"]. Everything you rendered as an inline <style> (accent, font, customCss) is absent, so every token falls back | Build the token string once and pass it as cssString too — The token trap |
Your @font-face is ignored | @font-face does not work inside a shadow root | Declare the stylesheet in assets.fonts, which is injected at document level — The two injection paths |
| Declared fonts arrive after first paint on a server-rendered page | You export getStaticAssets, and declared fonts are appended only on the manifest path | Omit getStaticAssets unless you truly need it — Component and SSR module |
A font you edited into manifest.json is not loading | The build bakes the font list into the client banner at config time | Rebuild after editing — Web fonts |
An imported image ships as a huge data: URL | Vite library mode inlines everything unless tagged ?no-inline; the preset only tags listed extensions | Keep unusual asset types off the import graph — Bundled assets |
spacing, font, accent colours or customCss have no effect | The platform applies none of the base fields for you | Render them yourself — Rendering the base fields yourself |
Submit and publish rejections
Three gates, and you only watch two of them: Preflight and submit walks all three, and every code and message is in index §1–§5.
| Symptom | Cause | Fix |
|---|---|---|
| The uploader lists zip problems and never transmits | The source-zip audit runs in your browser: structure, entries, sizes, and the manifest’s shape | The source zip · index §1 |
VERSION_NOT_GREATER after a clean browser audit | The browser checks the version’s shape, never its ordering against the live version | Version rules |
INVALID_VERSION on something a looser parser accepts, e.g. 1.0 | Central demands exactly three numeric parts | Same |
SLUG_TAKEN · SLUG_RESERVED | Another author owns the slug, or it is an unowned legacy row | Checked after the bytes are in the bucket |
INVALID_MANIFEST for a manifest your browser accepted | Central applies rules the browser does not — length caps on id, name, version, description, author, plus the widget count ceiling | Manifest: root fields |
MANIFEST_SHAPE_CHANGED — at publish, after review passed | A live plugin can never flip between the single-widget and suite shapes. It is not checked at submit or at review | Publish the new shape under a new slug — The manifest shape is frozen at first publish |
MANIFEST_WIDGET_CORE_MISSING / _TOO_OLD, or manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. | The build’s stamp is missing or below the floor. It is written by the last build pass; never by hand | Update widget-core, rebuild, commit manifest.json — runtime |
Declared icon {ref} is missing from the zip. (also cover, screenshot, README, sub-manifest) | media/ and widgets/*.manifest.json are authored content. Nothing generates them, and the reviewer only runs your build | Commit them into the source zip — Media references |
{slug}@{version} already exists on the CDN — published versions are immutable. | Version prefixes can never be overwritten | Resubmit with a bumped version — Immutability and retry safety |
| A file you shipped is simply not on the CDN | Anything more than one directory deep under dist/, widgets/ or media/ is dropped silently; an unusual file name is dropped with a reviewer-only warning | Keep the SDK’s output layout — The publishable surface |
| Your plugin shipped with an empty settings form and nobody told you | Known gap: built-zip warnings are visible only in the reviewer’s console | Run the local gate script before you zip — Run the reviewer’s gate yourself |
| Silence after submitting | Known gap: there is no notification of any review outcome, ever | Poll Dashboard ▸ Plugins ▸ My plugins — From your laptop to a customer page |
| Everything published, and every cache optimisation is gone | You published a pre-release version like 1.0.0-rc.1. Every immutability optimisation keys off a bare X.Y.Z segment | Use plain three-part versions — Known gaps |
It works locally but not on the customer site
The single most productive question: which of the two harnesses did you verify in, and what does it not simulate?
| It works in… | …and breaks live because | Read |
|---|---|---|
npm run dev | The sandbox seeds settings with configZod.parse({}), so every field is present and typed. Customers’ stored settings arrive raw | Options arrive RAW |
npm run dev | It never converts your configSchema back to zod, so a round-trip breaker looks fine here and empties the customer’s panel | What survives the manifest round-trip |
npm run dev | It always takes the CSR branch: no DSD, no props registry, no hydration | What the sandbox does not cover |
npm run dev on a suite | It composes a layout’s slots the client way, from bindings in mocks/fixtures.ts. A server-rendered page composes them in the renderer and hands the shell renderedSlots in light DOM | Suites |
npm run dev:ssr | The harness pre-parses options, points __HR_PLUGIN_ASSET_BASE__ at localhost, emits props as an attribute rather than the page registry, and never composes a layout’s slots. On widget-core below 0.12.2, add: seeded TextEncoder/TextDecoder, no /runtime, no externalFetch enforcement, no execution timeout | Where the preview differs from production |
| Either harness | Neither emits Declarative Shadow DOM or the page-level props registry | Hydration mismatches |
| A CSR embed | On a composed page your stylesheet is also in <head>, unisolated | Where your CSS actually lands |
| A CSR embed of a layout | The CSR layout gets a shadow root and full providers; the server-rendered shell gets neither, and its mount no-ops | How a layout renders |
And when the plugin is live but a page still disagrees with you:
| Symptom | Cause | Fix |
|---|---|---|
{"error":"Asset not in plugin manifest"} from /p/ | The file is neither a manifest-declared asset nor a key of your dist/manifest.json. In a suite it must also sit under a directory named for a declared widget slug | The allow-list |
{"error":"Plugin not found"} from /p/ | Unknown slug, or the plugin is not approved — a suspension 404s every file | The three kill switches |
| One widget of your suite stopped rendering everywhere, with no message anywhere | A platform per-widget kill switch strips its summary from every public surface and 404s its entire dist/{widget}/ directory | Same page |
| One widget stopped rendering on one feed only | The customer’s per-feed toggle. Assets keep serving; only that feed’s instances stop resolving | Same page |
| A widget you retired keeps rendering on existing pages | That is what deprecated does — it hides the widget from the picker and leaves placements alone | deprecated is not a kill switch |
| You published and customers still see the old build | Cache, not publishing. The publish fan-out purges what it can reach inside its budget; anything it misses waits out the page TTL | How an update reaches a customer |
| A page mixes new markup with an old bundle | It cannot. Server-rendered pages embed content-addressed URLs, so a cached page keeps loading exactly the bundles it was rendered with | A stale page is stale consistently |
| A live page broke after old version prefixes were pruned | Deleting an old prefix is the one operation that can break a cached page | What holds a copy of your old build |
| A 429, or data that stops arriving under load | The public API is rate limited per IP, shared across every endpoint | Rate limit |
Older versions of this guide told you to call a
refresh_manifestAPI to clear a stale manifest. Do not look for it: it is an administrator-only action with no author-facing surface. Publishing records the manifest and triggers the warm-and-purge fan-out; there is nothing for you to invalidate.
Seeing what the server actually did
Four signals, all owned by
Seeing what the server actually did on a real page:
development mode (?dev=1, which bypasses caching for a feed), the <!-- hr-render … -->
stamp at the end of the page source, the x-hr-render-* headers, and view-source for
data-hr-widget-error.
Two more things worth knowing while you read a live page:
- Your own
console.*calls inside the SSR bundle go to the renderer’s stdout, not to the browser. Ask an operator to grep the[plugin],[widget]and[layout]prefixes listed in index §8. - Check what is actually live before you debug anything else:
Checking a release yourself
reads your published
manifest.jsonanddist/manifest.jsonstraight off the CDN.
Still stuck
- Unfamiliar word? Glossary.
- Exact message, exact number, exact enforcement level? Limits and error index.
- Not sure which layer even owns the behaviour? How a widget renders draws all three render paths and From your laptop to a customer page draws the pipeline and says who is blocked at each step.
Glossary
Every term this book uses as a term of art, in one alphabetical list. Each entry is a one-line orientation plus the page that owns the term — the rule, the limit and the error string live there, never here. When this page and a linked page disagree, the linked page wins.
Version gates are shown inline, e.g. (0.11.0+), and mean
@homerunner-next/widget-core. Which versions you can actually install is in
Install and versions.
| Term | What it is | Defined in |
|---|---|---|
accepts | Per-slot list of categories and/or exact keywords the dashboard’s slot picker offers. A hint to the customer, read by nothing at render time. | Layout widgets |
| Advisory (unvalidated) | Enforcement level: nothing checks it at any layer, so a typo is yours to find in production. | Limits and error index |
Asset proxy (/p/) | {proxy}/p/{slug}/{dist-relative file} — the versionless plugin asset endpoint. One 302 to the content-hashed CDN file. Never put crossorigin on a /p/ script tag. | Keywords, assets and URLs |
assets.fonts | Stylesheet URLs (or confined relative paths) injected at document level, because @font-face is ignored inside a shadow root. (0.12.0+) | Keywords, assets and URLs |
| Build manifest | dist/manifest.json — generated {buildHash, generatedAt, files} mapping every dist-relative name to its hashed twin. Not the registration manifest. | Keywords, assets and URLs |
| Built zip | The zip the reviewer builds from your source and uploads to the CDN — your source plus the whole dist/ tree. | Packaging and publishing rules |
| Bundled asset | An image or font your widget imports; above the inline threshold the SDK emits it as a real file and resolves its URL from the bundle’s own location. (0.12.0+) | Keywords, assets and URLs |
category | content or layout on a widget summary. Anything but the literal layout resolves to content. | Manifest: widget summary |
| Composed page | The customer page a worker assembles from the renderer’s HTML and caches. Your widget is one island inside it. | How a widget renders |
| Confined relative path | A manifest path that cannot escape the published version prefix: no leading /, no scheme:, no \, no ... | Manifest: widget summary |
configSchema | The JSON Schema in your manifest (or sub-manifest) that drives the dashboard form. Emitted by zodToManifestSchema(). | Config schema and UI schema |
| Content widget | A widget that renders itself and hosts nothing — the default category. | Manifest: widget summary |
crossorigin trap | Adding crossorigin="anonymous" to a /p/ tag: the off-host 302 target sends no ACAO, so the bundle silently never executes. | Keywords, assets and URLs |
| CSR-only widget | A widget declaring "ssr": false — no SSR bundle, no ssr-entry.ts, mounted in the browser only. Give it an expectedHeight. | Manifest: widget summary |
data-hr-error | Host-element marker naming why a widget failed: mount-failed, render-failed, rate-limited, widget-fetch-failed, property-not-found. | Runtime, mount and the DOM contract |
data-hr-scheme | Host attribute meaning “follow the OS” — a pre-hydration CSS gate only, stamped by the server and removed the moment the runtime attaches. | Styling and theming |
data-hr-ssr-id | Server-written key into the page’s props registry, and the identifierPrefix used to hydrate. Layout shells never get one. | Runtime, mount and the DOM contract |
data-hr-theme | Host attribute carrying the resolved theme once JS owns theming. Your dark rules must target it, and the media-query arm must yield to it. | Styling and theming |
| Declarative Shadow DOM (DSD) | Server-emitted <template shadowrootmode="open">, so the browser attaches and styles your shadow root while parsing — no mount blink. | How a widget renders |
| Degraded page | A server-rendered page carrying any failure breadcrumb or failed prefetch. It still serves; its cache lifetime collapses. | Limits and error index |
dehydrateState | Optional SSR export: prefetch into the page’s shared React Query client so the browser hydrates without refetching. | Component and SSR module |
deprecated | Soft retirement of one suite widget: gone from the picker, existing placements keep rendering. Not a kill switch. | Manifest: widget summary |
| Dev panel | The bar npm run dev pins to the page — scenario, theme, latency and a settings textarea driving window.__HR_MOCK__. In a suite it takes a keyword list and pushes to all of them. | Dev sandbox and mocking |
| Dev sandbox | npm run dev (:3001): your widget — or every widget of a suite, with the layout composed — mounted against an MSW-mocked HomeRunner API. | Dev sandbox and mocking |
| dist-relative name | The normalised form every consumer reduces an asset reference to: everything after the last dist/, else the basename. | Keywords, assets and URLs |
| Elevated portal | A portal target with its own shadow root at document.body, for dialogs the widget’s own box would clip. Needs its tokens passed in. | Portals and dialogs |
| Embed snippet | The copyable HTML for a standalone placement: the runtime script, your bundle, and a host div carrying the keyword. | Runtime, mount and the DOM contract |
| Enforcement level | One of exactly four: publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated). Every rule in the book carries one. | Limits and error index |
{env} prefix | prod, dev or local — the mandatory first segment of every published URL. An example URL without it is wrong and 404s. | Keywords, assets and URLs |
| Evergreen runtime | Since 0.10.0 React, React DOM, React Query and @homerunner-next/widget-core/runtime are externalized onto window.HRWidgetRuntime, so platform mount fixes reach your published bundle with no rebuild. | Runtime, mount and the DOM contract |
expectedHeight | Anti-CLS hint in CSS pixels on a widget summary. Emitted as data-hr-min-height for CSR-only widgets and baked into embed snippets. | Manifest: widget summary |
externalFetch | The host allow-list your server bundle may fetch beyond HomeRunner’s public API. Unvalidated at publish; enforced only in the sandbox. | Component and SSR module |
| Failure node | The hidden data-hr-widget-error breadcrumb the renderer substitutes for a widget that could not render. It degrades the page; it does not break it. | Component and SSR module |
| Feed | One customer site’s content scope. Plugins are installed per feed, and every widget row belongs to exactly one. | Install, customers and kill switches |
filter.property | The property the page (SSR) or the layout (CSR) pushes into every widget’s settings, so a slot child knows which listing it is on. | Composing a page |
| Fixtures | mocks/fixtures.ts — per-endpoint overrides for the dev sandbox’s generated data, keyed by endpoint. In a suite the widget factory is what gives each mounted widget its own keyword. | Dev sandbox and mocking |
getFinalSettings | The platform’s merge that produces the settings blob your component receives. Precedence chain and __parentColorScheme live with it. | Settings and options |
getInitialData | Optional SSR export whose return value becomes props.data and is replayed to the browser. Must be JSON-serialisable. | Component and SSR module |
getStaticAssets | Optional SSR export returning {css, js}. Prefer omitting it — everything it returns is re-resolved, and exporting it drops your declared fonts from <head>. | Component and SSR module |
| Hashed twin | The {base}-{sha8}{ext} copy the build writes beside every dist/ file, recorded in the build manifest and served immutably. | Keywords, assets and URLs |
hr-widget-build | The SDK’s build CLI: validates every summary, then runs the (widget × pass) matrix and finalises the manifests. Never run bare vite build on a suite. (0.11.0+) | SDK reference |
HRPlugins | window.HRPlugins[registryKey] = {component, category?} — the page-global registry your client bundle writes to. | Runtime, mount and the DOM contract |
HRWidgetRuntime | The page-level global the runtime IIFE installs: React, React DOM, React Query, widgetCore and the baked base URLs your bundle externalizes against. | Runtime, mount and the DOM contract |
| Hydration | Attaching React to server-rendered markup instead of re-creating it, using the props and query cache the server serialised. | How a widget renders |
| IIFE bundle | Your client bundle — dist/{slug}.iife.js (v1) or dist/{widget}/{widget}.iife.js (suite). | Keywords, assets and URLs |
| Install / Uninstall | The customer’s per-feed lifecycle. Install is Activate; Uninstall is refused while widgets built from your plugin still exist. | Install, customers and kill switches |
| Keyword | The single string that addresses a widget everywhere: plugin:{slug} (v1), plugin:{slug}:{widget} (suite), bare for a system widget. | Keywords, assets and URLs |
| Kill switch | One of three subtractive switches that withhold your code: platform suspension, platform per-widget, per-feed per-widget. Nothing is deleted from the CDN. | Install, customers and kill switches |
| Layout shell | The layout’s own markup around its slots. On a server-rendered page it is static light DOM with no shadow root and no mount — never interactive. | Layout widgets |
| Layout widget | A suite widget with category: "layout" that hosts other widgets in named slots. Needs an SSR bundle, a slots config field and a registered category. (0.11.0+) | Layout widgets |
LayoutWidgetProps | WidgetProps plus renderedSlots. Treat widgetType and target as optional — SSR supplies neither. | Layout widgets |
| Manifest | Root manifest.json — the registration document central caches and ships with every render. | Manifest: root fields |
| Manifest shape | Single-widget (v1) or suite (widgets[]). The discriminator is one line, and the choice is frozen at first publish. | The two manifest shapes |
| Manifest URL | Where your published manifest lives: {cdn}/{env}/{slug}/{version}/manifest.json. The {env} segment is never optional. | Keywords, assets and URLs |
| Marketplace | The Marketplace tab of a feed’s Plugins page — the only catalogue customers see. There is no global plugin browser. | Install, customers and kill switches |
| Media reference | A media/<file> path in the manifest (icon, cover, screenshots, readme). The rules differ per field, and media/ resolves only after a publish. | Manifest: root fields |
mediaSafeAuto | Opts a widget into parse-time auto scheme stamping. It is a promise about your CSS that nothing validates. (0.12.0+) | Manifest: widget summary |
mount(container, config) | The runtime entry your bootstrap calls. Selects [data-hr-widget="{keyword}"] inside the container, hydrates or client-renders each match. Returns void. | Runtime, mount and the DOM contract |
| Namespaced keyword | The suite form plugin:{pluginSlug}:{widgetSlug}, split at the first colon. | Keywords, assets and URLs |
options (raw) | The settings blob your component receives. It is never zod-parsed for plugins, on either side — call parseWidgetConfig yourself, everywhere. | Settings and options |
options-updated | The DOM event the dashboard preview dispatches to re-render a mounted widget with new settings. | Runtime, mount and the DOM contract |
| Page assignment | The per-feed mapping (Feed → Page URLs → Page layouts) of a server-rendered page to a layout. Absent means Automatic. | Layout widgets |
| Page layout | A layout currently assigned to one of a feed’s server-rendered pages, rendered with stored bindings only. | Layout widgets |
pages | The layout-only summary field declaring which pages the layout may be assigned to. (0.12.0+) | Layout widgets |
parseWidgetConfig | The SDK call that strips null leaves, coerces stringified scalars, lifts legacy spacing and applies your schema. The only real gate on stored settings. | Config schema and UI schema |
| Playground | The dashboard page for one widget instance: settings form, live preview, embed code and — for a layout — the Slot Configuration panel. | Config schema and UI schema |
| Plugin | One React component (or up to 24 of them) plus SSR modules and a manifest, submitted as a source zip and published by HomeRunner. You host nothing. | Anatomy of a plugin |
| Preflight | The checks you run yourself before zipping — including the reviewer-only gates you would otherwise fail blind. | Preflight and submit |
prefill | Per-slot list of system widget keywords the dashboard binds at creation time, so a new layout opens populated. Creation-time only. (0.12.1+) | Layout widgets |
| Preset | A root-level one-click composition: a layout, its children and their slot bindings, created atomically from the widget picker. Max 8. (0.11.0+) | Layout widgets |
| Props registry | window.HRWidget.__WIDGET_PROPS__, keyed by data-hr-ssr-id — the exact props the server rendered with, replayed at hydration. | Runtime, mount and the DOM contract |
| Public API | /public-api/v1 — the read-mostly HomeRunner API your widget fetches from, client-side and inside the sandbox. | Public API |
| Publish | The admin action that uploads the built zip under a new version prefix, verifies it, repoints the live manifest, then warms and purges. It also un-suspends. | Publishing, versions and rollback |
registerPlugin / registerPluginWidget | The two spellings of the same registry write. registerPluginWidget(p, w, C, meta) is registerPlugin("{p}:{w}", C, meta). (suite form 0.11.0+) | Runtime, mount and the DOM contract |
| Registry key | Everything after the plugin: prefix — {slug} or {slug}:{widget}. The key you register under and the key mount looks up. | Runtime, mount and the DOM contract |
renderedSlots | The pre-rendered children a layout receives per slot. SSR omits an empty slot’s key entirely; CSR emits []. Always write renderedSlots?.[name] ?? null. | Layout widgets |
resolvedTheme | The light/dark prop your component receives. On a server-rendered page an auto widget is handed light and never corrected — branch in CSS, not on this prop. | Styling and theming |
| Review | A human read of your source zip ending in approved, changes requested or rejected. Nothing notifies you — poll My plugins. | From your laptop to a customer page |
| Rollback | Repointing the live version at an earlier published prefix. It does not un-suspend and it cannot target a pre-0.10.0 build. | Publishing, versions and rollback |
runtime.widgetCore | The widget-core version your build stamps into the manifest. Generated output — never hand-write it. Publishing requires ≥ 0.10.0. | Manifest: root fields |
| Scaffold | npx create-hr-plugin <slug> — the non-interactive project generator. --template single (default) writes a v1 project, --template suite a three-widget suite with a layout. Both ship a dev sandbox and an SSR preview. | Install and versions |
| Scenario | One of the dev sandbox’s six data states — default, empty, single, large, loading, error. Config endpoints are never gated by it. | Dev sandbox and mocking |
settings.slots | Record<slotName, widgetId[]> stored on the layout instance — the customer’s bindings, in render order. Not part of the manifest. | Layout widgets |
| Shadow root | The isolated DOM tree each content widget renders into. Also where your stylesheet is linked, and why @font-face and Radix’s a11y probes behave oddly. | Runtime, mount and the DOM contract |
shadowDOM | Root-level manifest flag, whole-plugin, no per-widget override. It must agree with what your bootstraps pass to mount(). | Manifest: root fields |
| Shared query client | The single React Query client a rendered page owns. Prefetch into it from dehydrateState; the ["widget", …] and ["feed", …] key spaces are the platform’s. | Fetching data |
| Slot | A named region a layout declares in slots[] and renders from renderedSlots. | Layout widgets |
| Slot binding | One widget id stored under a slot name. Bindings pointing at deleted widgets are dropped at render; nothing prunes them. | Layout widgets |
| Slot editor | The Slot Configuration panel the dashboard injects into a layout’s own form, over your zod slots field. | Composing a page |
| Slug | Your plugin’s id, used verbatim as the CDN slug, the keyword stem and the /p/ path segment. | Manifest: root fields |
| Source zip | What you submit: manifest, package.json, src/, widgets/, media/ — never dist/, node_modules/ or .git/. | Packaging and publishing rules |
spacing | The base schema’s compound margin/padding/width/maxWidth field (0.9.0+). It replaced the old width: {maxWidth, unit} pair; apply it with resolveWidgetSpacingStyle. | Config schema and UI schema |
| SSR | Server-side rendering: the renderer evaluates your SSR bundle, runs the data hooks, and embeds the markup and dehydrated cache into the page. | How a widget renders |
| SSR bundle | The CommonJS file at dist/[{widget}/]{name}-ssr.umd.js. The umd in the name is historical. | Component and SSR module |
ssr-entry.ts | The re-export barrel the SSR pass compiles. It exists iff the widget’s summary declares ssr.url. | Component and SSR module |
| SSR preview | npm run dev:ssr (:3003) — a local harness for the server path, one widget at a time. A suite names it: --widget {slug} (0.12.2+). It is not the renderer’s envelope; know the divergences before trusting it. | Previewing SSR locally |
| Sub-manifest | widgets/{slug}.manifest.json — one suite widget’s configSchema + uiSchema, generated by your own inject-schema script, fetched by the dashboard only. (0.11.0+) | Config schema and UI schema |
| Submission | Your source zip on Dashboard ▸ Plugins ▸ My plugins, and the five-state timeline it moves through. Re-submitting replaces whatever is in flight. | From your laptop to a customer page |
| Suite | The modern manifest shape: up to 24 widgets under one version, one review, one publish. New plugins should start here, even with one widget. (0.11.0+) | The two manifest shapes |
| Suspension | status = suspended — the platform-wide kill switch. /p/ 404s every file and every instance becomes a failure node. | Install, customers and kill switches |
| Symbolic component token | The uiSchema component name the dashboard resolves to a real form control. The canonical list — and what each must be paired with — is one table. | Config schema and UI schema |
| System widget | A first-party HomeRunner widget, addressed by a bare keyword (gallery, reviews) with no plugin: prefix; its assets are served from /w/, not /p/. | Keywords, assets and URLs |
uiSchema | The symbolic UI declaration beside your configSchema. It covers your own fields only; a top-level entry replaces the platform’s control wholesale. | Config schema and UI schema |
usePortalContainer | Hook returning the portal node inside your shadow root — the default target for dialogs and tooltips. | Portals and dialogs |
useShadowHost | Hook returning your widget’s host element (the [data-hr-widget] div). | Portals and dialogs |
useTopLevelPortal | Hook returning a portal target outside every shadow root, for dialogs the widget’s own box would clip. Pass it your token CSS. | Portals and dialogs |
| Version prefix | {env}/{slug}/{version}/ — the immutable key prefix every published object lives under. Bumping the version changes the prefix, not the /p/ URL. | Keywords, assets and URLs |
| Visibility | public (every account) or private (granted accounts only). Chosen by the reviewer; you cannot set or read it. | Install, customers and kill switches |
viteHomerunnerWidget | The Vite preset from @homerunner-next/widget-core/vite that supplies plugins, define, build and server. Drop it into vite.config.ts. | SDK reference |
| vm sandbox | The node:vm context the renderer evaluates your SSR bundle in: seven shared modules, stubbed browser globals, bounded execution. Not a security boundary. | Component and SSR module |
/w/ | The system-widget asset path (including runtime.iife.js). Plugin assets never live here; the scaffold’s local server only mirrors the shape. | Keywords, assets and URLs |
| Widget | One renderable unit on a page. Plugin widgets and system widgets share the same runtime, DOM and settings contracts. | Anatomy of a plugin |
| Widget icon | Optional per-widget icon (media/<file> or an absolute URL) shown on picker cards and the plugin page; falls back to the root icon, then a glyph. (0.12.0+) | Manifest: widget summary |
| Widget slug | A widget’s id, unique inside its plugin, ^[a-z0-9][a-z0-9-]*$, never dist / widgets / media. It names the dist/ directory and the keyword tail. | Manifest: widget summary |
| Widget summary | One object in widgets[] — the whole per-widget contract the renderer ever sees. (0.11.0+) | Manifest: widget summary |
WidgetProps | The props every widget component receives. Import it from /contracts rather than hand-rolling it; what each path actually supplies differs. | Component and SSR module |
widgetSchema | The nine-field base zod schema every widget extends (spacing, colorScheme, filter, lightModeColors, darkModeColors, font, language, section, customCss). The platform renders none of them for you. | Config schema and UI schema |
widgetType | A required, non-empty single-widget root field whose value is read by nothing. Routing uses the plugin id. | Manifest: root fields |
Numbers, caps and verbatim error strings are not repeated here — they live in Limits and error index, and symptoms are indexed in Troubleshooting.