zudo-cloudflare-wisdom
GitHub repository

Type to search...

to open search from anywhere

SSR Bindings via AsyncLocalStorage

How the zfb Cloudflare adapter threads per-request env/ctx into SSR pages using AsyncLocalStorage and a generated _worker.js wrapper, on Workers Static Assets or Pages advanced mode

Overview

When an SSR framework builds for Cloudflare, it has to solve one awkward problem: a page handler that runs deep inside the framework's router needs access to the per-request env (your KV, D1, R2, and secret bindings) and ctx (waitUntil, passThroughOnException). Those values are only handed to the top-level fetch(request, env, ctx) entry — they are not global, and passing them down through every layer of the router by hand is impractical.

The adapter solves this with AsyncLocalStorage. It emits a _worker.js that wraps the real (inner) worker bundle, captures { env, ctx, request } at the entry point, and makes it readable anywhere in the request via a small getCloudflareContext<Env>() accessor. The generated wrapper deploys two ways: as the main Worker entry alongside a Workers Static Assets [assets] block in wrangler.toml — the adapter's primary, verified target — or as a Cloudflare Pages advanced-mode _worker.js, the same file and convention, but unverified by the adapter's own test suite.

This page explains how that wrapper works, why AsyncLocalStorage (rather than a plain global) is the correct mechanism, and the request-dispatch contract you inherit the moment a _worker.js exists — on either target.

What the adapter emits

zfb build produces two files for the adapter's Worker output:

  • dist/_worker.js — the generated wrapper (shown below).

  • dist/_zfb_inner.mjs — your actual SSR bundle (the framework router and all your pages).

These two files are the same regardless of deploy target. On Workers Static Assets, _worker.js becomes the main entry in wrangler.toml, shipped alongside an [assets] block that points at the same dist/ directory. On Cloudflare Pages advanced mode, the same _worker.js sits at the root of the Pages output and is picked up by convention — no wrangler.toml main/[assets] wiring needed there, but also not a target the adapter's test suite exercises.

The wrapper imports the inner bundle by relative path (./_zfb_inner.mjs) rather than inlining it. Workerd's module loader resolves relative ESM imports inside a _worker.js directory, so the two files ship side by side and the adapter package never needs to bundle an esbuild binary into your output.

The generated _worker.js wrapper

// AUTO-GENERATED by @takazudo/zfb-adapter-cloudflare. Do not edit.
//
// Cloudflare Workers Static Assets entry (`main` in wrangler.toml,
// alongside an `[assets]` block; also deployable to Cloudflare Pages
// advanced mode). Forwards (request, env, ctx) to the inner zfb worker
// bundle, exposing env/ctx to user code via AsyncLocalStorage under a
// stable globalThis key.
//
// The same key is read by @takazudo/zfb-adapter-cloudflare's
// getCloudflareContext() inside the user bundle, so the two ends share
// state even though they live in separate ESM module instances.
//
// 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").
//   - GET/HEAD requests probe env.ASSETS first. The asset server
//     handles the trailing-slash canonicalisation for SSG output (e.g.
//     "/docs/foo" → redirect → "/docs/foo/" → dist/docs/foo/index.html
//     — 307 on Workers Static Assets, 308 on Cloudflare Pages), so
//     prerendered routes get the build-time head-injected HTML
//     (<link rel="stylesheet">, <script type="module" src="/assets/
//     islands-…">). If we let the inner Hono router handle them first,
//     it would dynamic-SSR the page WITHOUT the prod head injection
//     (which is a build-time post-process, not a runtime concern), and
//     islands would never hydrate.
//   - On 404 from ASSETS, fall through to the inner zfb worker — this
//     is where genuinely dynamic routes (`prerender = false`, e.g.
//     `pages/api/*.tsx`) are served. EXCEPTION: when the asset 404
//     carries a *styled* 404-page body (`not_found_handling = "404-page"`
//     on Workers Static Assets, or the `404.html` convention on Pages —
//     detected as an HTML document by content-type; the assets binding
//     streams every response and never sends a `content-length` header)
//     AND the inner ALSO 404s with only the framework default body (Hono's
//     default not-found, `text/plain` "404 Not Found", or a bare 404
//     with no content-type), the earlier styled asset 404 is preferred
//     over the inner's plain 404 — otherwise the styled `404.html` would
//     be discarded and users would see the inner plain-text 404 (issue
//     #1322). Any inner 404 that declares another content-type is a
//     deliberate response and WINS: a `text/html` 404 is a rendered
//     custom not-found page (a `prerender = false` route SSRing its own
//     404), and an `application/json` 404 is an intentional API error —
//     both are returned unchanged. Under `not_found_handling = "none"`
//     the asset 404 body is empty/non-HTML, so the inner always wins —
//     the historical behavior is preserved.
//   - Non-GET/HEAD requests skip ASSETS and go straight to the inner —
//     assets are read-only by definition, and we want POSTs
//     (`/api/ai-chat`, etc.) to reach the SSR handler without a probe
//     that would always 405 / 404.
import { AsyncLocalStorage } from "node:async_hooks";
import inner from "./_zfb_inner.mjs";

const STORAGE_KEY = "__zfb_cf_adapter_als__";

function getStorage() {
  const g = globalThis;
  let als = g[STORAGE_KEY];
  if (!als) {
    als = new AsyncLocalStorage();
    g[STORAGE_KEY] = als;
  }
  return als;
}

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

function isAssetProbeMethod(method) {
  // Only safe, side-effect-free methods probe the asset server. POST/
  // PUT/PATCH/DELETE go straight to the inner SSR worker.
  return method === "GET" || method === "HEAD";
}

function assetHasStyled404Body(response) {
  // True iff an ASSETS 404 carries the site's *styled* 404 page.
  // Platform fact: `not_found_handling = "404-page"` (Workers Static
  // Assets) and the Pages `404.html` convention both serve that page as
  // an HTML document — auto-detected `content-type: text/html`. The
  // Workers Static Assets binding streams every response (chunked
  // locally, HTTP/2 framed in production) and never sends a
  // `content-length` header, so content-type alone is the discriminator.
  // `not_found_handling = "none"` sends Cloudflare's default 404 with
  // NO headers at all (empty/non-HTML content-type), so this still
  // returns false there and the inner wins.
  // Header-only (never reads the body) so it holds for HEAD too and leaves
  // the one-shot asset stream intact for a verbatim return.
  const contentType = (response.headers.get("content-type") || "").toLowerCase();
  return contentType.includes("text/html");
}

function innerIsFrameworkDefault404(response) {
  // True iff an inner 404 is the framework's *generic* not-found, which
  // yields to the styled asset 404 page. The inner zfb router never
  // overrides Hono's default handler, so a route miss is always Hono's
  // default not-found — `text/plain` "404 Not Found" (or a bare 404 with no
  // content-type). Any inner 404 that declares another content-type is a
  // *deliberate* response and must WIN over the static styled asset page: a
  // `text/html` 404 is a rendered custom not-found page (e.g. a
  // `prerender = false` [slug] route SSRing its own 404), and an
  // `application/json` 404 is an intentional machine-readable API error.
  // Trade-off: a bare `text/plain` API 404 is indistinguishable from the
  // framework default and will yield to the styled page — API errors should
  // use `application/json` to be preserved.
  const contentType = (response.headers.get("content-type") || "").toLowerCase();
  if (contentType === "") return true;
  return contentType.includes("text/plain");
}

export default {
  async fetch(request, env, ctx) {
    if (isAssetProbeMethod(request.method) && canDelegateToAssets(env)) {
      // Trade-off: every GET/HEAD request that matches a prerendered route pays
      // the cost of one env.ASSETS.fetch() round-trip before the inner worker
      // sees it (when this probe actually runs — see the header comment on
      // run_worker_first). The upside is that the asset server handles
      // trailing-slash canonicalisation (e.g. /docs/foo → redirect → /docs/foo/)
      // and serves the build-time head-injected HTML with the hashed
      // <link>/<script> tags. If we skipped this probe, prerendered routes
      // would be dynamic-SSR'd by the inner Hono router without the prod head
      // injection, and islands would never hydrate. For purely dynamic apps
      // (prerender=false everywhere) the extra round-trip is pure overhead;
      // splitting the wrapper into two variants is the accepted future escape
      // hatch for that case.
      const assetResponse = await env.ASSETS.fetch(request);
      if (assetResponse.status !== 404) {
        return assetResponse;
      }
      // Asset 404. Fall through to the inner worker for genuinely dynamic
      // routes, but first hold the asset response UNREAD if it carries a
      // styled 404 page: if the inner also 404s with only the framework
      // default body (text/plain or none), we return this styled page
      // instead of the inner's plain 404 (issue #1322). An inner 404 that
      // renders its own page (text/html) or a structured API error
      // (application/json) wins. Returned verbatim — the one-shot body is
      // untouched.
      const styledAsset404 = assetHasStyled404Body(assetResponse) ? assetResponse : null;
      const store = { env, ctx, request };
      const innerResponse = await getStorage().run(store, () => inner.fetch(request));
      if (
        styledAsset404 !== null &&
        innerResponse.status === 404 &&
        innerIsFrameworkDefault404(innerResponse)
      ) {
        return styledAsset404;
      }
      return innerResponse;
    }
    const store = { env, ctx, request };
    return getStorage().run(store, () => inner.fetch(request));
  },
};

Three things are happening here, and the first two are independent of the third: a storage registry (getStorage + als.run), a dispatch policy (isAssetProbeMethod + canDelegateToAssets), and a 404 arbitration step (assetHasStyled404Body + innerIsFrameworkDefault404) that decides which 404 body a visitor actually sees. The next few sections take them in turn.

Reading bindings from a page

Inside any SSR page, you read the captured context through the adapter's accessor:

// pages/api/products.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";

export const prerender = false; // opt out of build-time SSG

interface Env {
  ANTHROPIC_API_KEY: string;
  DB: D1Database; // a `wrangler.toml` D1 binding named "DB"
}

export default async function Products() {
  const { env, ctx } = getCloudflareContext<Env>();
  ctx.waitUntil(reportToAnalytics());
  // A D1 binding is just-another-object on `env` — query it directly.
  const { results } = await env.DB.prepare("SELECT * FROM products").all();
  return new Response(JSON.stringify(results), {
    headers: { "content-type": "application/json" },
  });
}

getCloudflareContext() simply reads the active AsyncLocalStorage store that the wrapper opened with als.run. The <Env> generic is type-only — it narrows env to your bindings (here, a DB: D1Database), but the runtime value is the exact env object Cloudflare passed into the wrapper's fetch. The adapter never inspects env's members, so env.DB, env.ANTHROPIC_API_KEY, a KV namespace, an R2 bucket — all are threaded through verbatim. A D1 database is just another object on env.

env is narrowed by the generic; ctx is not

The Env generic on getCloudflareContext<Env>() only widens env's type — it has no effect on ctx. The package types ctx as its own minimal CloudflareExecutionContext interface, exposing exactly two methods: waitUntil and passThroughOnException. That is deliberate — the adapter does not depend on @cloudflare/workers-types at the type level (doing so would force every consumer of the package to install it), so it only surfaces the shape it actually threads through.

The runtime value is unaffected: ctx is still the exact ExecutionContext object Cloudflare passed into the wrapper's fetch, nothing stripped, only the compile-time type is narrower. If your own code needs the fuller @cloudflare/workers-types ExecutionContext shape — say a library you call expects it — widen it yourself with a cast at the call site:

const { ctx } = getCloudflareContext<Env>();
const typedCtx = ctx as ExecutionContext; // safe: same underlying object

There is no generic parameter for this because, unlike env, ctx's shape is not project-specific — it is the same two methods for every consumer, so a one-off cast at the call site is all a wider type needs.

Why AsyncLocalStorage, not a global

This is the single most important detail in the whole design, so it is worth being precise about the failure mode it avoids.

The tempting shortcut is to skip AsyncLocalStorage and just write the bindings onto a global:

// DO NOT DO THIS — it races across concurrent requests.
export default {
  async fetch(request, env, ctx) {
    globalThis.__env = env; // last writer wins
    return inner.fetch(request);
  },
};

Here is exactly why that is broken.

A Cloudflare Workers isolate is single-threaded, but it is not single-request. It interleaves many in-flight requests cooperatively: whenever a handler hits an await (any async I/O — a fetch, a D1 query, a KV read), it suspends and the event loop is free to run another request's handler in the same isolate, sharing the same globalThis.

Now trace two concurrent requests through the global-field version:

  1. Request A arrives. It writes globalThis.__env = envA, then awaits a slow D1 query.

  2. While A is suspended on that await, the event loop dispatches request B. B writes globalThis.__env = envB, overwriting the field.

  3. A's query resolves. A resumes past its await and reads globalThis.__env — and sees envB, B's bindings, not its own.

This is a last-writer-wins data race. It is not a parallel-CPU race that a mutex would fix — there is only one thread, and the writes never collide mid-instruction. The corruption happens precisely because execution yields at await boundaries: the global outlives the suspension, so the value A reads after resuming is whatever the most recent request wrote. Under load it surfaces as one tenant's request reading another tenant's secrets or database handle — intermittently, and almost never in local testing where requests rarely overlap.

AsyncLocalStorage fixes this at the right layer. als.run(store, cb) binds store to the async continuation chain rooted at cb. Every callback, every .then, every await-resumption that descends from that run reads the store that was active when it was scheduled — not the "current" value of a shared field. So when A resumes after its await, it is still inside A's run scope and reads envA; B's concurrent run scope is a completely separate store. Each request gets its own isolated view, and interleaving is harmless.

Why a globalThis key, then?

The store registry itself lives on globalThis under the stable key__zfb_cf_adapter_als__. That is not the same thing as storing env on a global. The wrapper (_worker.js) and your page bundle (_zfb_inner.mjs) are separate ESM module graphs, so a module-levelconst als = new AsyncLocalStorage() in one would be a differentinstance than the one the other imports. Pinning the singleAsyncLocalStorage instance to a known global key lets both ends share it. The per-request data still lives inside the store, scoped by run — never on the global.

The request-dispatch contract

The moment a _worker.js exists, you inherit responsibility for requests that would otherwise be served as static files. Exactly how much responsibility, and when, depends on the deploy target.

Cloudflare Pages advanced mode

Every request hits your worker. Cloudflare Pages' built-in static-asset routing is OFF unless your worker explicitly delegates to env.ASSETS.

That built-in routing is not nothing — it is the layer that does trailing-slash canonicalization (/docs/foo308/docs/foo/) and resolves a directory to its index.html for SSG output. When you take over the entry point, you also take over the responsibility for serving those static files. That is what env.ASSETS.fetch(request) is: a handle to the very asset server you just bypassed.

Workers Static Assets and run_worker_first

Workers Static Assets has a config knob Pages doesn't: run_worker_first in the [assets] block of wrangler.toml.

  • run_worker_first = false (the zfb default — most zfb-example-* reference apps this page's claims were verified against omit the key entirely, which resolves to the platform default of false; one repeats false explicitly with a comment explaining why, and one deliberately opts in to true instead): the platform's own asset router serves a matching GET/HEAD request before your Worker ever runs, with the same canonicalization Pages does (307 here, vs 308 on Pages). Your Worker only sees the request if the platform's router can't resolve it — a genuine miss.

  • run_worker_first = true: every request hits your Worker first, the same contract as Pages advanced mode above.

Either way, the in-Worker dispatch policy (isAssetProbeMethod + canDelegateToAssets) is still part of the wrapper — see "Platform-level probe bypass under run_worker_first = false" below for exactly when it runs and when the platform beats it to the punch.

Why ASSETS-first, not router-first

It is tempting to let the framework's router handle GET requests first and only reach for env.ASSETS as a fallback. Do not. For a prerendered (SSG) page, the inner router can re-render the page dynamically — but it will produce HTML without the build-time head injection.

zfb build post-processes each prerendered HTML file to inject the production <link rel="stylesheet"> and the <script type="module"> tags that load your island hydration bundles. That injection is a build step, not a runtime concern, so a dynamic SSR render of the same route emits the un-injected HTML. The page would render, but its islands would never hydrate — no stylesheet, no hydration script. Serving the prebuilt asset via env.ASSETS.fetch is what preserves the injected head.

Hence the dispatch policy in the wrapper:

  • GET / HEAD requests probe env.ASSETS.fetch(request) first. If the asset server returns anything other than 404, that response wins (the prebuilt, head-injected HTML, with correct canonicalization).

  • On a 404 from ASSETS, the wrapper doesn't fall through blindly — see "The 404 arbitration" below for exactly which of the two candidate 404 bodies a visitor ends up seeing.

  • Non-GET/HEAD requests (POST, PUT, PATCH, DELETE) skip the ASSETS probe entirely and go straight to the inner SSR worker. Assets are read-only, so probing would always 404/405; a mutating request like POST /api/ai-chat must reach the SSR handler directly.

Platform-level probe bypass under run_worker_first = false

On Workers Static Assets with the zfb default, most static-asset hits never reach the wrapper's isAssetProbeMethod + canDelegateToAssets check at all — the platform's own asset router already returned a response, and the Worker was never invoked for that request. This is not the in-Worker probe going dead: it is still exercised whenever

  • the deploy target is Cloudflare Pages advanced mode — Pages has no run_worker_first concept, so every request reaches the Worker and the in-Worker probe is the only dispatch layer;

  • run_worker_first = true is set on Workers Static Assets — every request reaches the Worker first, the same as Pages;

  • the platform's asset router itself doesn't resolve the request, even under run_worker_first = false — that "miss" still reaches the Worker, where env.ASSETS.fetch(request) is tried again and, on its own 404, falls through to the inner SSR worker exactly as described above.

In short: with the zfb default, the platform quietly handles the common case (a request that matches a built asset), and the wrapper's own probe logic exists for the cases the platform's router doesn't cover — plus the entirety of routing on Pages, where there is no platform-level shortcut at all.

The 404 arbitration: styled page vs. deliberate response

With not_found_handling = "404-page" (Workers Static Assets) or a dist/404.html at the build root (Pages' equivalent convention), an unmatched path serves your styled 404 page instead of Cloudflare's bare default. But a prerender = false route (like a pages/api/*.tsx handler) can also 404, deliberately, from inside your own SSR code — and the wrapper's ASSETS-first probe means the styled page and the inner worker's own 404 both become candidates for the same request. The wrapper picks between them using content-type based arbitration, and it recognizes exactly three shapes an inner 404 can take:

Inner 404's content-typeInterpreted asWinner
(empty — no header)Bare 404, no route handler ranStyled asset 404
text/plainHono's own default not-found bodyStyled asset 404
text/htmlA deliberate custom 404 page your SSR code renderedInner response
application/jsonA deliberate, machine-readable API errorInner response

The first two rows are collectively "the framework default" (innerIsFrameworkDefault404): the zfb router never overrides Hono's built-in not-found handler, so any genuine route miss inside the inner worker produces one of those two shapes on its own, without any of your code running. assetHasStyled404Body covers the other side of the decision — whether the ASSETS 404 actually carries a styled page (content-type: text/html) worth preferring in the first place. Under not_found_handling = "none", Cloudflare's bare fallback 404 has no headers at all, assetHasStyled404Body returns false, and the inner response always wins — the historical, pre-arbitration behavior is preserved for that configuration.

Both checks are header-only — neither function reads the response body. That matters because the Workers Static Assets binding streams every response and never sends a content-length header, so content-type is the only signal available before deciding, and whichever response wins still needs its one-shot stream returned intact. The response that loses the arbitration is never read either — its body is simply left unconsumed, and the runtime reclaims it like any other unreferenced object once the losing Response falls out of scope, with no explicit cleanup required.

The trap: a JSON API 404 without an explicit content-type gets silently overridden

An application/json inner 404 only wins if your handler actually sets that header. A prerender = false API route that 404s through a bareResponse(null, { status: 404 }), or that lets Hono's default not-found fall through unhandled, produces the exact same shape as the framework default (empty or text/plain content-type) — the arbitration logic cannot distinguish it from a genuine route miss. The styled dist/404.htmlsilently wins instead, and an API caller expecting JSON gets an HTML page back. Any route that wants its own 404 preserved — API or otherwise — must set an explicit content-type on every 404 it returns: application/jsonfor API errors, text/html for a custom rendered not-found page.

nodejs_compat is required

The wrapper imports AsyncLocalStorage from node:async_hooks. That Node.js built-in is only available in Workers when the nodejs_compat compatibility flag is enabled (with a sufficiently recent compatibility date). Without it, the worker fails to load with an unresolved node:async_hooks import. See Compatibility dates for how to set the flag and pick a compatibility date.

How it is tested

The adapter's acceptance test imports the produced _worker.js directly into vitest, builds a synthetic Request + env + ctx where env.DB is an in-memory D1Database-shaped stub, drives a POST (so the wrapper bypasses the ASSETS probe and goes straight to the inner worker), and asserts that a page reading env.DB.prepare(...).all() sees the rows the wrapper threaded in. Because the wrapper stores env verbatim and never inspects it, this proves the architectural claim — "an SSR route can reach env.DB" — without needing a live wrangler dev / miniflare run.

Revision History

CreatedUpdated