@pyreon/runtime-server
v0.52.0
Published
SSR/SSG renderer for Pyreon — streaming HTML + static generation
Readme
@pyreon/runtime-server
VNode → HTML renderer with progressive streaming and per-request AsyncLocalStorage isolation.
Walks a VNode tree and produces an HTML string or a Web-standard ReadableStream of chunks. Signal accessors are called synchronously to snapshot their current value — there is no reactivity on the server. renderToStream flushes progressively and resolves Suspense boundaries out-of-order (fallback first, then a <template> + inline swap script). Every renderToString / renderToStream / runWithRequestContext call runs in its own ALS store so concurrent requests never share provide() frames; the @pyreon/store registry is isolated automatically when the store is loaded (configureStoreIsolation() is the override, not the switch). Most apps consume this transitively through @pyreon/server.createHandler or @pyreon/zero rather than calling directly.
Install
bun add @pyreon/runtime-server @pyreon/core @pyreon/reactivityQuick start
import {
renderToString, renderToStream, runWithRequestContext, configureStoreIsolation,
} from '@pyreon/runtime-server'
import { setStoreRegistryProvider } from '@pyreon/store'
// Once at server startup — wire per-request store isolation:
configureStoreIsolation(setStoreRegistryProvider)
// One-shot HTML
const html = await renderToString(<App />)
// Progressive stream
return new Response(renderToStream(<App />), {
headers: { 'content-type': 'text/html' },
})
// Pre-fetch loader data + render, all under one isolated request context
const html = await runWithRequestContext(async () => {
await prefetchLoaderData(router, url.pathname, request)
return renderToString(<App />)
})renderToString
const html = await renderToString(vnode): Promise<string>One-shot HTML. Awaits async components. Each call gets its own context stack — no cross-request leakage even under high concurrency. Returns the complete document fragment for the rendered tree (the surrounding <!doctype html> shell is your responsibility).
renderToStream
const stream = renderToStream(vnode): ReadableStream<string>Progressive flush. Synchronous subtrees stream as soon as they're rendered. <Suspense> boundaries are streamed out-of-order: the fallback is emitted in-place, and when the async work resolves the resolved children arrive later as a <template> element followed by an inline <script> that swaps the template into the original slot. The browser parses both inline-and-resolved without needing a second request.
return new Response(renderToStream(<App />), {
headers: { 'content-type': 'text/html; charset=utf-8' },
})30-second Suspense timeout: if a boundary hasn't resolved within 30 seconds, the fallback stays in place forever and a dev-mode warning fires. No error is thrown — the stream completes cleanly with the fallback persisted.
Per-request context isolation
Pyreon's provide() / useContext use a module-level context stack at runtime — fine for a browser process with one document. On the server, concurrent requests would share that stack. runtime-server wraps each render in an AsyncLocalStorage store, so every renderToString / renderToStream / runWithRequestContext call gets its own isolated context frames.
// Outside any render? Use runWithRequestContext to isolate manual API calls
await runWithRequestContext(async () => {
router.preload(pathname, request)
return renderToString(<App />)
})Store isolation
import { configureStoreIsolation } from '@pyreon/runtime-server'
import { setStoreRegistryProvider } from '@pyreon/store'
configureStoreIsolation(setStoreRegistryProvider) // once at startupYou do NOT need this call. @pyreon/store publishes its registry setter on a globalThis seam when it loads on a server, and this renderer wires it at its own choke point, so each request already gets its own store map. It was opt-in before, which meant unisolated by DEFAULT: @pyreon/server and @pyreon/zero own the server and neither depends on @pyreon/store — that is why the API takes a setter as an argument — so the only party who could opt in was the application author. Call it to supply a provider of your own (a shared build-time cache across SSG pages, a test double); once at startup, not per request. An app that already calls it keeps working unchanged.
SSR-safe contracts the renderer enforces
- No reactivity. Signal accessors are snapshotted at render time. No effects are created on the server.
useHead,useStore, and similar APIs run their setup once and read the resulting value. - HTML escape + URL sanitization. All text content is escaped. URL attributes (
href,src,action,formaction) rejectjavascript:anddata:URIs by default. - Event handlers omitted.
on*props are stripped from the output — the server can't bind them. Hydration on the client wires them up. <For>key markers (<!--k:KEY-->) are URL-encoded so user-controlled keys cannot break out of the HTML comment. CompaniondecodeKeyFromMarker(comment)helper available for hydration / devtools consumers.- Compiler-emitted reactive props are resolved via
makeReactivePropsbefore each component invocation — parity with the CSR mount.ts path. SSR-rendered HTML matches what the client would render.
When to use this directly
Most Pyreon apps use:
@pyreon/server.createHandler— full SSR request handler with loader prefetching, error handling, head injection@pyreon/zero— full meta-framework wrapping the above plus routing, SSG, ISR
Use @pyreon/runtime-server directly when you need to:
- Render a fragment for a non-page response (RSS feed, OG image SVG, email body)
- Compose your own custom SSR pipeline outside of
@pyreon/server - Generate static HTML at build time without an HTTP layer
Dev-mode gates
Server packages use typeof process !== 'undefined' && process.env.NODE_ENV !== 'production' for dev-only diagnostics — the server-runtime convention. Server code doesn't go through Vite's bundle-time replacement (it runs in Node at startup), so the typeof guard reads correctly at runtime.
Documentation
Full docs: pyreon.dev/docs/runtime-server (or docs/src/content/docs/runtime-server.md in this repo).
License
MIT
