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

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

LevelMeaning on this page
publish ERRORBlocks. Source-zip errors block the upload in your browser; built-zip errors block the publish after review. Both are hard failures.
publish warningShown, never blocks.
silent runtime truncationAccepted, 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 zipBuilt zip
Made byYouThe reviewer, from your source
Audited byauditPluginZip (your browser, before any bytes leave)auditBuildZip (the reviewer’s browser, before any bytes reach the CDN)
Containsmanifest.json, package.json, src/, widgets/, media/, config filesYour source plus the whole dist/ tree
Must NOT containdist/, node_modules/, .git/node_modules/ (everything unrecognised is ignored)
Size ceiling20 MB60 MB
Uploaded toA private bucket, presigned PUT, never through a serverThe public plugin CDN, one presigned PUT per file
Failure meansYou fix and re-zipYour 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.

EntryRequiredRuleOn violation
manifest.jsonBoth — yesMust parse as a JSON objectpublish ERROR: manifest.json is missing from the zip root or is not valid JSON.
package.jsonBoth — yesMust parse as a JSON objectpublish ERROR: package.json is missing from the zip root or is not valid JSON.
package-lock.jsonBoth — noRecommended: the reviewer build otherwise resolves freshpublish 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.

RuleScopeOn violation
No absolute pathsAny entry"<name>": absolute paths are not allowed.
No .. traversalAny segment"<name>": path traversal is not allowed.
No node_modulesAny depth (path segment match)"<name>": node_modules must not be included — submit source only.
No .gitAny 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 symlinksAny 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.

LimitValueOn violation
Zip size20 MBZip is <N.N> MB — the limit is 20 MB.
Entry count2000Zip contains too many entries (<N> > 2000).
Uncompressed total100 MBZip expands to <N> MB — the limit is 100 MB.
Compression ratio100:1Zip compression ratio exceeds 100:1.

Structural failures short-circuit the whole audit:

ConditionOn violation
No end-of-central-directory recordNot a zip archive (no end-of-central-directory record).
Zip64 archiveZip64 archives are not supported for plugin submissions.
Central directory runs past EOFCorrupt zip: central directory extends past the end of the file.
Malformed central-directory entryCorrupt 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.

FieldTypeRequiredRuleOn violation
idstringBoth — yesNon-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.
versionstringBoth — yesNon-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).
namestringBoth — yesNon-emptypublish ERROR: manifest.json is missing the required "name" field.
widgets[]arraySuite — yesA non-empty array switches the manifest to the suite shape and runs the per-widget summary auditpublish ERROR, see Manifest: widget summary
widgetTypestringSingle-widget — yesNon-emptypublish ERROR: manifest.json is missing the required "widgetType" field.
ssr.urlstringSingle-widget — yesAny stringpublish ERROR: manifest.json is missing "ssr.url" (the SSR bundle path).
assets.jsstringSingle-widget — yesAny stringpublish 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.

DirectoryGenerated byMust 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.jsonYour build:manifest step, if you have oneCommit them unless npm run build regenerates them. They must exist in the built zip.
dist/hr-widget-buildNo — banned. The reviewer rebuilds it.
node_modules/npm installNo — 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:

FieldAccepted shapeMax
icon, cover, widgets[].iconmedia/<file> (flat, no subdirectory) or an absolute http(s):// URL2048 chars
screenshots[]media/<file> only — no absolute URL, no subdirectoryno path cap
readme, widgets[].readmemedia/<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.

CodeHTTPMeaningMessage
VALIDATION_ERROR422Malformed submission envelope (bad sha256, size, notes over 5000 chars)Laravel validation text
INVALID_MANIFEST422A manifest field failed the server-side contracte.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_KEY422The uploaded object is not under your slug’s folderzip_key must match submissions/<slug>/<file>.zip
INVALID_VERSION422Not parseable semver, or not three-partManifest "version" is not valid semver: <X> / Manifest "version" must be MAJOR.MINOR.PATCH semver: <X>
VERSION_NOT_GREATER422Not strictly greater than what is publishedVersion <X> must be greater than the published <Y>.
SLUG_RESERVED409The slug exists with no owner (a legacy admin-registered row)The plugin id "<X>" is reserved. Contact an administrator to claim it.
SLUG_TAKEN409The slug belongs to another authorThe 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:

FieldLimitOn violation
id255 charsManifest "id" exceeds 255 characters.
name255 charsManifest "name" exceeds 255 characters.
version64 charsManifest "version" exceeds 64 characters.
description4000 charsManifest "description" must be a string of at most 4000 characters.
author.name, author.url255 chars eachManifest "author.<field>" must be a string of at most 255 characters.
widgets[]24 entriesManifest 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.

RuleEnforcementOn violation
Semver shape (X.Y.Z, optional -pre and +build)publish ERROR, browsermanifest "version" ("<X>") is not valid semver (e.g. 1.2.0).
Exactly three numeric parts — 1.0 and 1 are refusedpublish ERROR, API INVALID_VERSIONManifest "version" must be MAJOR.MINOR.PATCH semver: <X>
Strictly greater than the currently published versionpublish ERROR, API VERSION_NOT_GREATERVersion <X> must be greater than the published <Y>.
Never re-publish an existing versionpublish 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.1 are accepted by both the browser audit and the API, and they publish — but every immutability optimisation in the platform requires a bare X.Y.Z path 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/.

PathPublished?
manifest.jsonYes
dist/acme.iife.jsYes — flat single-widget layout
dist/hero/hero.iife.jsYes — one widget directory deep
dist/hero/chunks/vendor.jsNo. Silently dropped: no error, no warning.
widgets/hero.manifest.jsonYes
media/icon.svg, media/widgets/hero.mdYes
src/**, package.json, anything elseNo

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

LimitValueEnforcementOn violation
Built zip60 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.
Client bundle1.5 MBpublish warningdist/<name> is <N.NN> MB.
Stylesheet512 KBpublish warningdist/<name> is <N.NN> MB.
SSR bundle1 MBpublish warningdist/<name> is <N.NN> MB — over the renderer's 1 MB cache threshold (slower cold SSR).
README (each)64 KBpublish 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.

GateShapeOn violation
Root manifest.json present and parseableBothmanifest.json not found at the zip root — zip the plugin folder AFTER building.
manifest.id matches the submissionBothmanifest id "<X>" does not match the submission's "<Y>".
manifest.version matches the submissionBothmanifest version "<X>" does not match the submitted "<Y>".
runtime.widgetCore present and semverBothmanifest 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.0BothBuilt 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}Bothdist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build.
Every declared asset exists under dist/Bothdist/<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 manifestBothThe build manifest has no hashed twin for <name>.
The hashed twin file itself is in the zipBothHashed twin dist/<hashed> is missing from the zip.
The client bundle contains the literal HRPluginsBoth<label>'s client bundle never registers on window.HRPlugins — the widget would never mount.
Each declared sub-manifest is present and valid JSONSuitewidgets/<x>.manifest.json (<label>'s sub-manifest) is missing from the zip or invalid JSON.
assets.js declaredSingle-widgetmanifest is missing "assets.js".
ssr.url declaredSingle-widgetmanifest is missing "ssr.url".
icon / cover reference shapeBothmanifest "<field>" must be a "media/<file>" path or an absolute http(s) URL.
Declared icon / cover file presentBothDeclared <field> <ref> is missing from the zip.
widgets[].icon reference shapeSuitewidget "<slug>" icon must be a "media/<file>" path or an absolute http(s) URL.
Declared widgets[].icon file presentSuiteDeclared widget "<slug>" icon <ref> is missing from the zip.
screenshots[] reference shapeBothmanifest screenshot "<X>" must be a "media/<file>" path.
Declared screenshot presentBothDeclared screenshot <ref> is missing from the zip.
README reference shapeBoth<label> must be a "media/<file>.md" (or "media/<dir>/<file>.md") path.
Declared README presentBothDeclared <label> <ref> is missing from the zip.
README ≤ 64 KBBoth<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.jshero/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 every widgets/*.manifest.json (suite) contains a configSchema whose properties include colorScheme and spacing.

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/.mjstext/javascript; charset=utf-8, .csstext/css; charset=utf-8, .json/.mapapplication/json; charset=utf-8, .mdtext/markdown; charset=utf-8, .svgimage/svg+xml, .woff2font/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