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

Previewing SSR locally

npm run dev proves your widget works in a browser. It proves nothing about the server. On a customer page the renderer evaluates your built SSR bundle inside a node:vm sandbox with no DOM, calls your data hooks there, and ships the resulting HTML before any client code runs — see How a widget renders. npm run dev:ssr runs that path on your laptop. It is a port of the renderer’s loader, shipped in the SDK’s /testing subpath (widget-core 0.8.0+, so the npm latest build has it too — Install and versions). Three things it gained recently matter enough to gate every claim below. In 0.12.2: it previews one named widget of a suite, and it stopped diverging from the renderer’s sandbox in five places. In 0.12.3: it links a stylesheet for a widget that exports no getStaticAssets, which before then previewed unstyled.

Both shapes. A single-widget project needs nothing but a build. A suite has no default widget, so you name the one to preview — see Suites: one widget at a time. What has no local equivalent, in either shape, is a layout’s server-side slot composition.


Running it

npm run dev:ssr is tsx scripts/dev-ssr.mts, which calls createSSRDevServer from @homerunner-next/widget-core/testing. It reads dist/ and never compiles anything, so a build must come first.

# packages/create-hr-plugin/template/package.json and template-suite/package.json —
# the same "dev:ssr": "tsx scripts/dev-ssr.mts" script in both shapes.
npm run build                            # Single-widget: dist/{slug}-ssr.umd.js + .iife.js + .css
npm run dev:ssr                          # Single-widget — serves http://localhost:3003

npm run build                            # Suite: dist/{widget}/{widget}-ssr.umd.js per widget
npm run dev:ssr -- --widget intro-card   # Suite — name the widget to preview

Skip the build and every scenario tab renders a red SSR render failed: panel naming the missing bundle by its dist-relative path; the exact string, and the four boot-time selection errors below, are in the error index.

Naming a widget — Suite

The selection is resolved before the server starts, against the root manifest.json’s widgets[], so an unusable choice fails at boot with a message naming the widgets that do work — never a red panel on every scenario tab. The suite scaffold’s scripts/dev-ssr.mts runs that lookup itself and prints the message before exiting 1; call createSSRDevServer yourself and it throws the same string. Three ways to make the selection, highest precedence first (widget-core 0.12.2+):

HowExample
--widget {slug} in argvnpm run dev:ssr -- --widget intro-card
--widget={slug} in argvnpm run dev:ssr -- --widget=intro-card
HR_SSR_WIDGETHR_SSR_WIDGET=intro-card in .env.local

Four conditions fail instead of booting: naming no widget at all, naming one the manifest does not declare, naming one on a single-widget project (there is nothing to name), and naming a CSR-only widget — a summary carrying "ssr": false ships no SSR bundle, so there is nothing to preview. That last one is the correct outcome, not a fault; verify a CSR-only widget in the sandbox instead.

Everything the selection drives comes from that one lookup: the keyword (plugin:{slug}:{widget}), the nested dist/{widget}/{widget}-ssr.umd.js, .iife.js and .css bundles, and the widget’s declared externalFetch hosts (Manifest: widget summary). A single-widget project resolves the flat plugin:{slug} keyword and dist/{slug}-* files exactly as it always has.

Routes: / redirects to /preview?scenario=default, which shows the scenario tabs, the rendered envelope and an inspector (raw server HTML, the props your component received, the dehydrated React Query cache). /__render.json?scenario=… returns the same render as JSON — html, props, dehydratedState, assets — which is the scriptable one. /dist/* and /mockServiceWorker.js are static, and /p/{anything}/{file} is a local stand-in for the plugin asset proxy served out of dist/ (Keywords, assets and URLs).

The SSR bundle is re-read from disk on every request, so npm run build in another terminal plus a refresh picks up new code with no restart. The mock handlers, the widget selection and the seeded settings are fixed at boot — changing a config.ts or your fixtures needs a restart.

Configuration

scripts/dev-ssr.mts calls process.loadEnvFile(".env.local") then process.loadEnvFile(".env"), each in a try/catch so a missing file is fine. Node’s loader never overwrites a variable that is already set, which gives you this precedence (verified on Node 22): shell environment › .env.local.env › the defaults below. --widget is the only CLI flag the script reads; npm run dev:ssr -- --hydrate still does nothing.

KeyDefaultEffect
PORT3003Server port
HR_SSR_WIDGETunsetSuite only. Which widget to preview, when you do not pass --widget (0.12.2+)
HR_SSR_HYDRATEunset1 turns on client hydration; anything else is server-render only
HR_RUNTIME_URL${assetBaseUrl}/w/runtime.iife.js, hydrate mode onlyThe runtime IIFE that defines window.HRWidgetRuntime. In server-only mode it stays undefined and is never used
NEXT_PUBLIC_HOMERUNNER_BASE_URLhttps://beta.homerunner.ioExposed inside the sandbox as process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL, and to the client as HRWidgetRuntime.apiBaseUrl
NEXT_PUBLIC_WIDGET_ASSET_BASE_URLhttps://assets-dev.homerunner.ioExposed as process.env.NEXT_PUBLIC_WIDGET_ASSET_BASE_URL; also the origin HR_RUNTIME_URL defaults from

.env.example covers most of that, but not all of it and not uniformly. Both templates ship the two NEXT_PUBLIC_* keys uncommented, as live values, plus HR_SSR_HYDRATE and HR_RUNTIME_URL commented out; the suite’s file adds a commented HR_SSR_WIDGET, which the single-widget one has no use for. PORT is in neither file — set it in the shell or add it to .env.local yourself. The two base URLs barely matter for data: the mock handlers match any origin, so the value only shows up if you read it in your own code.

Scenarios, and the loading downgrade

The tabs are the same six scenarios the browser sandbox uses; their meanings and the fixture keys are in Dev sandbox and mocking. Two behaviours are specific to this server. loading is silently downgraded to default for the server render — the loading handler never resolves, so an un-downgraded server render would hang forever instead of producing HTML. The real scenario is restored immediately afterwards, so in hydrate mode the client sees loading over a populated server render. And latency defaults to 0 ms here, not to the sandbox’s default.

Settings come from configZod.parse({}) at boot — the whole default object — so options.<field> reads never crash. In a suite that is the selected widget’s own src/widgets/{widget}/config.ts, not the page-wide union npm run dev has to seed, so there is no cross-widget field collision here. See the divergences for why pre-parsed settings are also a trap.

Hydrate mode

Set HR_SSR_HYDRATE=1 and the page additionally loads, in document order: the dehydrated cache as window.HRWidget.__REACT_QUERY_STATE__, the runtime IIFE from runtimeUrl, a config block setting HRWidgetRuntime.apiBaseUrl and pointing proxyBaseUrl at window.location.origin, then the selected widget’s client IIFE — dist/{slug}.iife.js for a single-widget project, dist/{widget}/{widget}.iife.js for a suite. Your mount() finds the server markup, builds a shadow root, migrates the SSR content into it and hydrates (Runtime, mount and the DOM contract). Because proxyBaseUrl is the local server, CSS resolves through the local /p/ route, not the CDN.

Two things to know before you trust it. Hydrate mode is not offline — the runtime IIFE comes from the asset origin, and with hydrate on and no runtimeUrl resolvable the page prints an amber warning and shows the server HTML only. And client-side requests are not mocked: the page registers mockServiceWorker.js directly, but MSW only intercepts for clients that have sent it a MOCK_ACTIVATE message, which only startMockWorker() does, and no handlers are registered in the page at all. Every fetch your component makes after hydration goes to the real apiBaseUrl with the harness’s placeholder ids.

The server render is always resolvedTheme: "light" unless you pass branding. For a dark one, add branding: { colorScheme: "dark" } to the mock option in scripts/dev-ssr.mts — the harness derives the theme from nothing else.


What the preview genuinely reproduces

renderPluginSSR is a port of the renderer’s plugin-federation.ts loader, kept in the SDK so it stays versioned with the contract. Faithfully reproduced:

  • The same vm contract — your UMD is IIFE-wrapped and evaluated in a fresh node:vm context, with the same “no default export” check and the same module-scope execution timeout (0.12.2+), whose value is in the limits index.
  • The same require whitelist — all seven specifiers, including @homerunner-next/widget-core/runtime (0.12.2+) — refusing everything else with the same message. An import your build did not bundle fails here.
  • The same browser-global stubs for window, document, self, navigator, location, localStorage and sessionStorage — inert proxies, not real objects. A top-level window.foo read survives; a call that expects a real DOM does not. And the same absences: no TextEncoder, no TextDecoder, no crypto (0.12.2+ removed the two encoders the harness used to seed and the renderer never had).
  • The same process.env whitelist (three keys), the same __HR_PLUGIN_ASSET_BASE__ global for bundled assets (0.12.2+), and the same call order: evaluate → getInitialDatadehydrateState (fresh QueryClient, retries off) → getStaticAssetsrenderToString, same ctx shape. Component and SSR module owns that contract.
  • The same externalFetch enforcement (0.12.2+) — the widget’s declared hosts are read out of manifest.json and the sandbox fetch is wrapped in a port of the renderer’s allow list, so a call to an undeclared host fails here instead of first in review. An absent field declares nothing, exactly as in production.
  • The same hydration tree (0.12.2+) — the server render runs inside WidgetHydrationTree with identifierPrefix set to the SSR id, and the preview envelope carries that id in data-hr-ssr-id, so mount() hydrates with the same tree and the same prefix (Runtime, mount and the DOM contract).
  • Mocked server-side fetch. msw/node patches globalThis.fetch and the sandbox reads through to it at call time. Unmatched requests are bypassed to the real network, not failed.
  • The same stylesheet fallback (0.12.3+)getStaticAssets is optional, and the renderer serves the widget’s manifest assets when a bundle omits it. The harness takes the same branch: it links the widget’s declared assets.css (or, absent one, the conventional dist/ name) when that file is actually in dist/, links nothing when it is not, and defers to getStaticAssets verbatim whenever the export is there — including when the export returns an empty list, which production also honours. The fallback reaches data-hr-css too, so hydrate mode is styled as well. Below 0.12.3 there was no fallback, so a hook-less widget — the shape --template suite scaffolds — previewed with an empty <head> and no CSS in either mode.

Where the preview differs from production

Take this seriously: code can pass here and fail on the renderer, and the reverse. Component and SSR module carries the summary table; what follows is what each difference does to you. Everything here is measured against widget-core 0.12.3 — five divergences that used to live on this list (the missing /runtime shared module, seeded TextEncoder/TextDecoder, an undefined __HR_PLUGIN_ASSET_BASE__, unenforced externalFetch, no execution timeout) were closed in 0.12.2, and a sixth (no stylesheet for a widget without getStaticAssets) in 0.12.3. On an older SDK they all still apply.

  • __HR_PLUGIN_ASSET_BASE__ points at your laptop. It is defined, but as http://localhost:{port}/dist/ — the renderer derives it from the bundle’s CDN URL ({origin}/{env}/{slug}/{version}/dist/). Bundled-asset URLs therefore have the right shape here and the wrong origin. Check the real ones against Keywords, assets and URLs.
  • The externalFetch allow list has one local loophole. Private, loopback and metadata hosts are refused even when declared — except when your apiBaseUrl is itself loopback, because a fully local rig’s whole world is loopback. Point the harness at a local Central and a private host you declared will succeed here and be blocked in production.
  • Options are pre-parsed. The scaffold seeds settings from configZod.parse({}), so every field is present and correctly typed. Customers’ stored settings arrive raw. A component or data hook that skips parseWidgetConfig cannot fail here and will fail there. Paste real settings JSON into mock.widgetSettings to reproduce.
  • The envelope is close, not identical. It carries data-hr-ssr-id — the part hydration depends on — but ships the props as a data-hr-ssr-props attribute where production writes a page-level props registry, and it never emits a declarative shadow root. Server-only mode has no shadow root at all and links your stylesheet into the harness page’s own <head>, so :host rules match nothing and your styles can bleed onto the harness chrome. None of that is a bug in your widget — and that last part is useful rather than misleading: it is the closest local view of the unisolated, document-level condition every server-rendered page puts your CSS in (Styling and theming).
  • Data is fixtures, not a feed. Unmatched requests bypass to the real network, and in hydrate mode the browser’s own fetches are not mocked at all (see above). A shape your fixtures get wrong is a shape you have not tested.
  • There is no server-side slot composition. A layout renders its shell with empty slots — the platform, not this harness, renders the bound children and passes them as renderedSlots. See Suites: one widget at a time.

Suites: one widget at a time

A suite puts each widget’s bundle at dist/{widget}/{widget}-ssr.umd.js under the namespaced keyword plugin:{slug}:{widget}, and createSSRDevServer resolves both from the widget you name (widget-core 0.12.2+; the scaffold’s suite dev:ssr script needs create-hr-plugin 0.8.1+):

# packages/create-hr-plugin/template-suite/scripts/dev-ssr.mts — what the script accepts
npm run build
npm run dev:ssr -- --widget intro-card      # → http://localhost:3003
npm run dev:ssr -- --widget=page-frame      # the layout shell, slots empty
HR_SSR_WIDGET=intro-card npm run dev:ssr    # or set it in .env.local

Run it once per widget. There is no “preview the whole suite” mode, and there does not need to be: every SSR question except composition is per-widget.

The one thing you cannot preview is a composed page. renderPluginSSR builds the props itself and passes no renderedSlots, so page-frame renders its shell with every slot empty. That is still a useful smoke test — a layout that throws during render 500s the whole customer page (Layout widgets) — but it is not a preview of the composed result. npm run dev exercises the client half of layout composition (Dev sandbox and mocking); the server half needs a real feed.

If you want a composed render locally, build it yourself, in two steps. renderPluginSSR gives you each child’s HTML; evaluatePluginSSR gives you the layout’s module, which you render with react-dom/server yourself, passing those children as your own renderedSlots. Only the second step needs the hand-rolled call — renderPluginSSR builds its props internally and has no renderedSlots field, so it can never do this. The two lookups the dev server boots with are exported for exactly this (widget-core 0.12.2+):

// packages/homerunner-widget-core/src/testing/ssr.ts (renderPluginSSR, evaluatePluginSSR),
// src/testing/dev-server.ts (resolvePluginSSRTarget) and src/contracts.ts, which types
// `renderedSlots` as Record<string, ReactNode>. Save as scripts/ssr-compose.mts and run it
// with tsx after `npm run build`.
import fs from "node:fs";
import React from "react";
import { renderToString } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
  renderPluginSSR,
  evaluatePluginSSR,
  resolvePluginSSRTarget,
  createNodeMockServer,
} from "@homerunner-next/widget-core/testing";
import { setMockState } from "@homerunner-next/widget-core/mock";

const SLUG = "acme-suite";
const LAYOUT = "page-frame";
const FEED_ID = 1;
// Your own binding table. On a real page this comes from the customer's stored page
// schema; nothing here checks it, so a slot name your layout does not declare renders
// nothing at all.
const BINDINGS: Record<string, string[]> = { main: ["intro-card"] };

// Same lookup createSSRDevServer boots with: keyword, dist paths, declared hosts.
function resolve(widget: string) {
  const { target, error } = resolvePluginSSRTarget({
    rootDir: process.cwd(), slug: SLUG, widget,
  });
  if (!target) throw new Error(error); // CSR-only, undeclared, or no widgets[]
  return target;
}

// 1. Render every slot child on its own — the full production order, per widget.
const renderedSlots: Record<string, React.ReactNode> = {};
for (const [slot, widgets] of Object.entries(BINDINGS)) {
  const nodes: React.ReactNode[] = [];
  for (const widget of widgets) {
    const target = resolve(widget);
    const mock = createNodeMockServer(target.keyword, {
      widgetId: widget, feedId: FEED_ID, delayMs: 0,
    });
    setMockState({ scenario: "default" }); // never "loading" — that handler never resolves
    const { html, ssrId } = await renderPluginSSR({
      ssrCode: fs.readFileSync(`dist/${target.ssrFile}`, "utf-8"),
      filename: target.ssrFile,
      ctx: { options: {}, feedId: FEED_ID, widgetId: widget },
      resolvedTheme: "light",
      externalFetch: target.externalFetch,
    });
    mock.close();
    // Each child reaches the layout as its own host element, not a bare string.
    nodes.push(React.createElement("div", {
      key: widget,
      "data-hr-widget": target.keyword,
      id: `${widget}:${FEED_ID}`,
      "data-hr-ssr-id": ssrId,
      dangerouslySetInnerHTML: { __html: html },
    }));
  }
  renderedSlots[slot] = React.createElement(React.Fragment, null, ...nodes);
}

// 2. Evaluate the LAYOUT in the same sandbox and render the shell yourself.
const layout = resolve(LAYOUT);
const mod = evaluatePluginSSR(
  fs.readFileSync(`dist/${layout.ssrFile}`, "utf-8"),
  "https://beta.homerunner.io",
  "https://assets-dev.homerunner.io",
  { filename: layout.ssrFile, externalFetch: layout.externalFetch, pluginAssetBase: "" },
);

const ctx = { options: {}, feedId: FEED_ID, widgetId: LAYOUT };
const layoutMock = createNodeMockServer(layout.keyword, {
  widgetId: LAYOUT, feedId: FEED_ID, delayMs: 0,
});
setMockState({ scenario: "default" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const data = mod.getInitialData ? await mod.getInitialData(ctx) : undefined;
await mod.dehydrateState?.(queryClient, ctx);

// No WidgetHydrationTree and no identifierPrefix here, deliberately: on a server-rendered
// page the shell is static light DOM whose mount is a no-op, so there is no hydration to
// line up with (Layout widgets covers that).
console.log(renderToString(
  React.createElement(QueryClientProvider, { client: queryClient },
    React.createElement(mod.default, { ...ctx, resolvedTheme: "light", data, renderedSlots }),
  ),
));
layoutMock.close();

Every divergence above still applies, and the settings are whatever you pass in ctx.options{} is not what a customer sends. Three things this deliberately does not reproduce: the keys a layout pushes down onto each child, and the Declarative Shadow DOM template and props-registry entry every real slot child gets (Layout widgets).


An SSR checklist

Run before you zip — Preflight and submit has the rest.

A content widget with an SSR bundle — Both. Every scenario except loading renders non-empty markup; error paints your error state rather than throwing. The inspector’s Props panel shows the options you expect, and the dehydrated cache is non-empty if you export dehydrateState. Nothing in the terminal says window is not defined. Fetch /__render.json?scenario=default twice and diff: any difference means a non-deterministic render (a clock read, a random value), which costs you the SSR DOM on hydration.

A CSR-only widget ("ssr": false) — Suite. Nothing to preview and no ssr-entry.ts to build; naming it exits 1 and that is the expected outcome. Verify it in the sandbox instead, and set expectedHeight (Manifest: widget summary) so the empty server host reserves the right space.

A layout widget — Suite. SSR is mandatory, and --widget {layout} previews the shell with empty slots — enough to prove it evaluates, renders and does not throw, which is the failure that 500s a whole customer page. It is not a preview of the composed result. Give every slot and every config field a default, keep the shell non-interactive, and class-namespace the CSS: on a server-rendered page the shell is light DOM and its stylesheet is document-level.


Seeing what the server actually did on a real page

Once your plugin is live on a feed, three signals answer “which layer served this, and when”.

Development mode turns off caching for one feed. The feed owner enables it from the dashboard for a time-boxed window; the Cloudflare worker then appends ?dev=1 to its render request, and the renderer serves fresh, writes nothing to cache and returns Cache-Control: no-store… plus X-HR-Dev-Mode: 1. Ask for it while you iterate — otherwise you are looking at a cached page and cannot tell a stale render from a broken one. The sandbox’s server-side GET cache is bypassed on those renders too (still enforced), per the limits index.

The stamp. Every fresh render appends an HTML comment to the compiled page — <!-- hr-render page=… build=… manifest-age=…s rendered-at=<ISO> key=… -->. View-source and read the last one; a page-level stamp follows every widget. A cached page deliberately keeps the stamp of the render that produced it, so an old rendered-at is the staleness evidence.

The headers, on the render API response (the JSON the worker fetches, not the customer page): x-hr-render-cache is hit-fast, hit, miss or bypass (bypass = development mode), x-hr-render-key is the compiled-page key, and x-hr-render-build / x-hr-render-at are read out of the stamp inside the payload being served — so on a hit they describe the original render. curl -sD - -o /dev/null answers all four.

If a widget is missing from the page entirely, search the source for data-hr-widget-error: the renderer leaves a hidden node carrying an HTML-comment reason where the widget should have been. Troubleshooting maps those reasons to causes.