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.