@sigx/server-renderer
v0.15.5
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-rendererA 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 — reading
getCurrentInstance() from an async continuation (after the first await of
an async setup, or from a fetcher) is unsupported on the server exactly as
it is in the browser: the current instance is one module-level slot, live
only for the synchronous span of setup(). 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/
