Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

LevelWhat it meansWhere you see it
publish ERRORBlocks. 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 warningRecorded, 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 truncationAccepted 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

GateRuns whereAudits whatYou see it
Source-zip auditYour browser, before any bytes leave your machine (auditPluginZip)Your submission zip and its manifest.jsonImmediately, in Dashboard → Plugins → My plugins
Central submit / publish / rollback APICentral, after uploadSlug ownership, version monotonicity, manifest shape, widget-core stampAs a coded 4xx surfaced by the dashboard
Built-zip auditThe reviewer’s browser at publish (auditBuildZip)The zip the reviewer built from your sourceNever 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

LimitValueShapeEnforcementMessage / behaviour
Widgets per plugin24Suitepublish ERRORmanifest declares {n} widgets — the limit is 24 per plugin.
Presets per plugin8 (0.11.0+)Suitepublish ERRORmanifest "presets" must be a list of at most 8 entries.
prefill keywords per slot8 (0.12.1+)Suite (layouts)publish ERROR and silent runtime truncationmanifest 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 widget8 (0.12.0+)Bothpublish ERROR and silent runtime truncationmanifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets. normalizeFonts filters invalid entries and slices to 8.
Slot name length64 charsSuite (layouts)publish ERRORmanifest widget "{slug}" has an invalid or duplicate slot name.
expectedHeight0 <= n <= 10000 (px)Suitepublish ERRORmanifest 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 charsBothpublish ERRORmanifest "icon" must be a "media/<file>" path or an absolute http(s) URL.
Font ref length2048 charsBothpublish ERRORmanifest widget "{slug}" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix.
README ref length255 charsBothpublish ERROR{label} must be a "media/<file>.md" (or "media/<dir>/<file>.md") path.
README file size64 KBBothpublish 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 pagefirst 12Bothsilent truncation (display only)Extra tags are stored and served, just not rendered.
Submission notes5000 charsBothpublish 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.

LimitValueEnforcementMessage
Compressed size20 MBpublish ERRORZip 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 count2000publish ERRORZip contains too many entries ({n} > 2000).
Uncompressed total100 MBpublish ERRORZip expands to {n} MB — the limit is 100 MB.
Compression ratio100:1publish ERRORZip compression ratio exceeds 100:1.
Inflated for inspectionmanifest.json and package.json only, 2 MB eachsilentA larger manifest.json is treated as unreadable → manifest.json is missing from the zip root or is not valid JSON.
Per-entry error linesde-duplicated, first 12displayPer-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.

LimitValueEnforcementMessage
Zip size60 MBpublish ERRORZip is {n} MB — zip the built plugin WITHOUT node_modules (60 MB limit).
Per publishable file30 MBpublish ERROR"{path}" is over the 30 MB per-file limit.
Total publishable bytes150 MBpublish ERRORThe build expands to {n} MB — over the 150 MB limit.
Files per publish request1–200publish ERRORBetween 1 and 200 files per publish.
Directory depth under dist/, widgets/, media/exactly one levelsilent — file is not published, no errordist/a/b/c.js is dropped without a word.
File-name charset[A-Za-z0-9._-] per segmentpublish 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.

BoundValueGovernsWhat happens at the edge
SSR module-scope execution5000 ms (floor 250 ms; PLUGIN_SSR_EXEC_TIMEOUT_MS)Synchronous work in your SSR bundle’s module scopeThe 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 redirects0Fetching ssr.urlHTTP {status} redirect refused — SSR bundles must be served directly from their published URL
externalFetch redirects5 hops, each re-validatedYour server-side fetch[plugin] fetch blocked — more than 5 redirects.
Sandbox GET response cache60 s, ok-GET only, 50 entries LRUYour server-side fetch on non-dev rendersServer-side data can be up to 60 s stale. ?dev=1 renders bypass it (still enforced, just uncached).
Sub-manifest fetch5 s (AbortSignal.timeout(5000))Dashboard reading widgets/{slug}.manifest.jsonA timeout or non-2xx degrades to a schemaless config panel, not an error.
Build-manifest fetch (renderer)3 sResolving hashed CDN asset URLsFalls back to the /p/ proxy URL.
Build-manifest fetch (/p/ proxy)3 sMapping a logical file to its hashed twinServes the logical name; cached for the life of the edge instance when the dist location is a versioned prefix.
Shadow-root CSS load3000 mswaitForShadowLinks before revealing a migrated/ponyfilled rootThe widget is revealed unstyled rather than staying hidden.
Child widget registration10000 msA 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 cache48 entries LRUWarm SSR rendersVersioned (/{x.y.z}/dist/…, non-loopback) URLs never expire; legacy author-hosted URLs expire after 5 min.
Redis SSR bundle cacheskip above 1,000,000 bytesCold-start bundle textVersioned 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 TTL60 sWhich plugin/version the proxy resolves toOnly 403/404/410 from central are negative-cached; 429/5xx/timeouts keep serving the last good entry.
Composed page cache86400 s (24 h)The customer page your widget renders intoA 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-outconcurrency 4, 30 s in-request + 150 s afterHow fast a publish reaches live pagesFeeds 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:ssrSSRDevServerOptions 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.

MessageCauseFix
Zip is {n} MB — the limit is 20 MB.Compressed zip over 20 MBExclude node_modules/, dist/, .git/ — see Packaging and publishing rules
Not a zip archive (no end-of-central-directory record).Not a zip, or truncatedRe-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 uploadRe-create the archive
Corrupt zip: bad central-directory entry.Malformed archiveRe-create the archive
Zip contains too many entries ({n} > 2000).Over 2000 entriesPrune 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 nameSame
"{name}": node_modules must not be included — submit source only.node_modules at any depthDelete it before zipping
"{name}": .git must not be included..git at any depthSame
"{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 entryReplace with the real file
Zip expands to {n} MB — the limit is 100 MB.Uncompressed total over 100 MBPrune
Zip compression ratio exceeds 100:1.Zip-bomb-shaped archivePrune
manifest.json is missing from the zip root or is not valid JSON.No root manifest.json, invalid JSON, or over 2 MBSee Manifest: root fields
package.json is missing from the zip root or is not valid JSON.No root package.jsonThe reviewer builds from it; it is required
Warning No package-lock.json — the reviewer build will resolve dependencies fresh.No lockfileCommit 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:

MessageCauseFix
manifest.json is missing the required "id" field. (also "version", "name")Missing or non-stringManifest: 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.PATCHUse three parts
manifest.json is missing the required "widgetType" field.v1 shape with no widgetTypeSingle-widget only; suites must not carry it
manifest.json is missing "ssr.url" (the SSR bundle path).v1 shape with no ssr.urlv1 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:

MessageCause
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+):

MessageCause
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.

HTTPCodeMessageCause
422VALIDATION_ERRORfirst validator messagezip_sha256 not 64 hex chars, zip_size_bytes outside 1..20971520, notes over 5000 chars, zip_key over 512 chars
422INVALID_ZIP_KEYzip_key must match submissions/{slug}/<file>.zipThe upload key does not belong to this slug
422INVALID_MANIFESTManifest "id" must be lowercase alphanumeric with hyphens (it is the plugin slug).Slug shape
422INVALID_VERSIONManifest "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
422VERSION_NOT_GREATERVersion {v} must be greater than the published {current}.Every submission must strictly increase over the live version
409SLUG_RESERVEDThe plugin id "{slug}" is reserved. Contact an administrator to claim it.A pre-pipeline row owns the slug with no author
409SLUG_TAKENThe plugin id "{slug}" already belongs to another author. · … was just registered by another author.Someone else owns the slug
409NO_SUBMISSIONThis plugin has no submission in review.A review action with nothing in flight
409PLUGIN_EXISTSA 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.

MessageCause
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):

MessageMeaning
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

HTTPCodeMessageCause
409NOT_APPROVEDOnly an approved submission can be published (current: {status}).Publish attempted before review approval
422VERSION_MISMATCHRequested version {x} does not match the submitted {y}.
422MANIFEST_URL_INSECUREmanifest_url must be served over https.Non-https CDN (loopback is allowed for local rigs)
422MANIFEST_INVALIDvalidator messageThe published manifest fails central’s field validation
422MANIFEST_MISMATCHThe published manifest does not match the submission's slug/version.
422MANIFEST_URL_MISMATCHmanifest_url must address /{slug}/{version}/.The URL is not under the immutable version prefix
422MANIFEST_SHAPE_CHANGEDThis 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
422MANIFEST_WIDGET_CORE_MISSINGThe 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
422MANIFEST_WIDGET_CORE_TOO_OLDBuilt against widget-core {v}; the platform requires 0.10.0 or newer.
409ALREADY_LIVEVersion {v} is already live.Rollback to the current version
422ROLLBACK_MANIFEST_MISMATCHmanifest_url must address /{slug}/{version}/. · The manifest at manifest_url is {x}@{y}, not {a}@{b}.
422ROLLBACK_MANIFEST_UNREACHABLECould not fetch the manifest for {v}: {msg}The old prefix was deleted
422PLUGIN_NOT_APPROVEDOnly approved plugins can be enabled on a feed.Install attempted on a non-approved plugin
409PLUGIN_IN_USERemove 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.

MessageMeaning
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, "&gt;");
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=CauseFix
keyword does not resolve against the plugin manifestThe 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 slugThe two manifest shapes
plugin layout widget rendered as content — needs a layout entry with slotsA category: "layout" widget was placed as ordinary contentLayout widgets
no plugin SSR URL in manifestA v1 manifest with no ssr.url. In v1 this is a failure, not a CSR-only widget — only a suite summary may declare ssr: falseManifest: root fields
bad plugin asset URL: {msg}A relative asset path with no known manifest URL, or an unparseable URLKeywords, 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 exportComponent 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 renderToStringParse 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 allowedA slot binding points at another layoutLayouts cannot nest
slot module load failed: {code}One slot child’s module failedOnly 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 a layout SSR data prep failed breadcrumb.

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.

MessageCause
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 URLYour 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:

ValueSet byMeaning
mount-failedmount() per-embed catch, or a deferred hydrate/render rejectionThat embed threw; other embeds on the page still mounted
render-failedWidgetRenderBoundaryYour component threw during render; the error state is shown instead of a blank node. Cleared when a changing resetKey retries
rate-limitedClientWrapperForWidgetThe config fetch returned 429. Removed on a successful retry
widget-fetch-failedClientWrapperForWidgetThe config fetch failed for any other reason. Removed on a successful retry
property-not-foundthe rendered error element onlyA configured property slug does not resolve

Console strings, by prefix:

StringMeaning
Widget ID is requiredThe 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 10000msA 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.

StatusBodyCause
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
302Success: Location is the hashed twin, Cache-Control: public, max-age=60, stale-while-revalidate=30, Access-Control-Allow-Origin: *

10. Dashboard config panel

MessageCauseFix
Plugin "{slug}" not enabled for this feed.The plugin is not installed on the feedInstall, 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 errorConfig 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 tripConfig schema and UI schema

11. Local build and dev tooling

Build CLI (hr-widget-build, run for you by npm run build):

MessageCause
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:

MessageCause
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:

MessageCause
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 configSchema warnings 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 the accepts predicate runs, and accepts is 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. prefill keywords 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. externalFetch is 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. replacedBy is 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 bare X.Y.Z path segment. Publishing one silently drops you to TTL-bound caching and /p/-proxied asset URLs everywhere. Use plain three-part versions.