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

Keywords, assets and URLs

Everything your plugin ships — bundles, stylesheets, images, fonts — is addressed twice: once by the keyword a customer’s widget row stores, and once by a URL the platform derives from your manifest. This page is the normative reference for both derivations, on every path.

Enforcement levels used below are defined once in limits-and-errors.md: publish ERROR (the upload or the publish is refused), publish warning (the reviewer sees it, you do not), silent runtime truncation (the value is dropped or capped with no message) and advisory (unvalidated) (nothing checks it; you find out in production).


1. The keyword grammar

A plugin widget is addressed everywhere — stored settings, embed snippets, the renderer, the registry on window.HRPlugins — by a single string.

ShapeKeywordRegistry key
Single-widget (v1)plugin:{pluginSlug}{pluginSlug}
Suiteplugin:{pluginSlug}:{widgetSlug}{pluginSlug}:{widgetSlug}

registryKey is everything after the plugin: prefix, unchanged. It is the key your client bundle registers under and the key mount() looks up — see runtime-and-mount.md.

// Source: packages/homerunner-widget-core/src/urls.ts:34-46
export function parsePluginKeyword(keyword: string): PluginKeywordParts | null {
  if (!keyword.startsWith(PLUGIN_WIDGET_PREFIX)) return null;   // "plugin:"
  const registryKey = keyword.slice(PLUGIN_WIDGET_PREFIX.length);
  if (!registryKey) return null;
  const sep = registryKey.indexOf(":");
  if (sep === -1) {
    return { pluginSlug: registryKey, widgetSlug: null, registryKey };
  }
  const pluginSlug = registryKey.slice(0, sep);
  const widgetSlug = registryKey.slice(sep + 1);
  if (!pluginSlug || !widgetSlug) return null;
  return { pluginSlug, widgetSlug, registryKey };
}

Parsing rules — Both shapes:

InputResultWhy
plugin:acme{acme, null, "acme"}No separator → single-widget form
plugin:acme:hero{acme, hero, "acme:hero"}Split at the first colon
gallerynullNot a plugin keyword — a system widget
plugin:nullEmpty registry key
plugin::heronullEmpty plugin slug
plugin:acme:nullEmpty widget slug

A null parse is fail-closed: nothing resolves, nothing mounts, and on a server-rendered page the renderer logs [plugin] No manifest entry for "<keyword>". Skipping. and replaces the widget with a hidden error breadcrumb. Both slugs are constrained to ^[a-z0-9][a-z0-9-]*$ at publish (publish ERROR), so a published keyword can never contain a third colon — see manifest-root.md and widget-summary.md.


2. Derived file names

You never invent bundle names. The SDK’s Vite preset emits fixed names, and every consumer — the renderer, the /p/ proxy, the client runtime — recomputes the same names from the keyword.

// Source: packages/homerunner-widget-core/src/urls.ts:55-64
export function pluginWidgetDistFile(parts, kind: "iife" | "css" | "ssr"): string {
  const base = parts.widgetSlug ?? parts.pluginSlug;
  const dir = parts.widgetSlug ? `${parts.widgetSlug}/` : "";
  const file =
    kind === "iife" ? `${base}.iife.js` : kind === "css" ? `${base}.css` : `${base}-ssr.umd.js`;
  return `${dir}${file}`;
}
ArtifactSingle-widget (v1)Suite
Client IIFEdist/{slug}.iife.jsdist/{widget}/{widget}.iife.js
Stylesheetdist/{slug}.cssdist/{widget}/{widget}.css
SSR bundle (CJS)dist/{slug}-ssr.umd.jsdist/{widget}/{widget}-ssr.umd.js
Bundled assetdist/{name}-{vitehash}.{ext}dist/{widget}/{name}-{vitehash}.{ext}
Source map…{file}.map…{file}.map

The -ssr.umd.js suffix is historical: the file is CommonJS, not UMD.

Rule (Both, publish ERROR). Your manifest’s assets.js, assets.css and ssr.url must point at files the build actually emitted. The built-zip audit checks each one and fails with dist/<name> (<label>'s <kind>) is missing from the zip. Do not rename outputs.

dist/manifest.json — the build manifest

After the last build pass the SDK walks dist/ recursively, writes a content-hashed twin next to every file, and records the mapping.

// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/dist/manifest.json (real, v1.3.3)
{
  "buildHash": "77328e0a",
  "generatedAt": "2026-09-07T06:25:25.567Z",
  "files": {
    "booking-cta/booking-cta.css": "booking-cta/booking-cta-a830c68f.css",
    "booking-cta/booking-cta.iife.js": "booking-cta/booking-cta.iife-ca74009c.js",
    "pdp-frame/pdp-frame-ssr.umd.js": "pdp-frame/pdp-frame-ssr.umd-5872d9b8.js",
    "pdp-frame/pdp-frame.css": "pdp-frame/pdp-frame-9992937d.css",
    "pdp-frame/pdp-frame.iife.js": "pdp-frame/pdp-frame.iife-be6fddd4.js",
    "stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png",
    "stay-hero/stay-hero-ssr.umd.js": "stay-hero/stay-hero-ssr.umd-0b04d970.js",
    "stay-hero/stay-hero.css": "stay-hero/stay-hero-f4a2cffb.css",
    "stay-hero/stay-hero.iife.js": "stay-hero/stay-hero.iife-f2a3fe21.js"
  }
}
PropertyRuleEnforcement
Keysdist-relative path — hero/hero.iife.js for a suite, a bare filename for v1generated
Values{base}-{sha8}{ext}, first 8 hex of the file’s SHA-256generated
manifest.json itselfexcluded from filesgenerated
*.mapexcluded from files — no hashed twin, not /p/-servable, but still uploaded to the CDN and reachable directlygenerated
buildHashSHA-256 (8 hex) of the sorted files mappublish forensics only — no serving path reads it
Presence in the built ziprequiredpublish ERRORdist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build.
Hashed twin for every declared assetrequiredpublish ERRORThe build manifest has no hashed twin for <name>. / Hashed twin dist/<hashed> is missing from the zip.

Three consumers read this file: the /p/ proxy (to pick a 302 target), the renderer (to emit a direct content-addressed URL) and the built-zip audit. See packaging-and-publishing.md.

Path normalisation: dist-relative name

Every consumer reduces a manifest asset reference — absolute URL or path — the same way before looking it up.

// Source: packages/homerunner-widget-core/src/urls.ts:107-118 (mirrored verbatim in
// packages/homerunner-embeddables/api/plugin-asset.ts:194-204 and
// packages/homerunner-renderer/lib/render-utils.tsx:177-188)
export function distRelativeAssetName(urlOrPath: string): string {
  const clean = urlOrPath.split("#")[0]!.split("?")[0]!;
  const marker = clean.lastIndexOf("dist/");
  if (marker !== -1 && (marker === 0 || clean[marker - 1] === "/")) {
    const rel = clean.slice(marker + "dist/".length);
    if (!rel.split("/").some((segment) => /^\.+$/.test(segment))) return rel;
  }
  return extractAssetFilename(clean);           // last path segment
}

Consequences you can rely on (Both): everything after the last dist/ survives, including the widget directory; a path with no dist/ segment collapses to its basename; a path containing a dot-only segment (dist/../evil.js) also collapses to its basename, so traversal can never escape the version prefix.


3. Where published files live

Published objects are keyed {env}/{slug}/{version}/{path} in one shared bucket, served from the plugin CDN (R2_PUBLIC_BASE_URL; https://plugins.homerunner.io in production).

// Source: src/lib/r2.ts:64-80 + src/lib/plugin-zip.ts:532-533,904-911
{env}/{slug}/{version}/manifest.json                    public, max-age=300
{env}/{slug}/{version}/dist/manifest.json               public, max-age=300
{env}/{slug}/{version}/dist/<file>                      public, max-age=31536000, immutable
{env}/{slug}/{version}/dist/{widget}/<file>             immutable  (bundles, css, images, fonts, .map)
{env}/{slug}/{version}/widgets/{widget}.manifest.json   immutable
{env}/{slug}/{version}/media/<file>                     immutable  (icon, cover, screenshots, README)
{env}/{slug}/{version}/media/{dir}/<file>.md            immutable  (per-widget READMEs)

# {env} = prod (production dashboard) | dev (preview deployments) | local (a laptop)

Exactly two objects are not immutable: the root manifest.json and dist/manifest.json, both public, max-age=300. Everything else — including media/ and .map files — is public, max-age=31536000, immutable.

The {env} segment is mandatory in every URL. Verifying a publish:

# Source: src/lib/r2.ts:70-80 (cdnPluginUrl) — the {env} prefix is never optional
BASE=https://plugins.homerunner.io/prod/pdp-suite/1.3.3
curl -s  $BASE/manifest.json      | jq .version
curl -s  $BASE/dist/manifest.json | jq .buildHash
curl -sI https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js | head -1  # 302

Version bumping, immutability and rollback are owned by publish-versions-and-rollback.md.


4. Two serving paths

There is no single origin and no single resolution rule. Which URL a browser sees depends on how the page was built.

PathWho uses itURL emittedRedirectCached
Direct content-addressed CDNServer-rendered / composed customer pages, for pipeline-published plugins{cdn}/{env}/{slug}/{version}/dist/[{widget}/]{file}-{sha8}.{ext}none1 year, immutable
/p/ proxyCSR embed snippets, the playground, the client runtime’s default CSS resolution, legacy author-hosted plugins, and any lookup failure{proxy}/p/{slug}/{distRelativeFile}one 302 to the CDN60 s + SWR 30 s on the 302; the target is immutable

Because the direct path has no pointer indirection, a cached page always loads the exact bundle its HTML was rendered with — there is no window in which new HTML meets an old bundle.

How the renderer resolves an asset

// Source: packages/homerunner-renderer/lib/render-utils.tsx:196-273
1. file      = distRelativeName(url)                    // "hero/hero.iife.js"
2. anchor    = resolvePluginAssetUrl(manifest_url, widget.assets.js)
3. if anchor does NOT match /\/\d+\.\d+\.\d+\/dist\/(?:[^/]+\/)?[^/]+$/
   or its host is localhost/127.0.0.1  ->  go to step 7
4. distBase  = anchor up to and including "/dist"
5. GET {distBase}/manifest.json  (3 s timeout; cached for the life of the
   process, failures negative-cached for the life of the process)
6. if files[file] exists  ->  {distBase}/{files[file]}      DONE
7. fallback: {proxyBase}/p/{slug}/{file}
   proxyBase = NEXT_PUBLIC_WIDGET_ASSET_BASE_URL
             ?? NEXT_PUBLIC_PROXY_BASE_URL
             ?? "https://assets.homerunner.io"

Two rules follow, and both surprise authors:

  • Anything getStaticAssets() returns is rewritten (Both). Absolute URLs are not passed through — they are reduced to a dist-relative name by step 1 and re-resolved. You cannot self-host an asset from getStaticAssets. The only pass-through channel for a third-party stylesheet URL is assets.fonts (§9). See component-and-ssr-module.md.

  • Pre-release versions lose the direct path. 1.0.0-rc.1 publishes, but every immutability detector requires a bare X.Y.Z path segment, so a pre-release prefix falls back to the /p/ proxy and to TTL caching everywhere.

    Not supported yet. Nothing warns you about this. Ship plain MAJOR.MINOR.PATCH versions — enforcement level today is advisory (unvalidated).

Neither the worker nor the dashboard puts crossorigin on a plugin script tag on either path (buildScriptsHtml, src/lib/homerunner/cf-worker.ts:2485-2493). See §6.


5. The /p/ plugin asset proxy

GET https://assets.homerunner.io/p/{slug}/{file}

There is no version segment and there never was one. {file} is the dist-relative name: flat (acme.iife.js) for a v1 plugin, widget-nested (stay-hero/stay-hero.iife.js) for a suite. Bumping your version does not change this URL — it changes the 302 target.

ConditionStatusBody / headers
No / after the slug400{"error":"Missing path: /p/{slug}/{file}"}
Slug unknown, not approved, or suspended404{"error":"Plugin not found"}
Manifest has no usable assets.js to anchor dist/500{"error":"Could not resolve plugin dist location"}
File not on the allow-list404{"error":"Asset not in plugin manifest"}
Served302Location: {distOrigin}{distBase}/{hashedFile}, Cache-Control: public, max-age=60, stale-while-revalidate=30, Access-Control-Allow-Origin: *

The allow-list

A file serves only if either condition holds (Both):

  1. it is a manifest-declared asset — root assets.js / assets.css / ssr.url, or any widget summary’s assets.js / assets.css / ssr.url — compared after dist-relative normalisation; or
  2. it is a key of your own dist/manifest.json files map. This is how SDK-emitted images, font files and ?url imports reach the browser.

Hashed twins are map values, never keys, so a hashed filename is only reachable through the 302 — not by requesting it from /p/ directly.

assets.fonts entries are not in the declared list (manifestKnownFilename does not read them). A dist-relative font file therefore serves only because rule 2 covers it — which it does, because everything under dist/ except manifest.json and *.map lands in the files map.

Suite-only rule. For a manifest with widgets[], a file admitted by rule 2 must sit under a first-level directory whose name equals a widget slug still present in the manifest central serves:

// Source: packages/homerunner-embeddables/api/plugin-asset.ts:276-287
const slash = filePath.indexOf('/');
if (slash <= 0) return false;
const dir = filePath.slice(0, slash);
return widgets.some((w) => w?.slug === dir);

Two consequences:

  • A suite asset emitted at the top of dist/ (no widget directory) can never serve through /p/. Keep bundled assets inside the widget’s own folder — which the SDK’s assetFileNames does for you (§8).
  • Disabling a suite widget with the platform kill switch 404s that widget’s entire dist/{widget}/ directory — bundles, images and fonts — not just its picker entry, within the proxy’s 60 s TTL. See install-and-kill-switches.md.

Proxy caching internals

CacheTTLNotes
Plugin lookup (central /public-api/v1/plugins/{slug})60 s per edge instanceOnly 403/404/410 are negative-cached; 429, 5xx and timeouts keep serving the last good entry
Build manifest, immutable prefix (/{X.Y.Z}/dist on a non-loopback host)life of the edge instanceThe prefix can never change, so neither can the map
Build manifest, legacy/author-hosted60 sFiles can be overwritten in place
Upstream fetches3 s timeoutA failed refresh serves the last good map (stale-on-error)
No build manifest at allThe 302 falls back to the logical filename, so a plugin that shipped none still serves

Legacy alias

{dashboard}/api/plugin-assets/{slug}[@{version}]/{file} still 302s onto /p/{slug}/{file}, stripping any @version. It exists only for IIFEs cached at customer edges before the proxy moved, it sends no Access-Control-Allow-Origin, and you must never emit it.


6. The crossorigin trap

Never put crossorigin="anonymous" on a /p/ script tag.

/p/ answers with ACAO: *, but it 302s off-host to the plugin CDN, and the redirect target sends no Access-Control-Allow-Origin. In CORS mode the browser therefore rejects the final response, the script never executes, and your plugin never registers on window.HRPlugins — with no error you can attribute to the cause.

<!-- Source: src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:63-85 -->
<!-- runtime: same-host redirect chain, ACAO on every hop → crossorigin is safe -->
<script src="https://assets.homerunner.io/w/runtime.iife.js" crossorigin="anonymous"></script>

<!-- plugin bundle: NO crossorigin attribute, deliberately -->
<script src="https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js"></script>

<div data-hr-widget-container>
  <div id="{widgetId}" data-hr-widget="plugin:pdp-suite:stay-hero" data-hr-min-height="160"></div>
</div>

The cost of leaving it off is that cross-origin script errors from your bundle reach the host page’s window.onerror as the opaque "Script error.". That is the accepted trade.

CSP

Advisory (unvalidated) — nothing on the platform inspects a customer’s CSP.

A customer page that enforces Content-Security-Policy needs both origins, because the two serving paths use different ones:

DirectiveNeedsWhy
script-srcthe asset origin (assets.homerunner.io)runtime IIFE, /p/ entry point
script-srcthe plugin CDN origin (plugins.homerunner.io)direct URLs on composed pages, and the /p/ 302 target
style-srcboth of the abovewidget CSS follows the same two paths
style-src / font-srcevery absolute host in your assets.fonts (e.g. fonts.googleapis.com, fonts.gstatic.com)declared fonts are injected as <link rel="stylesheet">
img-src / font-srcdata:assets ≤ 4096 bytes are inlined as data URLs (§8)

Both origins are deployment-configurable (NEXT_PUBLIC_WIDGET_ASSET_BASE_URL, R2_PUBLIC_BASE_URL); confirm the live values with a curl -sI on one of your own bundles before handing a customer a CSP snippet.


7. SDK URL helpers

These live on the @homerunner-next/widget-core/urls subpath and are re-exported from the root barrel. Full export inventory: sdk-exports.md.

ExportSignatureBehaviour
PLUGIN_WIDGET_PREFIX"plugin:"
parsePluginKeyword(keyword: string) => PluginKeywordParts | null§1
pluginWidgetDistFile(parts, kind: "iife" | "css" | "ssr") => string§2
DEFAULT_PROXY_BASE_URL"https://assets.homerunner.io"The asset origin, not the dashboard
isAbsoluteUrl(url: string) => booleanMatches ^https?:// only. Protocol-relative //host/x and data: count as RELATIVE
resolvePluginAssetUrl(manifestUrl: string | undefined, urlOrPath: string) => stringManifest URL first. Absolute input passes through; relative input resolves via new URL(urlOrPath, manifestUrl); throws when relative and manifestUrl is undefined
distRelativeAssetName(urlOrPath: string) => string§2
extractAssetFilename(urlOrPath: string) => stringLast path segment, query and hash stripped
getPluginAssetUrl(slug, file, proxyBaseUrl = DEFAULT_PROXY_BASE_URL) => string{base}/p/{slug}/{file}, trailing slash stripped

The throw, verbatim:

// Source: packages/homerunner-widget-core/src/urls.ts:93-96
Cannot resolve relative plugin asset "<x>": manifest URL not known. Ship absolute URLs in the
manifest, or pass the manifest URL when fetching.

On a server-rendered page that throw is caught and becomes a per-widget failure with the reason bad plugin asset URL: … — the widget disappears, the page is marked degraded, the rest of the page still renders.

Because path-only references resolve against the manifest’s own URL, a leading slash escapes your version prefix entirely (/shared/foo.js{cdn}/shared/foo.js). Never start a manifest asset path with /. In a Suite manifest the audit rejects it for you (publish ERROR — the confined-relative-path rule, see widget-summary.md); in the Single-widget shape the root assets/ssr paths are checked only for presence, so a leading slash is advisory (unvalidated) and 404s in production.


8. Bundled assets

Requires widget-core 0.12.0+.

Import images, icons and font files from your widget folder like any Vite project. The SDK rewrites the reference so it resolves against wherever the bundle was actually loaded from.

// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/widget.tsx:4,19
import heroBg from "./hero-bg.png";
// …
<img className="shero-bg" src={heroBg} alt="" data-testid="stay-hero-bg" />
/* Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/stay-hero.css:7 */
background: linear-gradient(120deg, #4f46e5, #9333ea), url(./hero-bg.png);
RuleValueShapeEnforcement
Inline thresholda file of a listed extension ≤ 4096 bytes becomes a data: URL; above it, a real file is emittedBothbuild behaviour
Emitted name (JS-referenced)dist/{widget}/{name}-{vitehash}{ext} (suite) · dist/{name}-{vitehash}{ext} (v1)Bothbuild behaviour
Emitted name (CSS url())same; the reference stays relative (url(./hero-bg-Cr1v4Zys.png)) and the browser resolves it against the stylesheet’s response URLBothbuild behaviour
Listed extensionspng jpg jpeg gif webp avif svg ico bmp woff woff2 ttf otf eot mp3 mp4 webm ogg wav pdfBothbuild behaviour
Must live under dist/{widget}/ to serve via /p/yesSuitesee §5 — silent 404 otherwise

Vite’s library mode inlines everything unless a reference carries ?no-inline; the preset appends that suffix for you above the threshold, which is why the limit works here at all. The consequence: an asset import whose extension is not on that list is inlined as a data: URL at any size, because nothing tags it ?no-inline. Keep unusual asset types off the import graph and fetch them instead.

How the URL is computed at runtime

The build prepends a plain-ES5 banner to both the client IIFE and the SSR bundle:

// Source: packages/homerunner-widget-core/src/vite/plugin-assets.ts:57-69 (formatted;
// see the same code minified at the head of pdp-suite/dist/stay-hero/stay-hero.iife.js)
var __hrAssetBase_pdp_suite_stay_hero = (function () {
  try {
    if (typeof document !== "undefined" && document.currentScript && document.currentScript.src) {
      var u = new URL(document.currentScript.src);
      var segs = u.pathname.split("/");
      segs.splice(-2);                       // 2 for a suite bundle, 1 for a v1 bundle
      return u.origin + segs.join("/") + "/";
    }
  } catch (e) {}
  return (typeof globalThis !== "undefined" && globalThis.__HR_PLUGIN_ASSET_BASE__) || "";
})();
function __hrAsset_pdp_suite_stay_hero(f) {
  try { return new URL(f, __hrAssetBase_pdp_suite_stay_hero).href; } catch (e) { return f; }
}

The import site becomes __hrAsset_…("stay-hero/hero-bg-Cr1v4Zys.png") in both builds. The base resolves to the plugin’s …/dist/ directory:

ContextBase comes fromValue
Browserdocument.currentScript.src minus 2 path segments (suite) or 1 (v1)…/{env}/{slug}/{version}/dist/
SSR sandboxglobalThis.__HR_PLUGIN_ASSET_BASE__, set by the renderer from the SSR bundle’s own URL…/{env}/{slug}/{version}/dist/
npm run devdocument.currentScript.srcthe Vite dev origin

document.currentScript is only valid during the bundle’s synchronous top-level execution — the banner runs there and captures the base once, so a later dynamic import cannot break it.

Not supported yet. Nothing verifies that a dist/-relative reference actually shipped. Asset presence is advisory (unvalidated) at publish: only manifest-declared assets (assets.js, assets.css, ssr.url, sub-manifests, media/ refs) are checked. A bundled asset lost by a bad build 404s in production with no earlier signal.


9. Web fonts — assets.fonts

Requires widget-core 0.12.0+.

@font-face is ignored inside a shadow root. A stylesheet loaded into your widget’s shadow root can reference a font family but can never define one. That is the entire reason this field exists: declared fonts are injected at document level, where the browser honours them.

// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/manifest.json (real, v1.3.3)
{
  "slug": "pdp-frame",
  "assets": {
    "js": "dist/pdp-frame/pdp-frame.iife.js",
    "css": "dist/pdp-frame/pdp-frame.css",
    "fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
  }
}
FieldTypeRequiredRuleOn violation
widgets[].assets.fontsstring[]Optional (Suite)Max 8 entriespublish ERRORmanifest widget "<slug>" "assets.fonts" must be a list of at most 8 stylesheets.
widgets[].assets.fonts[]stringAbsolute http(s)://…, or a relative path with no leading /, no scheme: prefix, no \, no .. segment, ≤ 2048 charspublish ERRORmanifest widget "<slug>" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix.
root assets.fontsstring[]Optional (Single-widget)Same value rulessilent runtime truncation — the v1 root is not audited at publish; normalizeFonts drops invalid entries and slices to 8 at resolve time
File presence for a relative entrymust exist under dist/advisory (unvalidated) — a missing file 404s at runtime

normalizeFonts also applies on the suite path: anything that slips past the audit is dropped, and an over-long list is capped at 8 with no message.

The two injection paths

PathWho injectsWhereDedupe key
Client (CSR embeds, hydration)the ES5 banner compiled into your client IIFEdocument.headlink[data-hr-font="{href}"], shared across every widget and plugin on the page
Server (composed pages)the renderer appends resolved fonts to the widget’s css array; the worker turns that array into <link rel="stylesheet"> in <head><head>none — the renderer owns the list

The SSR bundle carries no font loader (loadFonts: false); the renderer does that job. This is why you must declare fonts in the manifest rather than injecting them yourself: on a server-rendered page your client code has not run at first paint.

Resolution differs per entry kind:

  • Absolute http(s) URLs pass through unchanged on both paths. This is the only channel in the whole plugin system that lets you reference a third-party stylesheet URL verbatim.
  • Relative entries go through §4’s hashed-CDN-first resolution, exactly like your bundles. The client banner strips a leading dist/ and resolves the rest against the asset base.

Two behaviours worth knowing:

  • Declared fonts share the same css array as your widget stylesheet, so they also appear as <link> tags inside the Declarative Shadow DOM template — harmless duplication, but you will see them there when reading page source. (HR_DSD=inline never inlines a fonts.googleapis URL; it stays a link.)
  • The build reads assets.fonts from manifest.json at config time and bakes the list into the banner, filtering only for non-empty strings before slicing to 8 — it does not run the reference validator. Editing manifest.json without rebuilding leaves a stale list in the client bundle, and a malformed entry can ship in the banner while the server-side resolver drops it.

Choosing families, dark-mode and font.family from the base schema are covered in styling-and-theming.md.


10. Dev-server URLs

npm run dev serves the same URL shapes so mount()’s default resolution works unchanged. The switch is the __HR_WIDGET_DEV__ define, set to true in dev and false in a build.

// Source: packages/homerunner-widget-core/src/runtime/mount.tsx:134-138
const cssFile = pluginWidgetDistFile(parts, "css");
if (isViteDev()) {
  return [`/dist/${parts.pluginSlug}/${cssFile}`];
}
return [getPluginAssetUrl(parts.pluginSlug, cssFile, getProxyBaseUrlOverride())];
RouteShapeServes
/dist/{slug}/{slug}.cssSingle-widgetEvery .css imported from src/, run through the full Vite transform (Tailwind, PostCSS), concatenated in import order
/dist/{slug}/{widget}/{widget}.cssSuiteThe same, walking from src/widgets/{widget}/index.tsx so each widget’s dev stylesheet carries only its own imports
/dist/*BothStatic files from the project’s dist/, with Access-Control-Allow-Origin: *
/w/{file}Both302 to the hashed twin from dist/manifest.json — mirrors the production proxy contract
/w/manifest.json, /manifest.jsonBothThe build manifest and the root manifest

CSS imported from src/ is redirected to a no-op virtual module in dev so Vite never injects a <style> into document.head (a shadow root would ignore it anyway). On a .css change the server emits the custom HMR event hr-widget-css-update instead of Vite’s default CSS HMR, and a small client compiled into each widget entry cache-busts the <link> tags inside the shadow root by appending ?t={timestamp}.

The production proxy origin is overridable per host page via window.HRWidgetRuntime.proxyBaseUrl; plugin builds carry no environment-specific URL. More on the dev sandbox: dev-sandbox-and-mocking.md.


Known gaps

Not supported yet. You cannot self-host any asset. getStaticAssets() output is reduced to a dist-relative name and re-resolved against your published prefix, and register-by-URL is an admin-only escape hatch with no author-facing UI. assets.fonts absolute URLs are the single exception.

Not supported yet. Pre-release versions (1.0.0-rc.1) publish successfully but silently drop out of every immutability optimisation — direct hashed URLs, the proxy’s permanent build-manifest cache, and the slug-keyed SSR bundle cache all fall back to TTLs.

Not supported yet. A suite asset emitted outside dist/{widget}/ can never serve through /p/, and nothing warns you at build or publish time.

Not supported yet. Bundled and font asset presence is unvalidated at publish. Only manifest-declared bundles, sub-manifests and media/ refs are checked.

Symptom-first diagnosis for 404s, missing fonts and non-registering bundles: troubleshooting.md. Every number on this page is also indexed in limits-and-errors.md.