[]previous and next page

A request handler by hand

Without Dusk, a server entry is a function from a request to a response.

For agentsUpdated

Without Dusk, a server entry is a function from a request to a response. This one uses renderToStringAsync and the documented hand-off. It is type-checked, not executed, because it needs a server and the built client bundle.

src/entry-server.tsxtsx
import { renderToStringAsync, serialize } from "@shadow-js/web/server";
import { App } from "./stream-app";

/** Escapes the one value that goes into an attribute. */
function attribute(value: string): string {
  return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
}

export async function handle(request: Request, clientEntry: string): Promise<Response> {
  const url = new URL(request.url);
  const { html, data } = await renderToStringAsync(
    () => <App load={() => Promise.resolve(url.searchParams.get("name") ?? "stranger")} />,
    { timeoutMs: 3000 },
  );
  const page =
    `<!doctype html><html lang="en"><head><meta charset="utf-8"></head><body>` +
    `<div id="app">${html}</div>` +
    `<script type="application/json" id="shadow-data">${serialize(data)}</script>` +
    `<script type="module" src="${attribute(clientEntry)}"></script>` +
    `</body></html>`;
  return new Response(page, { headers: { "content-type": "text/html; charset=utf-8" } });
}
src/entry-client.tsxtsx
import { hydrate } from "@shadow-js/web";
import { deserialize } from "@shadow-js/web/server";
import { App } from "./stream-app";

const container = document.getElementById("app");
const payload = document.getElementById("shadow-data");
if (container === null) throw new Error("Missing #app element");

const data = payload === null ? {} : (deserialize(payload.textContent ?? "null") as Record<string, unknown>);
hydrate(
  () => <App load={() => Promise.resolve(new URLSearchParams(location.search).get("name") ?? "stranger")} />,
  container,
  { data },
);

Notes on putting this together:

  • Use @shadow-js/vite; for the server entry, Vite's SSR build or ssrLoadModule makes the plugin emit the server target. The packages' node, worker, deno and bun export conditions select the server builds of @shadow-js/core and @shadow-js/web (no effects, no DOM).
  • Import @shadow-js/web/server only in server code. The one exception is deserialize, a pure function with no DOM or Node dependency, which the client entry uses to read the data block.
  • The data block must be in the document before the client entry runs, and hydrate must be called with the same renderId that the server used.
  • If the page renders portals (dialogs, sheets) open at render time, pass a createServerPortals() collector to the render and emit portals.html() after the root element (see "Portals on the server").
  • For renderToStream, write the document head first, pipe the stream into the body, then close the document. The data block is the last chunk. A type="module" script runs after the document has been fully parsed, so it can read the block at its top level; a classic inline script cannot.
  • With a nonce-based CSP, pass the same nonce to renderToStream; the swap scripts carry it. The data block is type="application/json", so it is not executed and needs no nonce.