JORVEL

@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

Node deployments import from @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

ts
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.

ts
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.

ts
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

ts
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.

ts
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.

ts
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;
apps/dashboard/src/pages/users/[id].tsx
tsx
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.

ts
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

ts
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

ts
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

ts
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

ts
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

ts
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.

ts
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.