npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@purityjs/ssr

v0.4.0

Published

Server-side rendering for Purity — renderToString + SSR html`` tag

Readme

@purityjs/ssr

npm version npm downloads license

Server-side rendering for Purity. Renders components to an HTML string, awaits async resource() data, primes the client cache, supports Custom Elements via Declarative Shadow DOM, and (since 0.1) ships a streaming entry with <Suspense> boundaries for progressive flush.

import { renderToString, html } from '@purityjs/ssr';

const out = await renderToString(() => html`<h1>Hi</h1>`, { doctype: '<!doctype html>' });
//   → '<!doctype html><h1>Hi</h1>'

Install

npm install @purityjs/ssr

@purityjs/ssr has a peer dependency on @purityjs/core. Node 18+, Bun, Deno, Cloudflare Workers, and Vercel Edge are all supported runtimes — anything with ReadableStream<Uint8Array> and TextEncoder.

Module surface

| Export | Kind | Purpose | | --------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | | html | function | Server counterpart of @purityjs/core's html — returns a branded SSRHtml string instead of building DOM. Drop-in API replacement. | | renderToString(component, options?) | function | Buffered render. Returns Promise<string>, or Promise<{ body, head }> when extractHead: true is set. | | renderToStream(component, options?) | function | Progressive render. Returns ReadableStream<Uint8Array> that flushes the shell first, then per-suspense() boundary chunks. | | renderToStreamResponse(component, options?) | function | Prepares the shell and returns its head, status, headers, and progressive body before HTTP headers are sent. | | RenderToStringOptions | type | Options for renderToString (timeout, doctype, nonce, serialize toggle, extractHead). | | RenderToStringWithHead | type | { body: string; head: string } — return shape when extractHead: true. | | RenderToStreamOptions | type | Options for renderToStream (timeout, doctype, nonce, serialize toggle, AbortSignal). | | RenderToStreamResponse | type | { body, head, status?, headers? } returned after preparing the streaming shell. | | SSRHtml | type | Branded string { __purity_ssr_html__: string } — emitted by html and the *SSR control-flow helpers. |

head() itself is exported from @purityjs/core — see Head / meta tags below for the call-site pattern.

Every export is re-exported from @purityjs/ssr's root entry. There are no subpaths.

When to use which entry

| Scenario | Entry | | -------------------------------------------------------- | ------------------------ | | Static prerender (build-time, sitemap) | renderToString | | Small response, no slow data | renderToString | | Edge function with one-shot HTML reply | renderToString | | Slow data behind a fast shell (dashboards, app listings) | renderToStream | | Multiple independent slow regions | renderToStream | | Want progressive paint on a slow connection | renderToStream | | Stream a route with loader status, headers, or head() | renderToStreamResponse |

API

html

Identical signature to @purityjs/core's html, but its compiled factory emits HTML strings rather than DOM nodes. Returns SSRHtml.

import { html } from '@purityjs/ssr';

const greeting = (name: string) => html`<h1>Hello, ${name}</h1>`;
greeting('Ada').__purity_ssr_html__;
//   → '<h1>Hello, <!--[-->Ada<!--]--></h1>'

The <!--[-->VALUE<!--]--> marker pair brackets every ${expression} slot. The client-side hydrator walks these markers and binds reactivity in place — see ADR 0005.

In a Vite SSR build, the @purityjs/vite-plugin AOT-compiles html\`calls directly to the SSR factory output and the runtimehtml` function is not invoked. It still ships for unit tests and for ad-hoc SSR-without-Vite scripts.

renderToString(component, options?)

function renderToString(component: () => unknown, options?: RenderToStringOptions): Promise<string>;

interface RenderToStringOptions {
  /** Maximum ms to wait for pending resources during render. Default 5000. */
  timeout?: number;
  /** Inline a JSON snapshot of resolved resources. Default true. */
  serializeResources?: boolean;
  /** Optional doctype prefix (e.g. `'<!doctype html>'`). */
  doctype?: string;
  /** Strict-CSP nonce, base64 / URL-safe characters. */
  nonce?: string;
}

Resolution loop. Renders the component, awaits any resource() fetchers triggered during the pass, re-runs, and repeats until the render is quiescent (no new pending promises) or timeout ms elapse. Throws on timeout. MAX_PASSES = 10 — a render that creates new resources on every pass diverges and throws.

Resource cache. When serializeResources !== false and at least one resource() resolved during the render, the output ends with:

<script type="application/json" id="__purity_resources__">
  …
</script>

The client-side hydrate() reads this script, calls primeHydrationCache(...), and resource() calls inside the hydrating component consume cached values without re-fetching. The payload shape is:

  • [v0, v1, …] (positional) when no resource opted into a stable key.
  • { ordered: [...], keyed: { ...} } when at least one resource(..., { key }) is in play. Recommended for production code — positional indexing shifts under conditional resource creation.

Custom Elements. Components registered via component('p-tag', …) SSR through Declarative Shadow DOM:

<p-card>
  <template shadowrootmode="open">
    <style>
      …
    </style>
    <div class="card">…</div>
  </template>
</p-card>

Browsers since 2024 (Chrome 124+, Safari 16.4+, Firefox 123+) parse the inline shadow root immediately. The server-rendered content stays visible until hydrate(container, App) binds the parent's component properties and hydrates each shadow tree. Call hydrate() on the containing app root after registering components; Declarative Shadow DOM alone does not attach event handlers.

Property bindings such as :count=${count} preserve numbers, booleans, objects, and null because the client provides those values before the child renders. The client app must produce the values again during hydration. Object and function props are not written into HTML attributes; primitive host attributes can still appear in page source, so do not pass secrets as SSR props.

ADR 0004 covers the contract; pre-2024 browsers fall through to client-only render.

renderToStream(component, options?)

function renderToStream(
  component: () => unknown,
  options?: RenderToStreamOptions,
): ReadableStream<Uint8Array>;

interface RenderToStreamOptions {
  /** Per-boundary resource timeout. Default 5000. */
  timeout?: number;
  /** Inline shell-resolved resources. Default true. */
  serializeResources?: boolean;
  /** Optional doctype prefix. */
  doctype?: string;
  /** Strict-CSP nonce applied to every inline `<script>` we emit. */
  nonce?: string;
  /** Abort mid-stream — closes the controller and drops in-flight boundary renders. */
  signal?: AbortSignal;
}

Wire format. The stream emits, in order:

  1. The doctype prefix, if supplied.
  2. The shell HTML — every suspense(view, fallback) call emits its fallback HTML wrapped in <!--s:N-->FALLBACK<!--/s:N--> markers. Top-level resources still block the shell via the multi-pass loop; wrap async data in suspense() to defer it.
  3. <script type="application/json" id="__purity_resources__">…</script> with shell-resolved resources (suppressible via serializeResources: false).
  4. <script>window.__purity_swap = …</script> — the ~330-byte client splice helper, inlined exactly once when there is at least one queued boundary.
  5. Per boundary, in declaration order:
    <template id="purity-s-N">RESOLVED_HTML</template>
    <script>
      __purity_swap(N);
    </script>
  6. The stream closes when the queue drains (or signal aborts).

Backpressure and transport chunks. The Web Stream queues at most one chunk of up to 64 KiB. Its producer resumes only when the reader has room, pausing subsequent deferred boundary renders when output is queued. Node's pipeline passes downstream pressure through, but Node, socket, proxy, and browser buffers have separate limits. Shell HTML, current boundary output, and hydration data still occupy memory outside this queue.

Large output is encoded incrementally into independently owned UTF-8 buffers of at most 64 KiB, rather than allocating encoded bytes for the entire shell or boundary first. Encoding resumes with reader demand and stops on cancellation. Small output uses a single exact-sized allocation. Valid surrogate pairs are preserved; lone surrogates use standard TextEncoder replacement semantics.

Consumed boundary callbacks are removed from the pending map as their render starts. The active boundary owns its callbacks until its work ends; completed callbacks do not remain in that map while later boundaries wait. Cancellation and renderer exit clear the remaining map. npm run test:ssr:memory verifies on built Node packages that captured payloads from 32 completed boundaries can be collected while another boundary keeps the response open. This is a focused retention check, not a bound on total response memory.

The shell's ordered/keyed resource snapshots are released once serialized into the output string, before encoding begins; disabling serialization or encountering a serialization error also releases them. Deferred views that capture resource accessors can still read their values, and boundaries keep independent caches. The renderer drops its shell HTML reference before encoding and its assembled shell string after draining it. The memory regression check covers shell snapshots with both cache forms and serialization settings.

Wire fragments above can span multiple transport chunks, including splits inside HTML or scripts. Decode with Response.text() or a streaming TextDecoder and flush the decoder at the end. Explicit Suspense deadlines remain anchored to their first shell encounter while queued views wait. Cancel unread streams or supply a request/abort signal to release their listeners and active work; there is no implicit overall connection deadline. A direct stream starts shell work on its first automatic pull; the prepared response entry still finishes shell rendering before resolving.

__purity_swap(N) walks the document's comment nodes via TreeWalker to find the matching <!--s:N--> / <!--/s:N--> pair, removes the fallback nodes between them, and inserts the template's content in place. ADR 0006 Phase 3.

Per-boundary budgets. Each boundary renders in its own SSRRenderContext with its own multi-pass loop and its own { timeout } budget (the option is per-boundary, not per-response). When a boundary's deadline fires the renderer falls back to its fallback() HTML for the streamed chunk — siblings continue resolving normally. Use suspense({ onError }) to observe view / fallback / timeout phases.

Per-boundary resource cache. When a boundary's view resolves any resource(..., { key }), the streamed chunk also includes a <script type="application/json" id="__purity_resources_N__">{"keyed":{…}}</script> payload alongside the <template id="purity-s-N">. On the client, hydrate() scans every script[id^="__purity_resources_"] and merges the keyed entries before priming — boundary-side keyed resources hit the cache and skip refetching. Positional resources inside a boundary collide with the shell's index space, so streamed-boundary resources should always opt into a key.

Hydration timing. The MVP defers hydrate() until the stream closes. Selective per-boundary hydration (React-style) is out of scope for now.

renderToStreamResponse(component, options?)

Use this async entry when an HTTP adapter needs route metadata before sending headers. It runs the shell's resource passes, then returns { body, head, status?, headers? }. The body remains a progressive stream: Suspense boundaries resolve after the shell. A loader that returns a Web Response rejects the preparation promise with that same response, so the adapter can send its redirect or custom body directly.

import { renderToStreamResponse } from '@purityjs/ssr';

async function handle(request: Request): Promise<Response> {
  try {
    const rendered = await renderToStreamResponse(App, { request, signal: request.signal });
    const headers = new Headers(rendered.headers);
    headers.set('Content-Type', 'text/html; charset=utf-8');
    return new Response(rendered.body, { status: rendered.status ?? 200, headers });
  } catch (error) {
    if (error instanceof Response) return error;
    throw error;
  }
}

head contains head() calls made while preparing the shell. Insert it into your document template before streaming the body. Metadata generated only inside a deferred Suspense view arrives after the response begins and cannot change HTTP headers or the document head; put it in the route or shell instead. renderToStream() keeps its synchronous stream-returning API for pages that do not need metadata.

End-to-end recipes

Node 18+ HTTP server

// server.ts
import { createServer } from 'node:http';
import { renderToString } from '@purityjs/ssr';
import { App } from './app.js';

createServer(async (req, res) => {
  try {
    const html = await renderToString(App, { doctype: '<!doctype html>' });
    res.writeHead(200, { 'content-type': 'text/html' });
    res.end(html);
  } catch (err) {
    res.writeHead(500).end('SSR error');
  }
}).listen(3000);

Streaming Node 18+

import { createServer } from 'node:http';
import { Readable } from 'node:stream';
import { renderToStream } from '@purityjs/ssr';

createServer(async (req, res) => {
  res.writeHead(200, { 'content-type': 'text/html', 'transfer-encoding': 'chunked' });
  const stream = renderToStream(App, { doctype: '<!doctype html>' });
  Readable.fromWeb(stream as any).pipe(res);
}).listen(3000);

Cloudflare Workers / Vercel Edge / Bun / Deno

import { renderToStream } from '@purityjs/ssr';

export default {
  async fetch(req: Request): Promise<Response> {
    const stream = renderToStream(App, {
      doctype: '<!doctype html>',
      signal: req.signal, // cancel on client disconnect
    });
    return new Response(stream, { headers: { 'content-type': 'text/html' } });
  },
};

ReadableStream<Uint8Array> is the platform standard — no adapter code needed.

With <Suspense>

import { html } from '@purityjs/core/jit'; import { suspense } from '@purityjs/core';
import { renderToStream } from '@purityjs/ssr';

const App = () => html`
  <main>
    <h1>Hello</h1>
    ${suspense(
      () => html`<aside>${() => slowData()}</aside>`,
      () => html`<aside class="loading">…</aside>`,
      { timeout: 2000, onError: (err, info) => log(info.boundaryId, err) },
    )}
  </main>
`;

The shell paints with <aside class="loading">…</aside> immediately; when slowData() resolves the streamed chunk swaps in <aside>RESOLVED</aside>.

Head / meta tags (ADR 0008)

head(content) lets a component contribute HTML to the document <head> from inside the render tree. renderToString({ extractHead: true }) surfaces the collected HTML as { body, head } so you can splice it into your shell.

import { head } from '@purityjs/core/router'; import { html } from '@purityjs/core/jit';
import { renderToString, html as ssrHtml } from '@purityjs/ssr';

function PageHead({ title, description }: { title: string; description: string }) {
  head(ssrHtml`<title>${title}</title>`);
  head(ssrHtml`<meta name="description" content="${description}">`);
}

function App() {
  return ssrHtml`
    ${PageHead({ title: 'My Page', description: 'Welcome' })}
    <main><h1>Hi</h1></main>
  `;
}

const { body, head: headHtml } = await renderToString(App, { extractHead: true });
const final = template.replace('<!--head-->', headHtml).replace('<!--ssr-outlet-->', body);

Contract:

  • head() is server-only in Phase 1 — it's a no-op on the client. The browser already shows the SSR-rendered <head>; reactive client-side head element management lands in a follow-up ADR.
  • extractHead: false (the default) preserves the legacy Promise<string> return — apps that don't use head() are unaffected.
  • The collected HTML is the final render pass's output, so resource-dependent values (head(ssrHtml\${() => res()}`)`) show resolved values, not the loading placeholder.
  • renderToStreamResponse() returns shell-level head() output before the first byte is sent. The caller inserts it into the document template; deferred Suspense views cannot add to it.

Request context (ADR 0009)

Pass the incoming Request through to components via the request option on either render entry. Components read it with getRequest().

import { getRequest, head } from '@purityjs/core/router'; import { html } from '@purityjs/core/jit';
import { renderToString } from '@purityjs/ssr';

function PageHead() {
  const req = getRequest();
  if (!req) return; // client render — let the SSR head stand

  const url = new URL(req.url);
  const canonical = `${url.origin}${url.pathname}`;
  head(html`<link rel="canonical" href="${canonical}" />`);
}

// Edge / Bun / Deno / CF Workers — request is already a Request:
const html = await renderToString(App, { request });

// Node http.createServer — convert IncomingMessage to Request in one line:
const url = `http://${msg.headers.host}${msg.url}`;
const request = new Request(url, { method: msg.method, headers: msg.headers as HeadersInit });
const html = await renderToString(App, { request });

Contract:

  • getRequest() returns the same Request instance on every render pass and inside every suspense() boundary's view (renderToStream propagates through both shell and per-boundary contexts).
  • On the client (and in tests / ad-hoc renders without a request option), getRequest() returns null. Branch on the result if your component is dual-target.
  • getRequest() exposes the raw request; the router primitives (ADR 0011) layer dispatch on top — see below.

Router primitives (ADR 0011)

Three composable functions in @purityjs/core close the per-app routing boilerplate without picking a routing convention.

import { currentPath, matchRoute, navigate } from '@purityjs/core/router'; import { html } from '@purityjs/core/jit';

function App() {
  if (matchRoute('/')) return Home();
  const m = matchRoute('/users/:id');
  if (m) return UserPage(m.params.id);
  return NotFound();
}

html`<a
  href="/about"
  @click=${(e) => {
    e.preventDefault();
    navigate('/about');
  }}
  >About</a
>`;

| API | SSR behavior | Client behavior | | ---------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | currentPath(): string | new URL(request.url).pathname, or '/' without request | Reactive — tracks popstate + hashchange + navigate() | | currentSearch(): URLSearchParams | new URL(request.url).searchParams (fresh copy) | Reactive — same signal as currentPath. Mutations are local — go through navigate() to change the URL. | | currentHash(): string | new URL(request.url).hash (with #) or '' | Reactive — same signal as currentPath | | navigate(href, { replace? }) | No-op | pushState (or replaceState) + updates the reactive URL | | matchRoute(pattern, path?) | Pattern matcher; path defaults to currentPath() | Same — reactive when called inside a watch() / template |

Pattern grammar: literals (/about), :name captures (/users/:id), and * splat tail (/blog/*). Captured :name values are URI-decoded. Returns { params } | null.

Link auto-interception via interceptLinks() drops the per-<a> @click boilerplate; manageNavScroll() (ADR 0015) closes the "SPA keeps the previous page's scroll position" UX gap on forward navigation:

// entry.client.ts
import { hydrate } from '@purityjs/core'; import { interceptLinks, manageNavScroll } from '@purityjs/core/router';
import { App } from './app.ts';

hydrate(document.getElementById('app')!, App);
interceptLinks();
manageNavScroll();

Then templates use plain <a href> — same-origin clicks become navigate() calls automatically. The default interceptLinks predicate exempts modifier keys, target="_blank", download links, cross-origin hrefs, hash-only same-page links, and elements carrying data-no-intercept. Pass a custom shouldIntercept to fully replace the default.

manageNavScroll() subscribes to onNavigate() and scrolls to the URL's hash target (if any) or to the top after every forward navigate(). Browser-driven back/forward scroll restoration and hashchange-anchor scrolling are native — manageNavScroll only fills the pushState gap. Pass a custom onNavigate handler to take over (e.g. for smooth scroll, view transitions).

manageNavFocus() (ADR 0016) closes the accessibility gap on the same nav event — focus moves into the URL's hash target (or the page's <main> landmark by default) so screen readers announce the new region's accessible name. Pairs with manageNavScroll() via preventScroll: true so the two don't fight. Pass { selector } to target a different landmark (e.g. 'h1', '.app-content') or { onNavigate } to replace the default with a custom focus / live-region flow.

manageNavTransitions() (ADR 0017) wraps navigate()-driven URL + DOM updates in document.startViewTransition() so route changes can cross-fade. No-op on browsers without the API (Safari < 18, all Firefox as of 2026-05); honors prefers-reduced-motion. Pass { shouldTransition } to opt out per-nav. Style the cross-fade or per-element morph via the standard ::view-transition-* CSS pseudo-elements.

Still not in scope: <Route> / <Routes> component, layout nesting, URL search / hash reactivity, file-system route discovery, scroll restoration, focus management, view transitions, prefetch-on-hover. See ADR 0011 + ADR 0013's "Explicit non-features" sections.

Server actions (ADR 0012)

Server actions register a (Request) => Response handler at a stable URL. <form action=${action.url} method="POST"> posts to it natively (progressive enhancement); JS apps call action.invoke(body, init?) to POST via fetch and get the raw Response back.

// app/save-todo.server.ts
import { serverAction } from '@purityjs/core/actions';

export const saveTodo = serverAction('/api/save-todo', async (request) => {
  const data = await request.formData();
  const text = String(data.get('text') ?? '');
  if (!text) return new Response('text required', { status: 400 });
  await db.insert({ text });
  return Response.redirect(new URL('/', request.url).toString(), 303);
});

// In a server-rendered component:
html`<form action=${saveTodo.url} method="POST">
  <input name="text" />
  <button>Save</button>
</form>`;

// In your server entry, before SSR:
import { handleAction } from '@purityjs/core/actions';

const actionResponse = await handleAction(request);
if (actionResponse) return actionResponse;
// …else render the page normally.

| API | Returns | Behavior | | ----------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- | | serverAction(url, handler) | { url, handler, invoke } | Register at url. Last-wins on duplicate. Throws on bad input. | | action.invoke(body?, init?) | Promise<Response> | Client-only. POST to action.url via fetch. Defaults to method POST; init overrides. Throws on the server. | | findAction(request) | handler \| null | Look up handler by new URL(request.url).pathname. No invocation. | | handleAction(request) | Promise<Response \| null> | Find + invoke + await. null on no match so caller falls through. |

Action handlers belong in *.server.{ts,js,tsx,jsx} files — the @purityjs/vite-plugin strips those from client bundles automatically (default-on, ADR 0018). Handler bodies + their transitive imports (DB driver, secrets, API tokens) stop shipping to the browser. Apps sharing the URL between client + server pull it into a non-server module:

// app/api-urls.ts (shared)
export const SAVE_TODO_URL = '/api/save-todo';

// app/save-todo.server.ts (server-only — stripped from client)
import { serverAction } from '@purityjs/core/actions';
import { SAVE_TODO_URL } from './api-urls.ts';
export const saveTodo = serverAction(SAVE_TODO_URL, async (request) => {
  /* … */
});

// app/components/SaveButton.ts (client)
import { SAVE_TODO_URL } from '../api-urls.ts';
html`<form action=${SAVE_TODO_URL} method="POST">…</form>`;

Phase 1 non-features (per ADR 0012): no CSRF helper, no auto-serialization, no build-time URL derivation. CSRF + auth-aware checks live in your handler.

Static site generation (ADR 0010)

renderStatic(options) pre-renders a list of routes at build time. Composes renderToString + extractHead + getRequest(); returns a Map<path, html> plus a per-route errors map. No filesystem I/O — the caller writes the files however their runtime prefers.

import { renderStatic } from '@purityjs/ssr';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

const { files, errors } = await renderStatic({
  routes: ['/', '/about', '/blog/hello-world'],
  handler: (req) => () => App({ url: req.url }),
  shellTemplate:
    '<!doctype html><html><head>{{head}}</head>' +
    '<body><div id="app">{{body}}</div></body></html>',
  baseUrl: 'https://example.com',
  concurrency: 8,
});
for (const [route, html] of files) {
  const out = join('dist', route === '/' ? 'index.html' : `${route.replace(/^\//, '')}/index.html`);
  await mkdir(dirname(out), { recursive: true });
  await writeFile(out, html);
}
for (const [route, err] of errors) console.error('SSG failure:', route, err);

Options:

| Field | Type | Default | Purpose | | --------------- | --------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------- | | routes | Array<string \| { path; request? }> | required | URL paths to render. Per-route request overrides the synthesised one. | | handler | (req: Request) => () => unknown | required | Resolves a request to the component thunk for that route. | | shellTemplate | string | (none — return body) | {{body}} + optional {{head}} placeholders. | | baseUrl | string | 'http://localhost' | Origin used to synthesise per-route Request URLs. | | doctype | string | none | Forwarded to renderToString. | | renderOptions | Omit<RenderToStringOptions, 'extractHead' \| 'request'> | {} | Forwarded per-render. extractHead is forced on; request is per-route. | | concurrency | number | Infinity | Bounded worker pool when SSG'ing thousands of routes. | | onRoute | (path, html) => void \| Promise<void> | none | Stream completed renders to disk one-at-a-time. |

Contract:

  • Errors are per-route. One bad render doesn't abort the batch — check errors.size === 0 before publishing.
  • The synthesised Request is visible to components via getRequest(); req.url is baseUrl + path.
  • head() markup goes into {{head}}; if the template has no {{head}} placeholder, head markup is prepended to the body so it isn't lost.
  • renderToStream and renderStatic are mutually exclusive — pick the buffered (SSG) or streaming (SSR) path per response.

Strict CSP

Generate a nonce per request and pass it through; pair with a Content-Security-Policy: script-src 'nonce-…' header so every inline <script> we emit (resource cache prime, swap helper, per-boundary swap calls) is allowed under strict CSP.

import crypto from 'node:crypto';
const nonce = crypto.randomBytes(16).toString('base64');
const stream = renderToStream(App, { nonce });
res.setHeader('content-security-policy', `script-src 'nonce-${nonce}'; default-src 'self'`);

nonce is validated against [A-Za-z0-9+/=_-]+ (base64 + URL-safe). An invalid nonce throws synchronously.

Hydration contract

| What renders the SSR HTML | What attaches client behavior | Where to call it | | -------------------------------------------- | ---------------------------------- | ----------------- | | renderToString / renderToStream (server) | hydrate(container, App) (client) | entry.client.ts |

// entry.client.ts
import { hydrate } from '@purityjs/core';
import { App } from './app.js';

hydrate(document.getElementById('app')!, App);

The hydrator:

  • Walks <!--[-->…<!--]--> slot markers and binds reactivity to existing nodes (no rebuild). ADR 0005.
  • Adopts each() rows by key via <!--er:KEY-->row<!--/er--> per-row markers, and when() / match() cases via <!--m:KEY-->view<!--/m--> boundary markers. Same node references survive across hydration.
  • Strips <!--s:N--> / <!--/s:N--> Suspense markers (or treats them as opaque if a boundary hasn't streamed yet — Phase 3 MVP defers full hydration until the stream closes).
  • Falls back to a fresh mount() if the SSR DOM diverges structurally from the client template; never crashes the page.
  • Optional enableHydrationWarnings() logs structural mismatches; opt-in enableHydrationTextRewrite() overwrites SSR text content to match the template (ADR 0007).

SSR control-flow helpers

@purityjs/core re-exports server variants: eachSSR, whenSSR, matchSSR, listSSR. Use them in code that runs on the server only — they emit the same marker grammar the hydrator expects, including encoded keys for per-row / per-case adoption.

import { html as ssrHtml, renderToString } from '@purityjs/ssr';
import { eachSSR } from '@purityjs/core/internal';

const out = await renderToString(
  () =>
    ssrHtml`<ul>${eachSSR(
      () => items,
      (todo) => ssrHtml`<li>${() => todo().text}</li>`,
      (todo) => todo.id,
    )}</ul>`,
);

In a Vite SSR build the plugin auto-rewrites client each / when / match to their SSR variants — you write client code, it transparently emits SSR HTML.

Marker grammar reference

| Marker | Emitted by | Purpose | | ---------------------------------------------- | ---------------------- | --------------------------------------------------------------- | | <!--[-->VALUE<!--]--> | every expression slot | Hydrator finds reactive bindings without text-coalescing drift | | <!--e-->ROWS<!--/e--> | eachSSR | Outer each() boundary | | <!--er:KEY-->row<!--/er--> | eachSSR per row | Per-row adoption (KEY is encodeURIComponent with - → %2D) | | <!--m:KEY-->view<!--/m--> | matchSSR / whenSSR | Boundary marker carrying the rendered case key | | <!--l-->ROWS<!--/l--> | listSSR | Flat single-tag list boundary | | <!--s:N-->view<!--/s:N--> | suspense() | Streaming boundary; N is the per-render monotonic id | | <template shadowrootmode="open">…</template> | component() SSR | Declarative Shadow DOM payload for Custom Elements |

The <!--[--> / <!--]--> markers are 14 bytes per slot; the cost is fixed and pays for non-lossy hydration.

Conventions

  • One pushSSRRenderContext per render. Both renderToString and renderToStream push their own SSRRenderContext; user code reads it through getSSRRenderContext(). Don't push your own.
  • Async by default. Both render entries are async (the multi-pass resource loop awaits between passes). renderToStream returns the ReadableStream synchronously but the start() controller is async.
  • html is two different functions. @purityjs/core/html builds DOM (or returns a deferred-template thunk during hydration). @purityjs/ssr/html returns SSRHtml. Vite's SSR transform picks the right one per build target — apps written for both targets parametrise the user code on the html tag.
  • Shadow DOM scope. Components are isolated. Global stylesheets that need to pierce the shadow root use adoptedStyleSheets per component; CSS variables work normally. See docs/shadow-dom-rationale.md.
  • Resources need keys for streaming. Inside a suspense(view) view, prefer resource(..., { key: 'unique-string' }) so the per-boundary cache (Phase 6 second-half) addresses values stably across the SSR/hydrate boundary.
  • Output is byte-for-byte deterministic for a fixed input: marker IDs reset per render, resource counters reset per pass, and per-boundary IDs are monotonic. Snapshot tests are reliable.

Common gotchas

| Symptom | Cause | | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Purity] renderToString timed out after 5000ms | A resource() fetcher hangs. Check the network or set { timeout: longer }. | | did not converge within 10 passes | The render creates new resources on every pass. Memoize the resource list or move it out of the render path. | | Hydration mismatch — <p> vs <div> | SSR and client templates disagree. Run with enableHydrationWarnings() in dev. | | Streamed boundary refetches its data on hydrate | The resources inside the boundary's view aren't keyed. Use resource(..., { key: 'unique-string' }) — only keyed values get cross-boundary cache priming. | | __purity_swap blocked by CSP | Pass nonce through both renderToStream and your CSP header. | | Custom Element flickers on hydrate | Browser doesn't support Declarative Shadow DOM. Pre-2024 browsers fall through to client-render — feature-detect or polyfill. |

Decision records

The SSR feature set is documented through ADRs — start here when you need to dig deeper:

License

MIT