@jorvel/ssr
Server-rendering toolkit for JORVEL. Framework-agnostic — you pass your React App, your routes table, and an HTML template; the package handles streaming, caching, and the Suspense → HTTP-status bridge.
Entry points
@jorvel/ssr (re-exports @jorvel/ssr/node). Cloudflare Workers, Vercel Edge, and Deno Deploy import from @jorvel/ssr/edge — that bundle excludes node:stream and node:fs/promises so it loads cleanly under non-Node runtimes.Render
renderRouteToString(App: ComponentType, opts: {
path: string;
params?: Record<string, string>;
enrichHead?: (head: string, ctx: RenderContext) => string;
}): Promise<SsrRenderResult>;
type SsrRenderResult = {
html: string;
statusCode: number; // 200 / 404 / 302 etc. — set by redirect()/notFound()
redirect?: { status: number; location: string };
state?: unknown; // anything passed to provideSsrState()
};
renderRouteToStream(App, opts: {
path: string;
params?: Record<string, string>;
signal?: AbortSignal;
timeoutMs?: number;
onError?: (err: unknown) => void;
}): Promise<StreamRenderResult>;
type StreamRenderResult = {
pipe(writable: NodeJS.WritableStream): void;
abort(): void;
errors: unknown[]; // populated by deferred Suspense errors
};
injectIntoTemplate(template: string, html: string, opts?: { head?: string; bodyEnd?: string }): string;
// Web-stream variants (edge runtimes)
renderRouteToReadableStream(App, opts): Promise<ReadableStreamRenderResult>;
renderRouteToResponse(App, opts): Promise<Response>;
collectReadableStream(stream: ReadableStream): Promise<string>;Static export
Pre-render a list of routes to disk. Worker-pool parallelism by default. Optional manifest records per-file content hashes for cache invalidation.
staticExport(opts: {
App: ComponentType;
template: string;
routes: Array<{ path: string; params?: Record<string, string> }>;
outDir: string;
concurrency?: number; // default 8
manifestFile?: string; // e.g. 'manifest.json' — content-hash + bytes per output
detailed?: boolean; // returns { successes, failures } instead of just count
}): Promise<number | { successes: ExportEntry[]; failures: ExportFailure[] }>;
revalidateStaticPages(opts: RevalidateStaticPagesOptions): Promise<RevalidateResult>;Revalidation
Re-export a subset based on age or an explicit list. Pairs with a cron job / cache-invalidation webhook from the CMS.
await revalidateStaticPages({
manifestPath: 'dist/manifest.json',
outDir: 'dist',
App,
template,
paths: ['/docs/getting-started'], // explicit
// or: maxAgeMs: 24 * 60 * 60 * 1000 // stale-after age
});Edge adapter
createEdgeAdapter(opts: {
App: ComponentType;
template: string;
routes: RouteEntry[];
cache?: { scope?: 'public' | 'private'; maxAge?: number; sMaxAge?: number; staleWhileRevalidate?: number };
etag?: boolean;
csp?: (req: EdgeRequest) => string | { header: string; nonce: string };
htmlCache?: HtmlCache; // e.g. new LruHtmlCache({ max: 256, ttlMs: 60_000 })
onNotFound?: (req: EdgeRequest) => EdgeResponse;
beforeRemoteLoad?: (descriptor: RemoteDescriptor) => void | Promise<void>;
}): (req: EdgeRequest) => Promise<EdgeResponse>;
class LruHtmlCache {
constructor(opts: { max: number; ttlMs?: number });
get(key: string): { html: string; etag: string } | undefined;
set(key: string, value: { html: string; etag: string }): void;
}Remote SSR
Server-side equivalent of RemoteOutlet. Loads each remote's server bundle and renders the matched subpath. The host's response embeds the remote's HTML + hydration state.
ssrLoadRemote(name: string, entryUrl: string): Promise<unknown>;
ssrRenderRemote(remote: string, opts: {
exposed: string; // './App'
path: string;
params?: Record<string, string>;
}): Promise<{ html: string; state?: unknown }>;
createSsrRemoteOutlet(config: {
routes: HostRoute[];
remotes: Record<string, { entryUrl: string }>;
}): (path: string) => Promise<{ html: string; state?: unknown }>;
// Edge variant — no dynamic require, requires pre-imported remote modules
ssrLoadRemoteEdge(name: string, mod: unknown): unknown;Loaders
Server-only data fetchers attached to routes. Run before render; the result is passed via context to the component tree and serialized into the hydration payload.
defineLoader<T, Params = Record<string, string>>(
fn: (ctx: LoaderContext<Params>) => Promise<T> | T,
): LoaderDescriptor<T, Params>;
interface LoaderContext<P> {
params: P;
request: Request;
cookies: Record<string, string>;
abort: AbortSignal;
}
runLoaders<T>(loaders: LoaderDescriptor<unknown>[], opts: RunLoadersOptions): Promise<RunLoadersResult>;
useLoaderData<T extends LoaderDescriptor<unknown>>(): InferLoader<T>;
requireLoaderData<T>(): T;import { defineLoader, useLoaderData } from '@jorvel/ssr';
export const loader = defineLoader(async ({ params }) => {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) throw new Response('Not Found', { status: 404 });
return { user };
});
export default function UserPage() {
const { user } = useLoaderData<typeof loader>();
return <h1>{user.name}</h1>;
}Fragment SSR
Render multiple Suspense boundaries in parallel and stream them out-of-order. Useful for compose-style pages where each section has its own data source.
renderFragmentsToString(spec: FragmentSpec[]): Promise<RenderFragmentsResult>;
renderFragmentsToReadableStream(spec: FragmentSpec[]): Promise<RenderFragmentsStreamResult>;
interface FragmentSpec {
id: string;
component: ComponentType;
props?: Record<string, unknown>;
timeoutMs?: number;
}Redirects + control-flow
redirect(location: string, status?: 301 | 302 | 303 | 307 | 308): never;
json(body: unknown, status?: number, headers?: Record<string, string>): never;
notFound(): never;
isRedirect(err: unknown): err is SsrRedirect;
isJsonResponse(err: unknown): err is SsrJsonResponse;
isNotFound(err: unknown): err is SsrNotFound;These throw — the renderer catches them and converts to the right HTTP shape. The pattern mirrors Remix loaders, which means error boundaries in your tree work the same way.
Request context
getRequestContext(): RequestContext | undefined;
requireRequestContext(): RequestContext; // throws if outside SSR
runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T;
type RequestContext = {
url: string;
method: string;
headers: Record<string, string>; // lowercase keys
cookies: Record<string, string>;
locals: Record<string, unknown>; // populate from middleware
};
parseCookies(header: string | undefined | null): Record<string, string>;
buildRequestContext(request: Request): RequestContext;State hydration
serializeState(state: unknown, opts?: { key?: string; nonce?: string }): string;
hydrateState<T = unknown>(key?: string): T | undefined;
consumeHydratedState<T = unknown>(key?: string): T | undefined; // reads + clears
clearHydratedState(key?: string): void;Preload links
buildPreloadTags(links: Array<{ href: string; as: 'script' | 'style' | 'image' | 'font'; crossorigin?: boolean }>): string;
remoteEntryPreloads(remotes: Array<{ entryUrl: string }>): string;Drop the output into <head>. Browsers start fetching remoteEntry files in parallel with the HTML, knocking the first-remote round-trip off the LCP critical path.
Cache headers
cacheControl(opts: {
scope?: 'public' | 'private';
maxAge?: number;
sMaxAge?: number;
staleWhileRevalidate?: number;
immutable?: boolean;
}): string;
buildWeakEtag(body: string | Uint8Array): string;
ifNoneMatchHit(etag: string, header: string | null | undefined): boolean;ETag flow
Compute the ETag before serializing the response. If the client sent a matching If-None-Match, return 304 without writing the body — saves both bandwidth and Suspense work.
const html = await renderRouteToString(App, opts);
const etag = buildWeakEtag(html);
if (ifNoneMatchHit(etag, request.headers.get('if-none-match'))) {
return new Response(null, { status: 304, headers: { ETag: etag } });
}
return new Response(html, {
headers: {
'ETag': etag,
'Cache-Control': cacheControl({ scope: 'public', maxAge: 0, sMaxAge: 60, staleWhileRevalidate: 600 }),
'Content-Type': 'text/html; charset=utf-8',
},
});Cancellation
Streaming renders honor the signal option — abort on disconnect to free resources. The Node adapter wires this up to request.on('close'); edge adapters get the platform-provided request.signal.
