zfb
GitHub repository

Type to search...

to open search from anywhere

SSR on a Worker (adapter mode)

How a prerender = false zfb page actually runs in production — the two-layer worker output, how Workers Static Assets serves matching assets before the Worker runs, and the AsyncLocalStorage trick behind getCloudflareContext().

What this page covers

The mental model of what actually runs in production for a prerender = false zfb page: the two-layer worker output, how requests are dispatched to the right handler, and how getCloudflareContext() delivers Cloudflare bindings to any page component without prop-drilling.

This page is the mental model. For hands-on setup — installing the adapter, wiring up wrangler.toml, querying D1, and configuring compatibility_flags — read SSR and Cloudflare Bindings. This page is also specifically about zfb's emitted dist/_worker.js, not external Workers you deploy separately with wrangler.

This model is verified on Workers Static Assets. Cloudflare Pages advanced mode has not been verified for this adapter, so its comparisons below are not a deployment-support claim.

Your page is just a function

Strip away the framework vocabulary and a prerender = false zfb page is a plain async function that returns a Response. In the snippet below, renderToString, getUser, updateProfile, and AccountLayout are placeholder identifiers representing app-side code — they are not exports of zfb or @takazudo/zfb-adapter-cloudflare. The only framework-provided symbol the example uses is getCloudflareContext.

// pages/account.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
import AccountLayout from "../layouts/account-layout";

export const prerender = false;

interface Env {
  DB: D1Database;
  SESSION_SECRET: string;
}

export default async function AccountPage(): Promise<Response> {
  const { env, request } = getCloudflareContext<Env>();

  if (request.method === "POST") {
    const form = await request.formData();
    await updateProfile(env, form);
    return new Response(null, { status: 303, headers: { location: "/account" } });
  }

  const user = await getUser(env, request);
  return new Response(
    renderToString(<AccountLayout user={user} />),
    { headers: { "content-type": "text/html" } },
  );
}

The function receives no arguments. Cloudflare's (request, env, ctx) tuple is available through getCloudflareContext() anywhere in the call tree — layouts, helpers, lib modules. A POST from a <form method="post"> on the same page reaches the same handler; branching on request.method is the mutation path.

Nothing special happens here that does not happen in a hand-written Worker. The rest of this page explains how getCloudflareContext() actually works, and what the build emits to make it possible.

What the build actually emits

Running zfb build with @takazudo/zfb-adapter-cloudflare configured produces two generated JavaScript files under dist/, plus a compiled Wasm module for each .wasm import in the SSR graph:

dist/_worker.js — a small auto-generated stub written by the adapter. This is the Worker entry referenced by your wrangler config's main field under Workers Static Assets. Its root-level shape follows the Cloudflare Pages advanced-mode convention, but that target is unverified. Its job is to set up an AsyncLocalStorage scope for (env, ctx, request) and then, for the requests it actually receives, dispatch each one — either to the static asset server via env.ASSETS or to the inner bundle. It does not contain your application code.

dist/_zfb_inner.mjs — the real application bundle: every pages/*.tsx file, layouts, components, and lib helpers compiled into a single Hono-shaped ESM module. This is what actually renders your pages.

Compiled Wasm modules

When an SSR route imports Wasm, zfb emits a <name>-<hash>.wasm file (e.g. index_bg-a1b2c3d4.wasm — esbuild's own --asset-names=[name]-[hash] convention, not a zfb-specific scheme) beside these two JavaScript files. _zfb_inner.mjs imports it as a compiled WebAssembly.Module, while .assetsignore prevents it from becoming a public asset. See Importing WebAssembly for the adapter-agnostic build primitive — the default import, ambient typing, and dev/SSG/SSR behavior — and the SSR and Cloudflare Bindings guide for the Satori/Resvg setup, Wrangler rule, and package-size details.

The two-file layout avoids a second esbuild pass inside the adapter. Workerd's module loader resolves relative ESM imports from the deployed worker directory, so _worker.js can simply import inner from "./_zfb_inner.mjs" and both files stay independent.

For the broader two-bundle mental model (worker bundle vs island bundles), read Architecture overview.

The dispatch flow

Under Workers Static Assets — zfb's default deploy target — the platform's asset router runs before your Worker. With run_worker_first = false (the zfb default), Cloudflare matches each incoming request against the built static assets first. If a request matches a prerendered file, the edge serves it directly and _worker.js never runs. Only the misses — requests with no matching asset — fall through to your Worker. The old "every request hits _worker.js first" model is inverted: the platform serves assets first, and the Worker sees only the dynamic tail.

The asset layer also owns URL canonicalisation. With html_handling = "auto-trailing-slash", a request for a prerendered route is redirected to its canonical trailing-slash form before the file is served:

/docs/account307 redirect → /docs/account/dist/docs/account/index.html

(On Cloudflare Pages the equivalent redirect is a 308 — see the dual-compat note below.) So the production mental model is platform-first: static hits are served at the edge by the asset layer, and your Worker only ever sees the dynamic tail — prerender = false routes such as pages/api/*.tsx, plus anything the asset router did not resolve.

The in-Worker env.ASSETS probe is not dead code

_worker.js also contains an asset probe: before delegating to the inner bundle it can call env.ASSETS.fetch(request) itself. Under run_worker_first = false that probe is normally bypassed for asset hits — the platform already served them — which raises a fair question: why keep it at all? Because the same emitted wrapper has to work across the situations where the Worker, not the platform, is the first thing a request touches. worker-wrapper.mjs spells this out:

// Dispatch order is deliberately "ASSETS first, inner on 404" — this
// holds across both deploy targets, but *why* the ASSETS probe fires
// differs:
//
//   - Workers Static Assets with `run_worker_first = false` (the zfb
//     default): the platform itself serves asset hits before the
//     Worker ever runs, so for those requests this in-Worker probe is
//     normally bypassed — it never had a chance to run. The probe below
//     is NOT dead code: it is still exercised for Pages (no
//     `run_worker_first` concept; every request hits the Worker), for
//     `run_worker_first = true` deployments, and for any request the
//     platform's asset router itself does not resolve (which still
//     reaches this Worker as a "miss").

So the probe earns its place in exactly three cases:

  1. Cloudflare Pages (advanced mode). Pages has no run_worker_first concept — every request hits _worker.js first, so the in-Worker probe is the asset-serving path. This is what makes the same emitted file dual-compatible.

  2. run_worker_first = true. You have told the platform to run the Worker ahead of its asset router, so every request reaches the probe.

  3. Worker-handled misses under run_worker_first = false. A request the platform's asset router did not resolve still arrives at the Worker; the probe runs and may resolve it against env.ASSETS, or return 404 and fall through to the inner bundle.

The probe only fires when the ASSETS binding is actually configured — the wrapper guards on it:

function canDelegateToAssets(env) {
  return Boolean(env && env.ASSETS && typeof env.ASSETS.fetch === "function");
}

When it does run, the dispatch rule inside the Worker is static-first, dynamic-second:

Request methodDispatch order (when the Worker runs)
GET, HEADProbe env.ASSETS first. The inner worker is invoked only if the asset server returns 404. Any other status — 200, 307, etc. — is returned to the client directly.
POST, PUT, PATCH, DELETESkip env.ASSETS entirely. Go straight to the inner worker.

The fall-through rule is "404 only", not "non-200 only". This matters because the asset server emits redirects (307 on Workers Static Assets, 308 on Cloudflare Pages) to canonicalise trailing slashes for prerendered routes, and the wrapper has to let those reach the client so the browser can follow them to the index.html. Inspecting worker-wrapper.mjs: the wrapper calls await env.ASSETS.fetch(request) and returns the response untouched whenever assetResponse.status !== 404.

Why static-first for GET/HEAD: the asset server is responsible for the trailing-slash canonicalisation described above, and it also serves the build-time head injection (<link rel="stylesheet">, <script type="module" src="/assets/islands-<hash>.js">) that makes island hydration work. If the inner Hono router handled prerendered routes first, it would re-render them at request time without those injected assets, and islands would not hydrate.

Why POST/PUT/PATCH/DELETE go straight through: The asset server is read-only by definition. Probing it for a form submission would always return 404 or 405 — an unnecessary round-trip with no benefit. Form submissions, JSON API calls, and any mutation go directly to the inner worker.

The net effect is the same on either target: static pages are not paying the SSR cost. A prerender = true page is served by the asset layer — the platform's edge router under Workers Static Assets, or the in-Worker env.ASSETS probe on Pages — and the inner worker's fetch handler is not invoked. (The inner bundle is still loaded and evaluated on worker boot because _worker.js imports it at module scope — see What is NOT in the worker output for the precise wording.)

The getCloudflareContext() trick

Cloudflare Workers can dispatch multiple requests concurrently inside the same V8 isolate. A naïve globalThis.__env = env write would race across those concurrent requests. The adapter solves this with AsyncLocalStorage from node:async_hooks.

The mechanism in two steps:

Step 1 — the wrapper stores the context. Before calling inner.fetch(request), the generated _worker.js stub runs:

als.run({ env, ctx, request }, () => inner.fetch(request));

This opens a per-request async scope. Every await inside that scope — across layouts, helpers, database calls — still sees the same stored value.

Step 2 — user code reads the context. getCloudflareContext<Env>() calls als.getStore() on the same AsyncLocalStorage instance. Because the adapter module registers the storage instance on globalThis under a stable key, the wrapper file (_worker.js) and the user bundle (_zfb_inner.mjs) share the same instance even though they are separate ESM modules. getStore() returns the { env, ctx, request } object the wrapper stored for this request, not for any other concurrent request.

Why this means compatibility_flags = ["nodejs_compat"] is mandatory. AsyncLocalStorage lives in node:async_hooks. Workerd does not expose it by default — you must opt in with:

# wrangler.toml
compatibility_flags = ["nodejs_compat"]

Without this flag, the Worker fails to boot. The error message names node:async_hooks as the missing module. See the SSR and Cloudflare Bindings guide for the full wrangler.toml configuration.

Why AsyncLocalStorage instead of prop-drilling. Page handlers compose shared layouts and lib helpers freely. Threading env as an explicit parameter would force every component and helper to accept it — a leaky coupling between Cloudflare-specific infrastructure and generic UI code. ALS lets the framework boundary stay clean: the adapter owns the storage, user code reads it only where it's needed.

If you know Next.js or Remix

The concepts map like this:

ConceptNext.js App RouterRemixzfb adapter-mode
File-based routingapp/account/page.tsxroutes/account.tsxpages/account.tsx
Server data fetchasync function Page()loaderasync function AccountPage()
MutationServer ActionsactionPlain <form method="post">
Layoutslayout.tsxNested route layoutslayouts/*.tsx (imported)
Static vs dynamicdynamic = 'force-static' / 'force-dynamic'(always dynamic)export const prerender = true / false

Mechanically a Worker; ergonomically App-Router-style SSR; philosophically Remix-without-the-Remix-runtime. No RSC streaming, no Server Actions abstraction — just one bundle and a Web fetch handler.

What is NOT in the worker output

Two categories of output are intentionally absent from dist/_worker.js and dist/_zfb_inner.mjs:

Islands — components marked with "use client" are compiled by a separate esbuild step into a single combined browser-shipped bundle. In dev the bundle lands at dist/assets/islands.js (stable filename, no hash); in production ProductionAssetPipeline writes it as dist/assets/islands-<hash>.js and rewrites every reference in the rendered HTML to the hashed URL. The bundle is neither part of dist/_worker.js nor of dist/_zfb_inner.mjs — it is browser-shipped JavaScript, not server-executed Worker code. The Worker renders the static HTML shell; the browser downloads the islands bundle and its top-level hydration code walks every [data-zfb-island] element on the page. See Islands for the full mechanism.

Prerendered pages — any page that exports prerender = true (or omits the export, which defaults to true) is rendered to static HTML at build time. At request time the asset layer serves the .html file directly — the platform's edge asset router under Workers Static Assets (with run_worker_first = false), or the in-Worker env.ASSETS probe on Cloudflare Pages — so the inner Worker's fetch handler is not invoked for those routes.

Whether the inner bundle is even loaded depends on whether the Worker boots at all. _worker.js does a static import inner from "./_zfb_inner.mjs", so once the Worker boots, workerd pulls the whole inner module graph into memory regardless of which route triggered that boot — a static hit skips inner.fetch(), but it does not make workerd skip loading the inner bundle. On Cloudflare Pages (and under run_worker_first = true) the Worker boots on the first request, so this always applies. Under Workers Static Assets with run_worker_first = false, though, the Worker boots only when a request first misses the asset router: a deploy whose traffic only ever hits prerendered assets may never boot the Worker — and so never load the inner bundle — at all.

  • Architecture overview — the two-bundle model and how the worker bundle shape is the stable contract between build time and production.

  • Islands — how "use client" opts a component into the browser-shipped bundle that lives outside the Worker.

  • SSR and Cloudflare Bindings — for hands-on setup: wrangler.toml, D1 queries, secrets, and local development with wrangler dev.

Revision History

CreatedUpdated