@takazudo/zfb-adapter-cloudflare 0.1.0-next.8 → 0.1.0-next.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  > Rust-built static-site engine for Astro and Next.js users — millisecond rebuilds, single binary.
4
4
 
5
- The Cloudflare Pages adapter for [zfb][zfb-site]. Wraps the
6
- `@takazudo/zfb-runtime` page router into a Cloudflare Pages advanced-mode
7
- `_worker.js` entry, threading `(env, ctx)` through to user code via
8
- `AsyncLocalStorage`.
5
+ The Cloudflare adapter for [zfb][zfb-site], targeting **Workers Static
6
+ Assets** (also deployable to Cloudflare Pages advanced mode). It wraps the
7
+ `@takazudo/zfb-runtime` page router into a Worker entry (`_worker.js`),
8
+ threading `(env, ctx)` through to user code via `AsyncLocalStorage`.
9
9
 
10
10
  This package is the Cloudflare half of the SSR adapter contract. Other
11
11
  targets (Node, Netlify, …) will land as sibling `@takazudo/zfb-adapter-*`
@@ -27,7 +27,7 @@ npm install --save-dev @takazudo/zfb-adapter-cloudflare
27
27
 
28
28
  In `zfb.config.json`:
29
29
 
30
- ```jsonc
30
+ ```json
31
31
  {
32
32
  "framework": "preact",
33
33
  "adapter": "@takazudo/zfb-adapter-cloudflare"
@@ -62,36 +62,114 @@ export default async function Products() {
62
62
  See the [SSR and Cloudflare Bindings guide][ssr-guide] for the full D1
63
63
  lifecycle (`wrangler d1 create`, migrations, preview-vs-prod).
64
64
 
65
- [ssr-guide]: https://takazudomodular.com/pj/zudo-front-builder/guides/ssr-and-cloudflare-bindings/
65
+ [ssr-guide]: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/ssr-and-cloudflare-bindings/
66
66
 
67
67
  `zfb build` will:
68
68
 
69
69
  1. Render every SSG page (`prerender !== false`) into static HTML under
70
70
  `dist/`.
71
- 2. Hand the SSR bundle to this adapter, which writes
72
- `dist/_worker.js` (the wrapper) and `dist/_zfb_inner.mjs` (the
73
- bundle) ready to be deployed via Cloudflare Pages advanced mode.
71
+ 2. Hand the SSR bundle to this adapter, which writes `dist/_worker.js`
72
+ (the wrapper), `dist/_zfb_inner.mjs` (the bundle), and
73
+ `dist/.assetsignore` (see below) — ready to deploy as a Worker with
74
+ Static Assets, or via Cloudflare Pages advanced mode.
75
+
76
+ ## `wrangler.toml`
77
+
78
+ ```toml
79
+ name = "my-site"
80
+ main = "./dist/_worker.js"
81
+ compatibility_date = "2024-09-23"
82
+ compatibility_flags = ["nodejs_compat"]
83
+
84
+ [assets]
85
+ directory = "./dist"
86
+ binding = "ASSETS" # optional — lets the Worker probe assets itself
87
+ not_found_handling = "404-page"
88
+ ```
89
+
90
+ - `compatibility_flags = ["nodejs_compat"]` is **required**. The wrapper
91
+ imports `node:async_hooks` to thread `(env, ctx)` into your route
92
+ handlers; without this flag the Worker fails at runtime with
93
+ `No such module "node:async_hooks"`.
94
+ - `not_found_handling = "404-page"` is recommended. With it, an
95
+ unmatched path makes the asset layer serve your styled `dist/404.html`
96
+ (built from `pages/404.tsx`) with a `404` status. The `_worker.js`
97
+ wrapper still probes the inner Worker for genuinely dynamic
98
+ `prerender = false` routes, but its **404 precedence** is:
99
+ - inner returns a non-404 (a real dynamic route) → the inner response
100
+ wins;
101
+ - inner also 404s with only the framework default body (Hono's default
102
+ `text/plain` "404 Not Found", or a bare 404 with no content-type) →
103
+ the **styled `404.html` wins**, so visitors see your designed 404 page
104
+ instead of plain text;
105
+ - inner 404s with any other content-type → that **deliberate response is
106
+ preserved** and the styled page does not stomp it: a `text/html` 404 is
107
+ a rendered custom not-found page (e.g. a `prerender = false` route
108
+ SSRing its own 404), and an `application/json` 404 is an intentional
109
+ API error.
110
+
111
+ A bare `text/plain` API 404 is indistinguishable from the framework
112
+ default and yields to the styled page — use `application/json` for API
113
+ errors you want preserved.
114
+
115
+ Under `not_found_handling = "none"` the asset 404 has no styled body, so
116
+ the inner Worker's plain 404 is always shown. Avoid
117
+ `single-page-application`: it returns `index.html` for every unresolved
118
+ path _before_ the Worker ever sees the request, which breaks dynamic
119
+ routes like `pages/api/*.tsx`.
120
+
121
+ - `[assets] binding = "ASSETS"` is optional but recommended — it lets
122
+ the `_worker.js` wrapper probe `env.ASSETS.fetch()` directly for
123
+ GET/HEAD requests, matching the SSG-vs-SSR precedence described
124
+ below.
125
+
126
+ ## `wrangler dev` / `wrangler deploy`
127
+
128
+ ```sh
129
+ zfb build
130
+ wrangler dev # local Worker + assets simulation
131
+ wrangler deploy # ship it
132
+ ```
74
133
 
75
- ## CLI
134
+ ## `.assetsignore`
76
135
 
77
- The package ships a `zfb-adapter-cloudflare` bin invoked by
78
- `zfb-build`:
136
+ The adapter emits `dist/.assetsignore` alongside `_worker.js` and
137
+ `_zfb_inner.mjs`:
79
138
 
80
139
  ```
81
- zfb-adapter-cloudflare bundle <input.mjs> --outdir dist/
140
+ _worker.js
141
+ _zfb_inner.mjs
82
142
  ```
83
143
 
84
- `<input.mjs>` is the ESM bundle `zfb-build`'s bundler emits. The
85
- output is a single Workers-shaped entry plus a sidecar copy of the
86
- inner bundle.
144
+ This excludes the wrapper and the inner SSR bundle from the asset
145
+ upload, so they are reachable only through the Worker's own module
146
+ graph — never served as public static files. Without it, a request for
147
+ `/_worker.js` or `/_zfb_inner.mjs` would serve your server code as a
148
+ plain-text download.
149
+
150
+ **Precedence:** zfb copies your project's `public/` directory into
151
+ `dist/` _after_ running this adapter. If your `public/` directory
152
+ contains its own `.assetsignore`, it overrides the one this adapter
153
+ emits — the adapter's excludes are silently dropped. Only add a
154
+ `public/.assetsignore` if you have additional paths to exclude and
155
+ include the two lines above yourself.
156
+
157
+ ## Cloudflare Pages compatibility
158
+
159
+ The same `dist/` output is still deployable to Cloudflare Pages
160
+ advanced mode (a `_worker.js` at the root of the Pages output directory
161
+ is Pages' equivalent convention). The `_worker.js` wrapper dispatches
162
+ requests identically on both platforms; the only platform-visible
163
+ difference is that trailing-slash asset redirects come back as `307`
164
+ on Workers Static Assets versus `308` on Pages.
87
165
 
88
- ## Why two files
166
+ ## Why two bundle files instead of one
89
167
 
90
168
  The wrapper imports the inner bundle by relative path
91
169
  (`./_zfb_inner.mjs`) instead of inlining it, so the adapter package
92
170
  itself does not need to ship an esbuild binary. Workerd's Module
93
- loader resolves relative ESM imports inside an advanced-mode
94
- `_worker.js` directory layout.
171
+ loader resolves relative ESM imports inside the `_worker.js` directory
172
+ layout.
95
173
 
96
174
  ## Why AsyncLocalStorage on a globalThis registry
97
175
 
package/bin/cli.mjs CHANGED
@@ -6,8 +6,10 @@
6
6
  //
7
7
  // bundle <input> --outdir <dir>
8
8
  //
9
- // Wrap the input ESM bundle into a Cloudflare Pages `_worker.js`
10
- // placed under <dir>. The input bundle is the file `zfb_build`'s
9
+ // Wrap the input ESM bundle into a Cloudflare Workers Static Assets
10
+ // (Pages-compatible) `_worker.js` placed under <dir>, alongside a
11
+ // `.assetsignore` that excludes the wrapper and inner bundle from
12
+ // the asset upload. The input bundle is the file `zfb_build`'s
11
13
  // bundler emits; <dir> is typically the project's `dist/`.
12
14
  //
13
15
  // The CLI is intentionally tiny and dependency-free. It imports the
@@ -15,9 +17,8 @@
15
17
  // no TypeScript loader required) so there is a single source of truth.
16
18
  // invariant: no runtime npm deps — see SECURITY-DEPS.md
17
19
 
18
- import { copyFile, mkdir, writeFile } from "node:fs/promises";
19
20
  import { realpathSync } from "node:fs";
20
- import { join, resolve } from "node:path";
21
+ import { resolve } from "node:path";
21
22
  import { fileURLToPath } from "node:url";
22
23
 
23
24
  // ---------------------------------------------------------------------------
@@ -27,18 +28,14 @@ import { fileURLToPath } from "node:url";
27
28
  import { WORKER_WRAPPER_SOURCE } from "../src/worker-wrapper.mjs";
28
29
  export { WORKER_WRAPPER_SOURCE };
29
30
 
30
- export async function emitWorker({ inputBundlePath, outdir }) {
31
- const outdirAbs = resolve(outdir);
32
- const inputAbs = resolve(inputBundlePath);
33
-
34
- await mkdir(outdirAbs, { recursive: true });
35
- const innerBundlePath = join(outdirAbs, "_zfb_inner.mjs");
36
- await copyFile(inputAbs, innerBundlePath);
31
+ // ---------------------------------------------------------------------------
32
+ // emitWorker — shared implementation, no runtime npm deps.
33
+ // ---------------------------------------------------------------------------
37
34
 
38
- const workerPath = join(outdirAbs, "_worker.js");
39
- await writeFile(workerPath, WORKER_WRAPPER_SOURCE, "utf8");
35
+ import { emitWorker as _emitWorker } from "../src/emit-worker.mjs";
40
36
 
41
- return { workerPath, innerBundlePath };
37
+ export async function emitWorker({ inputBundlePath, outdir }) {
38
+ return _emitWorker({ inputBundlePath, outdir, workerWrapperSource: WORKER_WRAPPER_SOURCE });
42
39
  }
43
40
 
44
41
  // ---------------------------------------------------------------------------
@@ -55,7 +52,8 @@ function printUsage() {
55
52
  zfb-adapter-cloudflare bundle <input> --outdir <dir>
56
53
 
57
54
  Wrap an ESM bundle (the output of zfb-build's bundler) into a
58
- Cloudflare Pages \`_worker.js\` placed under <dir>.
55
+ Cloudflare Workers Static Assets \`_worker.js\` placed under <dir>
56
+ (also deployable to Cloudflare Pages advanced mode).
59
57
 
60
58
  Options:
61
59
  --outdir <dir> Output directory. Required.
@@ -143,7 +141,9 @@ async function main() {
143
141
  inputBundlePath: inputAbs,
144
142
  outdir: outdirAbs,
145
143
  });
146
- process.stdout.write(`wrote ${out.workerPath}\nwrote ${out.innerBundlePath}\n`);
144
+ process.stdout.write(
145
+ `wrote ${out.workerPath}\nwrote ${out.innerBundlePath}\nwrote ${out.assetsIgnorePath}\n`,
146
+ );
147
147
  }
148
148
 
149
149
  main().catch((err) => {
package/dist/build.d.ts CHANGED
@@ -18,8 +18,8 @@ export interface EmitWorkerInput {
18
18
  readonly inputBundlePath: string;
19
19
  /**
20
20
  * Absolute path to the output directory. The emitter creates it if
21
- * missing and writes `_worker.js` plus `_zfb_inner.mjs` (the copied
22
- * input bundle) into it.
21
+ * missing and writes `_worker.js`, `_zfb_inner.mjs` (the copied input
22
+ * bundle), and `.assetsignore` into it.
23
23
  */
24
24
  readonly outdir: string;
25
25
  }
@@ -30,24 +30,29 @@ export interface EmitWorkerInput {
30
30
  export interface EmitWorkerOutput {
31
31
  readonly workerPath: string;
32
32
  readonly innerBundlePath: string;
33
+ readonly assetsIgnorePath: string;
33
34
  }
34
35
  /**
35
- * Emit a Cloudflare Pages `_worker.js` that wraps the zfb input bundle.
36
+ * Emit a Cloudflare Workers Static Assets (Pages-compatible) `_worker.js`
37
+ * that wraps the zfb input bundle.
36
38
  *
37
- * Output shape (two files in `outdir`):
39
+ * Output shape (three files in `outdir`):
38
40
  *
39
- * _worker.js — entry imported by Cloudflare Pages advanced mode
41
+ * _worker.js — Worker entry point (`main` in wrangler.toml, or the
42
+ * Pages advanced-mode convention)
40
43
  * _zfb_inner.mjs — the input bundle, copied verbatim
44
+ * .assetsignore — excludes the two files above from the asset upload
45
+ * so they are only reachable through the Worker's
46
+ * module graph, never served as a public static file
41
47
  *
42
48
  * The wrapper imports the inner bundle via the relative path
43
49
  * `./_zfb_inner.mjs`. Workerd's Module loader resolves relative ESM
44
- * imports inside an advanced-mode `_worker.js` directory, so this layout
45
- * works without re-bundling.
50
+ * imports inside the `_worker.js` directory, so this layout works
51
+ * without re-bundling.
46
52
  *
47
- * Why two files instead of one: re-bundling here would require a second
48
- * esbuild pass and would force the adapter to ship its own esbuild
49
- * binary slot. The two-file layout keeps the adapter dependency-free at
50
- * runtime — it is just `node:fs` glue.
53
+ * Why two bundle files instead of one: re-bundling here would require a
54
+ * second esbuild pass and would force the adapter to ship its own
55
+ * esbuild binary slot. The two-file layout keeps the adapter
56
+ * dependency-free at runtime — it is just `node:fs` glue.
51
57
  */
52
58
  export declare function emitWorker(input: EmitWorkerInput): Promise<EmitWorkerOutput>;
53
- //# sourceMappingURL=build.d.ts.map
package/dist/build.js CHANGED
@@ -10,6 +10,9 @@
10
10
  // @ts-expect-error worker-wrapper.mjs has no declaration file; the export
11
11
  // shape is narrowed explicitly below.
12
12
  import { WORKER_WRAPPER_SOURCE as _wrapper } from "./worker-wrapper.mjs";
13
+ // @ts-expect-error emit-worker.mjs has no declaration file; the export
14
+ // shape is narrowed via EmitWorkerInput/EmitWorkerOutput below.
15
+ import { emitWorker as _emitWorker } from "./emit-worker.mjs";
13
16
  /**
14
17
  * The wrapper source written to `_worker.js`. Imported from the single
15
18
  * canonical `.mjs` file so `src/build.ts` and `bin/cli.mjs` always stay
@@ -17,33 +20,33 @@ import { WORKER_WRAPPER_SOURCE as _wrapper } from "./worker-wrapper.mjs";
17
20
  */
18
21
  export const WORKER_WRAPPER_SOURCE = _wrapper;
19
22
  /**
20
- * Emit a Cloudflare Pages `_worker.js` that wraps the zfb input bundle.
23
+ * Emit a Cloudflare Workers Static Assets (Pages-compatible) `_worker.js`
24
+ * that wraps the zfb input bundle.
21
25
  *
22
- * Output shape (two files in `outdir`):
26
+ * Output shape (three files in `outdir`):
23
27
  *
24
- * _worker.js — entry imported by Cloudflare Pages advanced mode
28
+ * _worker.js — Worker entry point (`main` in wrangler.toml, or the
29
+ * Pages advanced-mode convention)
25
30
  * _zfb_inner.mjs — the input bundle, copied verbatim
31
+ * .assetsignore — excludes the two files above from the asset upload
32
+ * so they are only reachable through the Worker's
33
+ * module graph, never served as a public static file
26
34
  *
27
35
  * The wrapper imports the inner bundle via the relative path
28
36
  * `./_zfb_inner.mjs`. Workerd's Module loader resolves relative ESM
29
- * imports inside an advanced-mode `_worker.js` directory, so this layout
30
- * works without re-bundling.
37
+ * imports inside the `_worker.js` directory, so this layout works
38
+ * without re-bundling.
31
39
  *
32
- * Why two files instead of one: re-bundling here would require a second
33
- * esbuild pass and would force the adapter to ship its own esbuild
34
- * binary slot. The two-file layout keeps the adapter dependency-free at
35
- * runtime — it is just `node:fs` glue.
40
+ * Why two bundle files instead of one: re-bundling here would require a
41
+ * second esbuild pass and would force the adapter to ship its own
42
+ * esbuild binary slot. The two-file layout keeps the adapter
43
+ * dependency-free at runtime — it is just `node:fs` glue.
36
44
  */
37
45
  export async function emitWorker(input) {
38
- const { mkdir, copyFile, writeFile } = await import("node:fs/promises");
39
- const { resolve, join } = await import("node:path");
40
- const outdir = resolve(input.outdir);
41
- const inputBundle = resolve(input.inputBundlePath);
42
- await mkdir(outdir, { recursive: true });
43
- const innerBundlePath = join(outdir, "_zfb_inner.mjs");
44
- await copyFile(inputBundle, innerBundlePath);
45
- const workerPath = join(outdir, "_worker.js");
46
- await writeFile(workerPath, WORKER_WRAPPER_SOURCE, "utf8");
47
- return { workerPath, innerBundlePath };
46
+ return _emitWorker({
47
+ inputBundlePath: input.inputBundlePath,
48
+ outdir: input.outdir,
49
+ workerWrapperSource: WORKER_WRAPPER_SOURCE,
50
+ });
48
51
  }
49
52
  //# sourceMappingURL=build.js.map
package/dist/build.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"build.js","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,0EAA0E;AAC1E,kEAAkE;AAClE,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,yEAAyE;AACzE,mEAAmE;AAEnE,0EAA0E;AAC1E,sCAAsC;AACtC,OAAO,EAAE,qBAAqB,IAAI,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAEzE;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAW,QAAkB,CAAC;AA+BhE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,KAAsB;IACrD,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAC;IACxE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,CAAC;IAEpD,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACrC,MAAM,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IAEnD,MAAM,KAAK,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACzC,MAAM,eAAe,GAAG,IAAI,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IACvD,MAAM,QAAQ,CAAC,WAAW,EAAE,eAAe,CAAC,CAAC;IAE7C,MAAM,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC9C,MAAM,SAAS,CAAC,UAAU,EAAE,qBAAqB,EAAE,MAAM,CAAC,CAAC;IAE3D,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,CAAC;AACzC,CAAC"}
1
+ {"version":3,"file":"build.js","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,0EAA0E;AAC1E,kEAAkE;AAClE,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,yEAAyE;AACzE,mEAAmE;AAEnE,0EAA0E;AAC1E,sCAAsC;AACtC,OAAO,EAAE,qBAAqB,IAAI,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACzE,uEAAuE;AACvE,gEAAgE;AAChE,OAAO,EAAE,UAAU,IAAI,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAE9D;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAW,QAAkB,CAAC;AAgChE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,KAAsB;IACrD,OAAO,WAAW,CAAC;QACjB,eAAe,EAAE,KAAK,CAAC,eAAe;QACtC,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,mBAAmB,EAAE,qBAAqB;KAC3C,CAA8B,CAAC;AAClC,CAAC","sourcesContent":["// `@takazudo/zfb-adapter-cloudflare/build` — Node-only build helpers.\n//\n// This sub-entry is intentionally **not** imported by the Workers-runtime\n// entry (`./`). Code in this module may freely use Node built-ins\n// (`node:fs`, `node:path`, …) because it only ever runs in a Node 22+\n// build environment, never inside a Cloudflare Worker isolate.\n//\n// The `./` entry (`src/index.ts`) exports only Workers-runtime-safe code\n// (AsyncLocalStorage helpers) that can be bundled into the worker.\n\n// @ts-expect-error worker-wrapper.mjs has no declaration file; the export\n// shape is narrowed explicitly below.\nimport { WORKER_WRAPPER_SOURCE as _wrapper } from \"./worker-wrapper.mjs\";\n// @ts-expect-error emit-worker.mjs has no declaration file; the export\n// shape is narrowed via EmitWorkerInput/EmitWorkerOutput below.\nimport { emitWorker as _emitWorker } from \"./emit-worker.mjs\";\n\n/**\n * The wrapper source written to `_worker.js`. Imported from the single\n * canonical `.mjs` file so `src/build.ts` and `bin/cli.mjs` always stay\n * in sync without any duplication.\n */\nexport const WORKER_WRAPPER_SOURCE: string = _wrapper as string;\n\n/**\n * Inputs to [`emitWorker`].\n */\nexport interface EmitWorkerInput {\n /**\n * Absolute path to the input ESM bundle produced by `zfb-build`. The\n * bundle must export a Workers-shaped `default { fetch: (request) =>\n * Promise<Response> }` (this is the contract `zfb_build::bundler`\n * pins). The file is copied verbatim next to the emitted wrapper so\n * relative imports inside it keep resolving.\n */\n readonly inputBundlePath: string;\n /**\n * Absolute path to the output directory. The emitter creates it if\n * missing and writes `_worker.js`, `_zfb_inner.mjs` (the copied input\n * bundle), and `.assetsignore` into it.\n */\n readonly outdir: string;\n}\n\n/**\n * Output paths the emitter produced. Returned for callers that want to\n * log them (the Rust orchestrator surfaces them in build output).\n */\nexport interface EmitWorkerOutput {\n readonly workerPath: string;\n readonly innerBundlePath: string;\n readonly assetsIgnorePath: string;\n}\n\n/**\n * Emit a Cloudflare Workers Static Assets (Pages-compatible) `_worker.js`\n * that wraps the zfb input bundle.\n *\n * Output shape (three files in `outdir`):\n *\n * _worker.js — Worker entry point (`main` in wrangler.toml, or the\n * Pages advanced-mode convention)\n * _zfb_inner.mjs — the input bundle, copied verbatim\n * .assetsignore — excludes the two files above from the asset upload\n * so they are only reachable through the Worker's\n * module graph, never served as a public static file\n *\n * The wrapper imports the inner bundle via the relative path\n * `./_zfb_inner.mjs`. Workerd's Module loader resolves relative ESM\n * imports inside the `_worker.js` directory, so this layout works\n * without re-bundling.\n *\n * Why two bundle files instead of one: re-bundling here would require a\n * second esbuild pass and would force the adapter to ship its own\n * esbuild binary slot. The two-file layout keeps the adapter\n * dependency-free at runtime — it is just `node:fs` glue.\n */\nexport async function emitWorker(input: EmitWorkerInput): Promise<EmitWorkerOutput> {\n return _emitWorker({\n inputBundlePath: input.inputBundlePath,\n outdir: input.outdir,\n workerWrapperSource: WORKER_WRAPPER_SOURCE,\n }) as Promise<EmitWorkerOutput>;\n}\n"]}
@@ -0,0 +1,49 @@
1
+ // Shared, dependency-free implementation of `emitWorker`.
2
+ //
3
+ // Kept as a plain `.mjs` module (no TypeScript) so it can be imported from
4
+ // both the typed TypeScript surface (`src/build.ts`) and the dependency-free
5
+ // CLI binary (`bin/cli.mjs`) without needing a TypeScript loader.
6
+ //
7
+ // Do NOT duplicate this logic elsewhere. The two consumers always stay in
8
+ // sync by importing from here.
9
+ //
10
+ // invariant: no runtime npm deps — see SECURITY-DEPS.md
11
+
12
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
13
+ import { join, resolve } from "node:path";
14
+
15
+ // `.assetsignore` tells the Workers Static Assets uploader (and Pages'
16
+ // asset server) to skip these two files, so they are only ever reachable
17
+ // through the Worker's module graph, not served as public static assets.
18
+ const ASSETS_IGNORE_CONTENT = "_worker.js\n_zfb_inner.mjs\n";
19
+
20
+ /**
21
+ * Emit a Cloudflare Workers Static Assets (Pages-compatible) `_worker.js`
22
+ * that wraps the zfb input bundle.
23
+ *
24
+ * Output shape (three files in `outdir`):
25
+ *
26
+ * _worker.js — Worker entry point (`main` in wrangler.toml, or the
27
+ * Pages advanced-mode convention)
28
+ * _zfb_inner.mjs — the input bundle, copied verbatim
29
+ * .assetsignore — excludes the two files above from the asset upload
30
+ *
31
+ * @param {{ inputBundlePath: string; outdir: string; workerWrapperSource: string }} input
32
+ * @returns {Promise<{ workerPath: string; innerBundlePath: string; assetsIgnorePath: string }>}
33
+ */
34
+ export async function emitWorker({ inputBundlePath, outdir, workerWrapperSource }) {
35
+ const outdirAbs = resolve(outdir);
36
+ const inputAbs = resolve(inputBundlePath);
37
+
38
+ await mkdir(outdirAbs, { recursive: true });
39
+ const innerBundlePath = join(outdirAbs, "_zfb_inner.mjs");
40
+ await copyFile(inputAbs, innerBundlePath);
41
+
42
+ const workerPath = join(outdirAbs, "_worker.js");
43
+ await writeFile(workerPath, workerWrapperSource, "utf8");
44
+
45
+ const assetsIgnorePath = join(outdirAbs, ".assetsignore");
46
+ await writeFile(assetsIgnorePath, ASSETS_IGNORE_CONTENT, "utf8");
47
+
48
+ return { workerPath, innerBundlePath, assetsIgnorePath };
49
+ }
package/dist/index.d.ts CHANGED
@@ -39,4 +39,3 @@ export declare function runWithCloudflareContext<T>(context: CloudflareContext,
39
39
  * recommended so TypeScript catches typos like `env.ANTRHOPIC_KEY`.
40
40
  */
41
41
  export declare function getCloudflareContext<Env = unknown>(): CloudflareContext<Env>;
42
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- // `@takazudo/zfb-adapter-cloudflare` — Cloudflare Pages adapter for the
2
- // zfb framework.
1
+ // `@takazudo/zfb-adapter-cloudflare` — Cloudflare Workers Static Assets
2
+ // adapter for the zfb framework (also deployable to Cloudflare Pages
3
+ // advanced mode).
3
4
  //
4
5
  // This entry (`./`) is the **Workers-runtime** surface. It is safe to
5
6
  // bundle into a Cloudflare Worker and does not depend on any Node-only
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,iBAAiB;AACjB,EAAE;AACF,sEAAsE;AACtE,uEAAuE;AACvE,iEAAiE;AACjE,EAAE;AACF,wCAAwC;AACxC,EAAE;AACF,6EAA6E;AAC7E,EAAE;AACF,oCAAoC;AACpC,+CAA+C;AAC/C,kFAAkF;AAClF,yDAAyD;AACzD,MAAM;AACN,EAAE;AACF,wEAAwE;AACxE,qBAAqB;AACrB,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,oDAAoD;AACpD,EAAE;AACF,uEAAuE;AACvE,yEAAyE;AACzE,yEAAyE;AACzE,4DAA4D;AAC5D,EAAE;AACF,sEAAsE;AACtE,yEAAyE;AACzE,mEAAmE;AACnE,yEAAyE;AACzE,qEAAqE;AACrE,uEAAuE;AACvE,uEAAuE;AACvE,6CAA6C;AAE7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AA8BrD,uDAAuD;AACvD,MAAM,WAAW,GAAG,wBAAwB,CAAC;AAM7C;;;;;;GAMG;AACH,SAAS,UAAU;IACjB,MAAM,CAAC,GAAG,UAAuC,CAAC;IAClD,IAAI,GAAG,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC;IACzB,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,GAAG,GAAG,IAAI,iBAAiB,EAAqB,CAAC;QACjD,CAAC,CAAC,WAAW,CAAC,GAAG,GAAG,CAAC;IACvB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAI,OAA0B,EAAE,EAAW;IACjF,OAAO,UAAU,EAAE,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;AACvC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,CAAC,GAAG,UAAU,EAAE,CAAC,QAAQ,EAAE,CAAC;IAClC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,MAAM,IAAI,KAAK,CACb,6FAA6F;YAC3F,yFAAyF;YACzF,gGAAgG,CACnG,CAAC;IACJ,CAAC;IACD,OAAO,CAA2B,CAAC;AACrC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,qEAAqE;AACrE,kBAAkB;AAClB,EAAE;AACF,sEAAsE;AACtE,uEAAuE;AACvE,iEAAiE;AACjE,EAAE;AACF,wCAAwC;AACxC,EAAE;AACF,6EAA6E;AAC7E,EAAE;AACF,oCAAoC;AACpC,+CAA+C;AAC/C,kFAAkF;AAClF,yDAAyD;AACzD,MAAM;AACN,EAAE;AACF,wEAAwE;AACxE,qBAAqB;AACrB,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,oDAAoD;AACpD,EAAE;AACF,uEAAuE;AACvE,yEAAyE;AACzE,yEAAyE;AACzE,4DAA4D;AAC5D,EAAE;AACF,sEAAsE;AACtE,yEAAyE;AACzE,mEAAmE;AACnE,yEAAyE;AACzE,qEAAqE;AACrE,uEAAuE;AACvE,uEAAuE;AACvE,6CAA6C;AAE7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AA8BrD,uDAAuD;AACvD,MAAM,WAAW,GAAG,wBAAwB,CAAC;AAM7C;;;;;;GAMG;AACH,SAAS,UAAU;IACjB,MAAM,CAAC,GAAG,UAAuC,CAAC;IAClD,IAAI,GAAG,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC;IACzB,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,GAAG,GAAG,IAAI,iBAAiB,EAAqB,CAAC;QACjD,CAAC,CAAC,WAAW,CAAC,GAAG,GAAG,CAAC;IACvB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAI,OAA0B,EAAE,EAAW;IACjF,OAAO,UAAU,EAAE,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;AACvC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,CAAC,GAAG,UAAU,EAAE,CAAC,QAAQ,EAAE,CAAC;IAClC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,MAAM,IAAI,KAAK,CACb,6FAA6F;YAC3F,yFAAyF;YACzF,gGAAgG,CACnG,CAAC;IACJ,CAAC;IACD,OAAO,CAA2B,CAAC;AACrC,CAAC","sourcesContent":["// `@takazudo/zfb-adapter-cloudflare` — Cloudflare Workers Static Assets\n// adapter for the zfb framework (also deployable to Cloudflare Pages\n// advanced mode).\n//\n// This entry (`./`) is the **Workers-runtime** surface. It is safe to\n// bundle into a Cloudflare Worker and does not depend on any Node-only\n// built-ins beyond `node:async_hooks` (which workerd polyfills).\n//\n// Usage in a `prerender = false` route:\n//\n// import { getCloudflareContext } from \"@takazudo/zfb-adapter-cloudflare\";\n//\n// export const prerender = false;\n// export default async function ApiRoute() {\n// const { env, ctx } = getCloudflareContext<{ ANTHROPIC_API_KEY: string }>();\n// // env.ANTHROPIC_API_KEY, ctx.waitUntil(...), etc.\n// }\n//\n// For Node-only build helpers (e.g. `emitWorker`), import the `./build`\n// sub-entry instead:\n//\n// import { emitWorker } from \"@takazudo/zfb-adapter-cloudflare/build\";\n//\n// ## Why AsyncLocalStorage on a globalThis registry\n//\n// Cloudflare Workers can process multiple requests concurrently in the\n// same isolate. A naïve `globalThis.__env = env` write would race across\n// requests. AsyncLocalStorage gives us a per-request scope that survives\n// `await` points without interfering with sibling requests.\n//\n// We register the storage instance on `globalThis` under a stable key\n// (`__zfb_cf_adapter_als__`) so the wrapper at `_worker.js` and the user\n// pages bundled together can share the same instance even when the\n// adapter module ends up duplicated in the final bundle graph (e.g. when\n// the wrapper file is emitted side-by-side with the inner bundle and\n// each pulls in its own copy of this module). Module-instance identity\n// is the property AsyncLocalStorage relies on; the registry pattern is\n// what makes it survive bundler duplication.\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\n/**\n * Cloudflare execution context — minimal projection of the workerd\n * `ExecutionContext` interface. We do not depend on `@cloudflare/workers-types`\n * at the type level here because that would force every consumer of this\n * package to install it; instead we keep a minimal structural shape and\n * let users widen it via the generic on [`getCloudflareContext`] when\n * they need richer bindings.\n */\nexport interface CloudflareExecutionContext {\n /** Extends the lifetime of the request beyond the response. */\n waitUntil(promise: Promise<unknown>): void;\n /** Falls through to the static origin on uncaught exceptions. */\n passThroughOnException(): void;\n}\n\n/**\n * Per-request Cloudflare context. `Env` defaults to `unknown` so the\n * caller can narrow it at the call site (recommended) or leave it open.\n */\nexport interface CloudflareContext<Env = unknown> {\n /** CF env bindings (secrets, KV, D1, …) wired up via wrangler.toml. */\n readonly env: Env;\n /** ExecutionContext for waitUntil / passThroughOnException. */\n readonly ctx: CloudflareExecutionContext;\n /** The original Request, useful when handlers want headers / URL. */\n readonly request: Request;\n}\n\n/** Stable globalThis key the registry pattern uses. */\nconst STORAGE_KEY = \"__zfb_cf_adapter_als__\";\n\ninterface RegistryGlobal {\n [STORAGE_KEY]?: AsyncLocalStorage<CloudflareContext>;\n}\n\n/**\n * Acquire (or lazily create) the singleton AsyncLocalStorage instance.\n *\n * Stored on `globalThis` under a stable key so the wrapper at\n * `_worker.js` and the user bundle share state even if the adapter\n * module ends up duplicated in the final module graph.\n */\nfunction getStorage(): AsyncLocalStorage<CloudflareContext> {\n const g = globalThis as unknown as RegistryGlobal;\n let als = g[STORAGE_KEY];\n if (!als) {\n als = new AsyncLocalStorage<CloudflareContext>();\n g[STORAGE_KEY] = als;\n }\n return als;\n}\n\n/**\n * Establish a Cloudflare context for the duration of `fn`. Used by the\n * `_worker.js` wrapper; not normally called by user code.\n */\nexport function runWithCloudflareContext<T>(context: CloudflareContext, fn: () => T): T {\n return getStorage().run(context, fn);\n}\n\n/**\n * Read the current Cloudflare context. Throws if called outside a\n * Cloudflare request scope (e.g. from a build-time SSG render). Catch\n * the error and gate on `prerender = false` if you need a route to work\n * in both modes.\n *\n * The `Env` generic narrows the bindings shape — passing it is\n * recommended so TypeScript catches typos like `env.ANTRHOPIC_KEY`.\n */\nexport function getCloudflareContext<Env = unknown>(): CloudflareContext<Env> {\n const c = getStorage().getStore();\n if (!c) {\n throw new Error(\n \"[zfb-adapter-cloudflare] getCloudflareContext() called outside a Cloudflare request scope. \" +\n \"This usually means the route was rendered at build time (SSG) instead of dispatched by \" +\n \"the Worker. Add `export const prerender = false;` to the page if it needs Cloudflare bindings.\",\n );\n }\n return c as CloudflareContext<Env>;\n}\n"]}
@@ -10,34 +10,59 @@
10
10
  /** @type {string} */
11
11
  export const WORKER_WRAPPER_SOURCE = `// AUTO-GENERATED by @takazudo/zfb-adapter-cloudflare. Do not edit.
12
12
  //
13
- // Cloudflare Pages advanced mode entry. Forwards (request, env, ctx) to
14
- // the inner zfb worker bundle, exposing env/ctx to user code via
15
- // AsyncLocalStorage under a stable globalThis key.
13
+ // Cloudflare Workers Static Assets entry (\`main\` in wrangler.toml,
14
+ // alongside an \`[assets]\` block; also deployable to Cloudflare Pages
15
+ // advanced mode). Forwards (request, env, ctx) to the inner zfb worker
16
+ // bundle, exposing env/ctx to user code via AsyncLocalStorage under a
17
+ // stable globalThis key.
16
18
  //
17
19
  // The same key is read by @takazudo/zfb-adapter-cloudflare's
18
20
  // getCloudflareContext() inside the user bundle, so the two ends share
19
21
  // state even though they live in separate ESM module instances.
20
22
  //
21
- // CF Pages advanced-mode contract: when _worker.js is present, every
22
- // request hits the worker — static asset routing is OFF unless the
23
- // worker explicitly delegates to env.ASSETS. The dispatch order below
24
- // is deliberately "ASSETS first, inner on 404":
23
+ // Dispatch order is deliberately "ASSETS first, inner on 404" — this
24
+ // holds across both deploy targets, but *why* the ASSETS probe fires
25
+ // differs:
25
26
  //
26
- // - GET/HEAD requests probe env.ASSETS first. CF Pages' asset server
27
+ // - Workers Static Assets with \`run_worker_first = false\` (the zfb
28
+ // default): the platform itself serves asset hits before the
29
+ // Worker ever runs, so for those requests this in-Worker probe is
30
+ // normally bypassed — it never had a chance to run. The probe below
31
+ // is NOT dead code: it is still exercised for Pages (no
32
+ // \`run_worker_first\` concept; every request hits the Worker), for
33
+ // \`run_worker_first = true\` deployments, and for any request the
34
+ // platform's asset router itself does not resolve (which still
35
+ // reaches this Worker as a "miss").
36
+ // - GET/HEAD requests probe env.ASSETS first. The asset server
27
37
  // handles the trailing-slash canonicalisation for SSG output (e.g.
28
- // "/docs/foo" → 308 → "/docs/foo/" → dist/docs/foo/index.html), so
38
+ // "/docs/foo" → redirect → "/docs/foo/" → dist/docs/foo/index.html
39
+ // — 307 on Workers Static Assets, 308 on Cloudflare Pages), so
29
40
  // prerendered routes get the build-time head-injected HTML
30
41
  // (<link rel="stylesheet">, <script type="module" src="/assets/
31
42
  // islands-…">). If we let the inner Hono router handle them first,
32
43
  // it would dynamic-SSR the page WITHOUT the prod head injection
33
44
  // (which is a build-time post-process, not a runtime concern), and
34
- // islands would never hydrate. See zfb#XXX (filed by Wave 10 of
35
- // the zudo-doc#1355 zfb-pipeline-gaps epic).
36
- // - On 404 from ASSETS, fall through to the inner zfb worker. This is
37
- // where genuinely dynamic routes (\`prerender = false\`, e.g.
38
- // \`pages/api/*.tsx\`) are served.
45
+ // islands would never hydrate.
46
+ // - On 404 from ASSETS, fall through to the inner zfb worker — this
47
+ // is where genuinely dynamic routes (\`prerender = false\`, e.g.
48
+ // \`pages/api/*.tsx\`) are served. EXCEPTION: when the asset 404
49
+ // carries a *styled* 404-page body (\`not_found_handling = "404-page"\`
50
+ // on Workers Static Assets, or the \`404.html\` convention on Pages —
51
+ // detected as an HTML document with a real Content-Length) AND the
52
+ // inner ALSO 404s with only the framework default body (Hono's
53
+ // default not-found, \`text/plain\` "404 Not Found", or a bare 404
54
+ // with no content-type), the earlier styled asset 404 is preferred
55
+ // over the inner's plain 404 — otherwise the styled \`404.html\` would
56
+ // be discarded and users would see the inner plain-text 404 (issue
57
+ // #1322). Any inner 404 that declares another content-type is a
58
+ // deliberate response and WINS: a \`text/html\` 404 is a rendered
59
+ // custom not-found page (a \`prerender = false\` route SSRing its own
60
+ // 404), and an \`application/json\` 404 is an intentional API error —
61
+ // both are returned unchanged. Under \`not_found_handling = "none"\`
62
+ // the asset 404 body is empty/non-HTML, so the inner always wins —
63
+ // the historical behavior is preserved.
39
64
  // - Non-GET/HEAD requests skip ASSETS and go straight to the inner —
40
- // CF Pages assets are read-only by definition, and we want POSTs
65
+ // assets are read-only by definition, and we want POSTs
41
66
  // (\`/api/ai-chat\`, etc.) to reach the SSR handler without a probe
42
67
  // that would always 405 / 404.
43
68
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -65,14 +90,77 @@ function isAssetProbeMethod(method) {
65
90
  return method === "GET" || method === "HEAD";
66
91
  }
67
92
 
93
+ function assetHasStyled404Body(response) {
94
+ // True iff an ASSETS 404 carries the site's *styled* 404 page.
95
+ // Platform fact: \`not_found_handling = "404-page"\` (Workers Static
96
+ // Assets) and the Pages \`404.html\` convention both serve that page as
97
+ // an HTML document — auto-detected \`content-type: text/html\` and a real
98
+ // \`Content-Length\` (it is a static file). \`not_found_handling = "none"\`
99
+ // and a bare Pages advanced-mode 404 send Cloudflare's default 404 with
100
+ // an empty / non-HTML body, so this returns false and the inner wins.
101
+ // Header-only (never reads the body) so it holds for HEAD too and leaves
102
+ // the one-shot asset stream intact for a verbatim return.
103
+ const contentType = (response.headers.get("content-type") || "").toLowerCase();
104
+ const contentLength = Number(response.headers.get("content-length") || "0");
105
+ return contentType.includes("text/html") && contentLength > 0;
106
+ }
107
+
108
+ function innerIsFrameworkDefault404(response) {
109
+ // True iff an inner 404 is the framework's *generic* not-found, which
110
+ // yields to the styled asset 404 page. The inner zfb router never
111
+ // overrides Hono's default handler, so a route miss is always Hono's
112
+ // default not-found — \`text/plain\` "404 Not Found" (or a bare 404 with no
113
+ // content-type). Any inner 404 that declares another content-type is a
114
+ // *deliberate* response and must WIN over the static styled asset page: a
115
+ // \`text/html\` 404 is a rendered custom not-found page (e.g. a
116
+ // \`prerender = false\` [slug] route SSRing its own 404), and an
117
+ // \`application/json\` 404 is an intentional machine-readable API error.
118
+ // Trade-off: a bare \`text/plain\` API 404 is indistinguishable from the
119
+ // framework default and will yield to the styled page — API errors should
120
+ // use \`application/json\` to be preserved.
121
+ const contentType = (response.headers.get("content-type") || "").toLowerCase();
122
+ if (contentType === "") return true;
123
+ return contentType.includes("text/plain");
124
+ }
125
+
68
126
  export default {
69
127
  async fetch(request, env, ctx) {
70
128
  if (isAssetProbeMethod(request.method) && canDelegateToAssets(env)) {
129
+ // Trade-off: every GET/HEAD request that matches a prerendered route pays
130
+ // the cost of one env.ASSETS.fetch() round-trip before the inner worker
131
+ // sees it (when this probe actually runs — see the header comment on
132
+ // run_worker_first). The upside is that the asset server handles
133
+ // trailing-slash canonicalisation (e.g. /docs/foo → redirect → /docs/foo/)
134
+ // and serves the build-time head-injected HTML with the hashed
135
+ // <link>/<script> tags. If we skipped this probe, prerendered routes
136
+ // would be dynamic-SSR'd by the inner Hono router without the prod head
137
+ // injection, and islands would never hydrate. For purely dynamic apps
138
+ // (prerender=false everywhere) the extra round-trip is pure overhead;
139
+ // splitting the wrapper into two variants is the accepted future escape
140
+ // hatch for that case.
71
141
  const assetResponse = await env.ASSETS.fetch(request);
72
142
  if (assetResponse.status !== 404) {
73
143
  return assetResponse;
74
144
  }
75
- // Fall through to the inner worker for genuinely dynamic routes.
145
+ // Asset 404. Fall through to the inner worker for genuinely dynamic
146
+ // routes, but first hold the asset response UNREAD if it carries a
147
+ // styled 404 page: if the inner also 404s with only the framework
148
+ // default body (text/plain or none), we return this styled page
149
+ // instead of the inner's plain 404 (issue #1322). An inner 404 that
150
+ // renders its own page (text/html) or a structured API error
151
+ // (application/json) wins. Returned verbatim — the one-shot body is
152
+ // untouched.
153
+ const styledAsset404 = assetHasStyled404Body(assetResponse) ? assetResponse : null;
154
+ const store = { env, ctx, request };
155
+ const innerResponse = await getStorage().run(store, () => inner.fetch(request));
156
+ if (
157
+ styledAsset404 !== null &&
158
+ innerResponse.status === 404 &&
159
+ innerIsFrameworkDefault404(innerResponse)
160
+ ) {
161
+ return styledAsset404;
162
+ }
163
+ return innerResponse;
76
164
  }
77
165
  const store = { env, ctx, request };
78
166
  return getStorage().run(store, () => inner.fetch(request));
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@takazudo/zfb-adapter-cloudflare",
3
- "version": "0.1.0-next.8",
3
+ "version": "0.1.0-next.80",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Rust-built static-site engine for Astro and Next.js users — millisecond rebuilds, single binary. Cloudflare Pages adapter.",
6
+ "description": "Rust-built static-site engine for Astro and Next.js users — millisecond rebuilds, single binary. Cloudflare adapter: Workers Static Assets (Pages-compatible).",
7
7
  "keywords": [
8
8
  "zfb",
9
9
  "zfb-adapter",
10
10
  "cloudflare",
11
11
  "cloudflare-pages",
12
12
  "cloudflare-workers",
13
+ "workers-static-assets",
13
14
  "ssg",
14
15
  "static-site-generator",
15
16
  "adapter"
@@ -44,6 +45,7 @@
44
45
  "dist",
45
46
  "bin",
46
47
  "src/worker-wrapper.mjs",
48
+ "src/emit-worker.mjs",
47
49
  "README.md",
48
50
  "CHANGELOG.md",
49
51
  "LICENSE"
@@ -61,7 +63,7 @@
61
63
  "vitest": "^2.1.9"
62
64
  },
63
65
  "scripts": {
64
- "build": "tsc && cp src/worker-wrapper.mjs dist/worker-wrapper.mjs",
66
+ "build": "tsc && cp src/worker-wrapper.mjs dist/worker-wrapper.mjs && cp src/emit-worker.mjs dist/emit-worker.mjs",
65
67
  "test": "vitest run",
66
68
  "test:watch": "vitest",
67
69
  "typecheck": "tsc --noEmit"
@@ -0,0 +1,49 @@
1
+ // Shared, dependency-free implementation of `emitWorker`.
2
+ //
3
+ // Kept as a plain `.mjs` module (no TypeScript) so it can be imported from
4
+ // both the typed TypeScript surface (`src/build.ts`) and the dependency-free
5
+ // CLI binary (`bin/cli.mjs`) without needing a TypeScript loader.
6
+ //
7
+ // Do NOT duplicate this logic elsewhere. The two consumers always stay in
8
+ // sync by importing from here.
9
+ //
10
+ // invariant: no runtime npm deps — see SECURITY-DEPS.md
11
+
12
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
13
+ import { join, resolve } from "node:path";
14
+
15
+ // `.assetsignore` tells the Workers Static Assets uploader (and Pages'
16
+ // asset server) to skip these two files, so they are only ever reachable
17
+ // through the Worker's module graph, not served as public static assets.
18
+ const ASSETS_IGNORE_CONTENT = "_worker.js\n_zfb_inner.mjs\n";
19
+
20
+ /**
21
+ * Emit a Cloudflare Workers Static Assets (Pages-compatible) `_worker.js`
22
+ * that wraps the zfb input bundle.
23
+ *
24
+ * Output shape (three files in `outdir`):
25
+ *
26
+ * _worker.js — Worker entry point (`main` in wrangler.toml, or the
27
+ * Pages advanced-mode convention)
28
+ * _zfb_inner.mjs — the input bundle, copied verbatim
29
+ * .assetsignore — excludes the two files above from the asset upload
30
+ *
31
+ * @param {{ inputBundlePath: string; outdir: string; workerWrapperSource: string }} input
32
+ * @returns {Promise<{ workerPath: string; innerBundlePath: string; assetsIgnorePath: string }>}
33
+ */
34
+ export async function emitWorker({ inputBundlePath, outdir, workerWrapperSource }) {
35
+ const outdirAbs = resolve(outdir);
36
+ const inputAbs = resolve(inputBundlePath);
37
+
38
+ await mkdir(outdirAbs, { recursive: true });
39
+ const innerBundlePath = join(outdirAbs, "_zfb_inner.mjs");
40
+ await copyFile(inputAbs, innerBundlePath);
41
+
42
+ const workerPath = join(outdirAbs, "_worker.js");
43
+ await writeFile(workerPath, workerWrapperSource, "utf8");
44
+
45
+ const assetsIgnorePath = join(outdirAbs, ".assetsignore");
46
+ await writeFile(assetsIgnorePath, ASSETS_IGNORE_CONTENT, "utf8");
47
+
48
+ return { workerPath, innerBundlePath, assetsIgnorePath };
49
+ }
@@ -10,34 +10,59 @@
10
10
  /** @type {string} */
11
11
  export const WORKER_WRAPPER_SOURCE = `// AUTO-GENERATED by @takazudo/zfb-adapter-cloudflare. Do not edit.
12
12
  //
13
- // Cloudflare Pages advanced mode entry. Forwards (request, env, ctx) to
14
- // the inner zfb worker bundle, exposing env/ctx to user code via
15
- // AsyncLocalStorage under a stable globalThis key.
13
+ // Cloudflare Workers Static Assets entry (\`main\` in wrangler.toml,
14
+ // alongside an \`[assets]\` block; also deployable to Cloudflare Pages
15
+ // advanced mode). Forwards (request, env, ctx) to the inner zfb worker
16
+ // bundle, exposing env/ctx to user code via AsyncLocalStorage under a
17
+ // stable globalThis key.
16
18
  //
17
19
  // The same key is read by @takazudo/zfb-adapter-cloudflare's
18
20
  // getCloudflareContext() inside the user bundle, so the two ends share
19
21
  // state even though they live in separate ESM module instances.
20
22
  //
21
- // CF Pages advanced-mode contract: when _worker.js is present, every
22
- // request hits the worker — static asset routing is OFF unless the
23
- // worker explicitly delegates to env.ASSETS. The dispatch order below
24
- // is deliberately "ASSETS first, inner on 404":
23
+ // Dispatch order is deliberately "ASSETS first, inner on 404" — this
24
+ // holds across both deploy targets, but *why* the ASSETS probe fires
25
+ // differs:
25
26
  //
26
- // - GET/HEAD requests probe env.ASSETS first. CF Pages' asset server
27
+ // - Workers Static Assets with \`run_worker_first = false\` (the zfb
28
+ // default): the platform itself serves asset hits before the
29
+ // Worker ever runs, so for those requests this in-Worker probe is
30
+ // normally bypassed — it never had a chance to run. The probe below
31
+ // is NOT dead code: it is still exercised for Pages (no
32
+ // \`run_worker_first\` concept; every request hits the Worker), for
33
+ // \`run_worker_first = true\` deployments, and for any request the
34
+ // platform's asset router itself does not resolve (which still
35
+ // reaches this Worker as a "miss").
36
+ // - GET/HEAD requests probe env.ASSETS first. The asset server
27
37
  // handles the trailing-slash canonicalisation for SSG output (e.g.
28
- // "/docs/foo" → 308 → "/docs/foo/" → dist/docs/foo/index.html), so
38
+ // "/docs/foo" → redirect → "/docs/foo/" → dist/docs/foo/index.html
39
+ // — 307 on Workers Static Assets, 308 on Cloudflare Pages), so
29
40
  // prerendered routes get the build-time head-injected HTML
30
41
  // (<link rel="stylesheet">, <script type="module" src="/assets/
31
42
  // islands-…">). If we let the inner Hono router handle them first,
32
43
  // it would dynamic-SSR the page WITHOUT the prod head injection
33
44
  // (which is a build-time post-process, not a runtime concern), and
34
- // islands would never hydrate. See zfb#XXX (filed by Wave 10 of
35
- // the zudo-doc#1355 zfb-pipeline-gaps epic).
36
- // - On 404 from ASSETS, fall through to the inner zfb worker. This is
37
- // where genuinely dynamic routes (\`prerender = false\`, e.g.
38
- // \`pages/api/*.tsx\`) are served.
45
+ // islands would never hydrate.
46
+ // - On 404 from ASSETS, fall through to the inner zfb worker — this
47
+ // is where genuinely dynamic routes (\`prerender = false\`, e.g.
48
+ // \`pages/api/*.tsx\`) are served. EXCEPTION: when the asset 404
49
+ // carries a *styled* 404-page body (\`not_found_handling = "404-page"\`
50
+ // on Workers Static Assets, or the \`404.html\` convention on Pages —
51
+ // detected as an HTML document with a real Content-Length) AND the
52
+ // inner ALSO 404s with only the framework default body (Hono's
53
+ // default not-found, \`text/plain\` "404 Not Found", or a bare 404
54
+ // with no content-type), the earlier styled asset 404 is preferred
55
+ // over the inner's plain 404 — otherwise the styled \`404.html\` would
56
+ // be discarded and users would see the inner plain-text 404 (issue
57
+ // #1322). Any inner 404 that declares another content-type is a
58
+ // deliberate response and WINS: a \`text/html\` 404 is a rendered
59
+ // custom not-found page (a \`prerender = false\` route SSRing its own
60
+ // 404), and an \`application/json\` 404 is an intentional API error —
61
+ // both are returned unchanged. Under \`not_found_handling = "none"\`
62
+ // the asset 404 body is empty/non-HTML, so the inner always wins —
63
+ // the historical behavior is preserved.
39
64
  // - Non-GET/HEAD requests skip ASSETS and go straight to the inner —
40
- // CF Pages assets are read-only by definition, and we want POSTs
65
+ // assets are read-only by definition, and we want POSTs
41
66
  // (\`/api/ai-chat\`, etc.) to reach the SSR handler without a probe
42
67
  // that would always 405 / 404.
43
68
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -65,14 +90,77 @@ function isAssetProbeMethod(method) {
65
90
  return method === "GET" || method === "HEAD";
66
91
  }
67
92
 
93
+ function assetHasStyled404Body(response) {
94
+ // True iff an ASSETS 404 carries the site's *styled* 404 page.
95
+ // Platform fact: \`not_found_handling = "404-page"\` (Workers Static
96
+ // Assets) and the Pages \`404.html\` convention both serve that page as
97
+ // an HTML document — auto-detected \`content-type: text/html\` and a real
98
+ // \`Content-Length\` (it is a static file). \`not_found_handling = "none"\`
99
+ // and a bare Pages advanced-mode 404 send Cloudflare's default 404 with
100
+ // an empty / non-HTML body, so this returns false and the inner wins.
101
+ // Header-only (never reads the body) so it holds for HEAD too and leaves
102
+ // the one-shot asset stream intact for a verbatim return.
103
+ const contentType = (response.headers.get("content-type") || "").toLowerCase();
104
+ const contentLength = Number(response.headers.get("content-length") || "0");
105
+ return contentType.includes("text/html") && contentLength > 0;
106
+ }
107
+
108
+ function innerIsFrameworkDefault404(response) {
109
+ // True iff an inner 404 is the framework's *generic* not-found, which
110
+ // yields to the styled asset 404 page. The inner zfb router never
111
+ // overrides Hono's default handler, so a route miss is always Hono's
112
+ // default not-found — \`text/plain\` "404 Not Found" (or a bare 404 with no
113
+ // content-type). Any inner 404 that declares another content-type is a
114
+ // *deliberate* response and must WIN over the static styled asset page: a
115
+ // \`text/html\` 404 is a rendered custom not-found page (e.g. a
116
+ // \`prerender = false\` [slug] route SSRing its own 404), and an
117
+ // \`application/json\` 404 is an intentional machine-readable API error.
118
+ // Trade-off: a bare \`text/plain\` API 404 is indistinguishable from the
119
+ // framework default and will yield to the styled page — API errors should
120
+ // use \`application/json\` to be preserved.
121
+ const contentType = (response.headers.get("content-type") || "").toLowerCase();
122
+ if (contentType === "") return true;
123
+ return contentType.includes("text/plain");
124
+ }
125
+
68
126
  export default {
69
127
  async fetch(request, env, ctx) {
70
128
  if (isAssetProbeMethod(request.method) && canDelegateToAssets(env)) {
129
+ // Trade-off: every GET/HEAD request that matches a prerendered route pays
130
+ // the cost of one env.ASSETS.fetch() round-trip before the inner worker
131
+ // sees it (when this probe actually runs — see the header comment on
132
+ // run_worker_first). The upside is that the asset server handles
133
+ // trailing-slash canonicalisation (e.g. /docs/foo → redirect → /docs/foo/)
134
+ // and serves the build-time head-injected HTML with the hashed
135
+ // <link>/<script> tags. If we skipped this probe, prerendered routes
136
+ // would be dynamic-SSR'd by the inner Hono router without the prod head
137
+ // injection, and islands would never hydrate. For purely dynamic apps
138
+ // (prerender=false everywhere) the extra round-trip is pure overhead;
139
+ // splitting the wrapper into two variants is the accepted future escape
140
+ // hatch for that case.
71
141
  const assetResponse = await env.ASSETS.fetch(request);
72
142
  if (assetResponse.status !== 404) {
73
143
  return assetResponse;
74
144
  }
75
- // Fall through to the inner worker for genuinely dynamic routes.
145
+ // Asset 404. Fall through to the inner worker for genuinely dynamic
146
+ // routes, but first hold the asset response UNREAD if it carries a
147
+ // styled 404 page: if the inner also 404s with only the framework
148
+ // default body (text/plain or none), we return this styled page
149
+ // instead of the inner's plain 404 (issue #1322). An inner 404 that
150
+ // renders its own page (text/html) or a structured API error
151
+ // (application/json) wins. Returned verbatim — the one-shot body is
152
+ // untouched.
153
+ const styledAsset404 = assetHasStyled404Body(assetResponse) ? assetResponse : null;
154
+ const store = { env, ctx, request };
155
+ const innerResponse = await getStorage().run(store, () => inner.fetch(request));
156
+ if (
157
+ styledAsset404 !== null &&
158
+ innerResponse.status === 404 &&
159
+ innerIsFrameworkDefault404(innerResponse)
160
+ ) {
161
+ return styledAsset404;
162
+ }
163
+ return innerResponse;
76
164
  }
77
165
  const store = { env, ctx, request };
78
166
  return getStorage().run(store, () => inner.fetch(request));
@@ -1 +0,0 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAcA;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAA2B,CAAC;AAEhE;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAelF"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAwCA;;;;;;;GAOG;AACH,MAAM,WAAW,0BAA0B;IACzC,+DAA+D;IAC/D,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;IAC3C,iEAAiE;IACjE,sBAAsB,IAAI,IAAI,CAAC;CAChC;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB,CAAC,GAAG,GAAG,OAAO;IAC9C,uEAAuE;IACvE,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,+DAA+D;IAC/D,QAAQ,CAAC,GAAG,EAAE,0BAA0B,CAAC;IACzC,qEAAqE;IACrE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AA0BD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,EAAE,OAAO,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEtF;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,GAAG,OAAO,KAAK,iBAAiB,CAAC,GAAG,CAAC,CAU5E"}