A request handler by hand
Without Dusk, a server entry is a function from a request to a response.
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 orssrLoadModulemakes the plugin emit the server target. The packages'node,worker,denoandbunexport conditions select the server builds of@shadow-js/coreand@shadow-js/web(no effects, no DOM). - Import
@shadow-js/web/serveronly in server code. The one exception isdeserialize, 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
hydratemust be called with the samerenderIdthat the server used. - If the page renders portals (dialogs, sheets) open at render time, pass a
createServerPortals()collector to the render and emitportals.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. Atype="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
noncetorenderToStream; the swap scripts carry it. The data block istype="application/json", so it is not executed and needs no nonce.