@sigx/server-renderer
v1.0.1
Published
Server-side rendering and client hydration for SigX
Maintainers
Readme
@sigx/server-renderer
Server-side rendering and client hydration for SignalX. Supports streaming and string-based rendering, a plugin-driven architecture, and head management.
The SSR platform's boundary model lives here: plugins decide a component's
flush (inline / stream / skip) and hydrate (load / idle / visible / media /
interaction / never) axes through the pre-setup resolveBoundary hook, core
records them in the per-request __SIGX_BOUNDARIES__ table, and the built-in
boundary hydrator schedules each one client-side — selective hydration without
a framework switch. @sigx/ssr-islands is the first-party pack on these seams.
Packs install on the APP — app.use(pack()) is the one install shape (#413).
A pack's install(app) registers its server render hooks through the
provideSSRPlugin seam, and every render method that receives the App
(createSSR().render(app), the request/fetch handlers' app option) picks
them up. createSSR({ plugins }) remains as the instance-level channel for
engine internals, tests, and custom engines; there is no SSRInstance.use().
📚 Full guides, API reference and live examples → https://sigx.dev/server/
Install
npm install @sigx/server-rendererPeer of
sigx. This package peers onsigx(a caret on the published version,^1.0.0from 1.0);@sigx/serializeis its only dependency. An app installssigxonce and owns the single copy of the runtime; a library declares it as a peer, never as a dependency (docs/rfc-1.0.md§3).
A taste
import { renderToStream } from '@sigx/server-renderer/server';
import App from './App';
// Streaming (recommended)
const stream = renderToStream(<App />);// Client hydration
import { defineApp } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import App from './App';
defineApp(<App />).use(ssrClientPlugin).hydrate('#root');The request handler
Production servers are static assets plus one handler over the public
document API — crawlers get blocking documents, everyone else shell-first
streaming, with useResponse's status/headers/redirect written before the
first byte (the dev twin lives in @sigx/vite/ssr):
import { createRequestHandler } from '@sigx/server-renderer/node';
app.use(createRequestHandler({
template,
app: (url) => createApp(url), // fresh app per request
document: { assets } // manifest preloads (collectAssets)
}));The fetch handler (edge runtimes)
createFetchHandler is the WinterCG sibling (rfc-deploy §2) — the same
dispatch decisions expressed as (Request) => Promise<Response>, the shape
Cloudflare Workers, Deno Deploy, Bun, Vercel Edge, and Netlify all consume
natively. It lives on the WinterCG-clean ./server entry (and the root
.), so it runs wherever the render path runs:
import { createFetchHandler } from '@sigx/server-renderer/server';
const handler = createFetchHandler<{ env: Env; ctx: ExecutionContext }>({
template,
app: (url) => createApp(url), // same frozen entry contract
document: { assets }
});
export default {
// static assets → server functions → document render: the composition
// stays in YOUR entry — no sigx handler serves files or mounts the
// server-fn endpoint for you.
fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
handler(request, { env, ctx })
};The second argument is the platform context — opaque to sigx, threaded
verbatim into the template/app/document callbacks. It is optional
under the default TPlatform = unknown (Deno/Bun entries pass nothing);
instantiating the generic with typed bindings makes it required, so
forgetting Cloudflare's { env, ctx } is a compile error rather than an
undefined at render time. A redirect from useResponse
returns a bodyless Response and releases the render; cancelling the
Response body (client disconnect) does the same. A shell failure yields a
minimal 500 — there is no next() in the fetch world; a custom error page
is a try/catch wrapper around the returned handler.
Shared with the Node handler: defaultIsBot (the crawler regex behind the
bot → blocking dispatch) and chunksToBytes (the pull-based
string-chunks → UTF-8 ReadableStream<Uint8Array> encoder under both the
fetch handler and renderDocumentToWebStream) — both exported from
./server for hand-written servers.
Runtime portability & request isolation
The . and ./server entries are WinterCG-clean — no Node builtins on
the string / Web-stream / document paths, verified in CI by an edge smoke
test that forbids every node: import while streaming a document through
the production dist. Node-only Readable shapes (renderToNodeStream,
renderDocumentToNodeStream, the toNodeStream adapter) live in
@sigx/server-renderer/node.
Request isolation is a contract, not a runtime feature: the per-request
SSRContext is the isolation mechanism — AsyncLocalStorage is never
required. Everything a request collects (head configs, response state,
async results, the boundary table) lives on its own context, created per
render call; concurrent renders share nothing. There is no AsyncLocalStorage
anywhere on the render path and never has been. The current instance is one
module-level slot, and the walk keeps it correct for the whole of a
component's render — setup(), the render function that runs once its
async loads settle (inline, or in a streamed deferred render), an
errorScope fallback after a rejected one — across every suspension,
however many concurrent renders interleave at that await (#552). What stays
unsupported, on the server exactly as in the browser, is reading
getCurrentInstance() from an async continuation of your own (after the
first await of an async setup, or from a fetcher): that code runs outside
the walk, on whatever the slot holds at the time. Resolve everything
instance-dependent synchronously in setup and close over the result.
Request-state registration (packs)
useData/useStream values reach the client automatically through the
__SIGX_ASYNC__ blob. A pack that owns request-scoped state of its own —
@sigx/store's ssrState is the canonical case — enters the same blob
through the public writer on the per-request context (#407):
// inside a component setup, during a server render
const ssrCtx = getCurrentInstance()?.ssr?._ctx; // typed on SSRHelper
ssrCtx?.registerSerializedState('store:cart', {
toJSON: () => snapshot(state) // encoded at EMIT time, so state
}); // mutated later serializes finalEmission is handled by stateSerializationPlugin (on by default under
renderDocument): shell-walk registrations ship with the shell blob;
stream-phase registrations (a store first created below a streamed
boundary) ship with the next flush; the onStreamEnd plugin hook — the
request's last emission point — is a final drain that guarantees delivery.
Keys share the useAsync/useStream namespace, so prefix them
(store:cart). Re-registering an emitted key ships a patch; the client
merge is last-write-wins. The client half is peekRestored /
invalidateRestored / reviveFromServer from
@sigx/runtime-core/internals — the blob's single accessor
(docs/seams.md). reviveFromServer — the boundary codec's revive half —
is also a public export of ./client, for pack code that decodes
serialized boundary state itself.
The pack contract (SSRContext accessors)
Strategy packs — first-party (@sigx/ssr-islands, @sigx/resume) and
third-party alike — build on the same typed public surface; nothing they
need lives behind underscore fields or internals imports (#416):
ctx.currentComponentId()— the id of the component currently rendering.resolveBoundaryandtransformComponentContextrun after the component's id is pushed, so this is "the component this hook call is about".ctx.boundaries()— the per-request boundary table as aReadonlyMap<number, SSRBoundaryRecord>(live, not a snapshot), for whole-table scans: islands' "any schedulable island?" preload check, resume's refresh-envelope encoding. Mutate individual records throughctx.getBoundary(id); the table's shape belongs to core.createSSRContext({ appContext })— seed a self-created context with an app's DI (type handlers, provides). A boundary refresh re-rendering in a fresh context passes the app context here instead of writing a private field.SSRPack— the factory return type for packs installed withapp.use(pack): the one object that is both theSSRPlugin(render and hydration hooks) and the app plugin (install(app)registers it viaprovideSSRPlugin(app._context, pack)).
The eager scheduler vs the lazy hydration core
Client-side selective hydration is split in two, so deferred pages execute zero runtime JS at load:
@sigx/server-renderer/client/scheduler— the eager half: reads the boundary table, wiresload/idle/visible/media/interactiontriggers, listens for streamed boundaries. It imports nothing from the sigx family (~2 kB, size-limit-guarded). The table surface is an accessor pair: reads (getBoundaryTable/getBoundaryRecord) and writes (installBoundaryRecords/removeBoundaryRecord— how a single-flight refresh patch enters the table and a swapped-out boundary retires, rfc-server §6.3).- The hydration core — the executor (
hydrateComponent, the renderer, mount/hydrate primitives) lives in a separate chunk thatloadHydrationCore()dynamically imports on the first strategy that actually fires. The component chunk and the executor fetch in parallel.
registerClientPlugin (part of the scheduler surface) accepts either a
plugin object or a lazy source — { name, load: () => import('...') } —
resolved together with the hydration core, so a pack's client hooks ride the
same lazily-fetched chunk as the renderer. Registrations dedupe by name,
first-wins. Packs that hydrate through their own wake-up (rather than the
boundary scheduler) should register plugin objects, or await
resolveClientPlugins() before hydrating.
Server plugins can contribute <link rel="modulepreload"> URLs to the
document shell via the optional assets(ctx) hook — the intended pairing is
to preload the lazily-imported runtime chunk whenever the request recorded
schedulable boundaries, keeping the fetch off the critical path while
execution still waits for the first trigger.
The ./client barrel re-exports the full surface (scheduler + executor +
ssrClientPlugin) for app-rooted hydration, where the runtime is loaded by
definition.
📚 Documentation
Streaming and string rendering, the plugin system, hydration, head management — full guides, the complete API reference and live examples → https://sigx.dev/server/
