@takazudo/zfb-adapter-cloudflare 0.1.0-next.9 → 0.1.0-next.91

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], verified on **Workers Static
6
+ Assets**. It wraps the `@takazudo/zfb-runtime` page router into a Worker entry
7
+ (`_worker.js`), threading `(env, ctx)` through to user code via
8
+ `AsyncLocalStorage`. Cloudflare Pages advanced mode is unverified.
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,127 @@ 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), copied
73
+ `x-<hash>.wasm` modules, and `dist/.assetsignore` (see below) — ready
74
+ for Workers Static Assets.
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`
74
127
 
75
- ## CLI
128
+ ```sh
129
+ zfb build
130
+ wrangler dev # local Worker + assets simulation
131
+ wrangler deploy # ship it
132
+ ```
76
133
 
77
- The package ships a `zfb-adapter-cloudflare` bin invoked by
78
- `zfb-build`:
134
+ ## `.assetsignore`
79
135
 
136
+ The adapter emits `dist/.assetsignore` alongside `_worker.js` and
137
+ `_zfb_inner.mjs`. When zfb passes one or more `--asset` Wasm modules, it
138
+ also adds every copied basename:
139
+
140
+ ```
141
+ _worker.js
142
+ _zfb_inner.mjs
143
+ index_bg-a1b2c3d4.wasm
80
144
  ```
81
- zfb-adapter-cloudflare bundle <input.mjs> --outdir dist/
145
+
146
+ This excludes the wrapper, inner SSR bundle, and compiled Wasm modules from
147
+ the asset upload, so they are reachable only through the Worker's own module
148
+ graph — never served as public static files. Without it, a request for
149
+ `/_worker.js` or `/_zfb_inner.mjs` would serve your server code as a
150
+ plain-text download.
151
+
152
+ **Merge behavior:** zfb copies your project's `public/` directory into
153
+ `dist/` _after_ running this adapter. If `public/.assetsignore` adds entries,
154
+ zfb merges them with the generated entries; it does not drop the wrapper,
155
+ inner-bundle, or Wasm exclusions.
156
+
157
+ ## CLI asset contract
158
+
159
+ zfb calls the package CLI with the SSR bundle plus one repeatable `--asset`
160
+ path for every bundle-relative Wasm module:
161
+
162
+ ```sh
163
+ zfb-adapter-cloudflare bundle ./bundle.mjs --outdir ./dist \
164
+ --asset index_bg-a1b2c3d4.wasm
82
165
  ```
83
166
 
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.
167
+ Each asset path must be relative to the input bundle directory. The CLI copies
168
+ it into `outdir` under its basename and records that basename in
169
+ `.assetsignore`; paths that escape the input directory or collide with emitted
170
+ output fail the build.
171
+
172
+ ## Cloudflare Pages advanced mode
173
+
174
+ The root-level `_worker.js` follows the Cloudflare Pages advanced-mode
175
+ convention, but this adapter is only verified on Workers Static Assets.
176
+ Cloudflare Pages advanced mode remains unverified and should not be treated as
177
+ a supported deployment target until it has a dedicated smoke test.
87
178
 
88
- ## Why two files
179
+ ## Why two bundle files instead of one
89
180
 
90
181
  The wrapper imports the inner bundle by relative path
91
182
  (`./_zfb_inner.mjs`) instead of inlining it, so the adapter package
92
183
  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.
184
+ loader resolves relative ESM imports inside the `_worker.js` directory
185
+ layout.
95
186
 
96
187
  ## Why AsyncLocalStorage on a globalThis registry
97
188
 
package/bin/cli.mjs CHANGED
@@ -4,20 +4,22 @@
4
4
  //
5
5
  // Subcommands:
6
6
  //
7
- // bundle <input> --outdir <dir>
7
+ // bundle <input> --outdir <dir> [--asset <path>]...
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
11
- // bundler emits; <dir> is typically the project's `dist/`.
9
+ // Wrap the input ESM bundle into a Cloudflare Workers Static Assets
10
+ // `_worker.js` placed under <dir>, alongside a `.assetsignore` that
11
+ // excludes the wrapper, inner bundle, and every copied --asset basename
12
+ // from the asset upload. The input bundle is the file `zfb_build`'s
13
+ // bundler emits; <dir> is typically the project's `dist/`. Cloudflare
14
+ // Pages advanced mode is unverified.
12
15
  //
13
16
  // The CLI is intentionally tiny and dependency-free. It imports the
14
17
  // wrapper string from the canonical `src/worker-wrapper.mjs` (plain JS,
15
18
  // no TypeScript loader required) so there is a single source of truth.
16
19
  // invariant: no runtime npm deps — see SECURITY-DEPS.md
17
20
 
18
- import { copyFile, mkdir, writeFile } from "node:fs/promises";
19
21
  import { realpathSync } from "node:fs";
20
- import { join, resolve } from "node:path";
22
+ import { resolve } from "node:path";
21
23
  import { fileURLToPath } from "node:url";
22
24
 
23
25
  // ---------------------------------------------------------------------------
@@ -27,18 +29,19 @@ import { fileURLToPath } from "node:url";
27
29
  import { WORKER_WRAPPER_SOURCE } from "../src/worker-wrapper.mjs";
28
30
  export { WORKER_WRAPPER_SOURCE };
29
31
 
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);
32
+ // ---------------------------------------------------------------------------
33
+ // emitWorker — shared implementation, no runtime npm deps.
34
+ // ---------------------------------------------------------------------------
37
35
 
38
- const workerPath = join(outdirAbs, "_worker.js");
39
- await writeFile(workerPath, WORKER_WRAPPER_SOURCE, "utf8");
36
+ import { emitWorker as _emitWorker } from "../src/emit-worker.mjs";
40
37
 
41
- return { workerPath, innerBundlePath };
38
+ export async function emitWorker({ inputBundlePath, outdir, assets = [] }) {
39
+ return _emitWorker({
40
+ inputBundlePath,
41
+ outdir,
42
+ assets,
43
+ workerWrapperSource: WORKER_WRAPPER_SOURCE,
44
+ });
42
45
  }
43
46
 
44
47
  // ---------------------------------------------------------------------------
@@ -52,13 +55,15 @@ function fail(message) {
52
55
 
53
56
  function printUsage() {
54
57
  process.stdout.write(`Usage:
55
- zfb-adapter-cloudflare bundle <input> --outdir <dir>
58
+ zfb-adapter-cloudflare bundle <input> --outdir <dir> [--asset <path>]...
56
59
 
57
60
  Wrap an ESM bundle (the output of zfb-build's bundler) into a
58
- Cloudflare Pages \`_worker.js\` placed under <dir>.
61
+ Cloudflare Workers Static Assets \`_worker.js\` placed under <dir>
62
+ with a protected \`.assetsignore\`. Cloudflare Pages advanced mode is unverified.
59
63
 
60
64
  Options:
61
65
  --outdir <dir> Output directory. Required.
66
+ --asset <path> Bundle-relative Wasm asset to copy. Repeatable.
62
67
  -h, --help Show this help.
63
68
  `);
64
69
  }
@@ -75,6 +80,7 @@ function parseArgs(argv) {
75
80
 
76
81
  let input = null;
77
82
  let outdir = null;
83
+ const assets = [];
78
84
  let i = 1;
79
85
  while (i < args.length) {
80
86
  const arg = args[i];
@@ -90,6 +96,20 @@ function parseArgs(argv) {
90
96
  i += 1;
91
97
  continue;
92
98
  }
99
+ if (arg === "--asset") {
100
+ const next = args[i + 1];
101
+ if (!next) fail("--asset requires a path argument");
102
+ assets.push(next);
103
+ i += 2;
104
+ continue;
105
+ }
106
+ if (arg.startsWith("--asset=")) {
107
+ const asset = arg.slice("--asset=".length);
108
+ if (!asset) fail("--asset requires a path argument");
109
+ assets.push(asset);
110
+ i += 1;
111
+ continue;
112
+ }
93
113
  if (arg.startsWith("--")) {
94
114
  fail(`unknown option: ${arg}`);
95
115
  }
@@ -104,7 +124,7 @@ function parseArgs(argv) {
104
124
  if (!input) fail("missing required positional argument: <input>");
105
125
  if (!outdir) fail("missing required option: --outdir <dir>");
106
126
 
107
- return { command: "bundle", input, outdir };
127
+ return { command: "bundle", input, outdir, assets };
108
128
  }
109
129
 
110
130
  async function main() {
@@ -142,8 +162,11 @@ async function main() {
142
162
  const out = await emitWorker({
143
163
  inputBundlePath: inputAbs,
144
164
  outdir: outdirAbs,
165
+ assets: parsed.assets,
145
166
  });
146
- process.stdout.write(`wrote ${out.workerPath}\nwrote ${out.innerBundlePath}\n`);
167
+ process.stdout.write(
168
+ `wrote ${out.workerPath}\nwrote ${out.innerBundlePath}\nwrote ${out.assetsIgnorePath}\n`,
169
+ );
147
170
  }
148
171
 
149
172
  main().catch((err) => {
package/dist/build.d.ts CHANGED
@@ -18,10 +18,19 @@ 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), copied Wasm assets, and `.assetsignore` into it. The ignore
23
+ * file protects every generated JavaScript and Wasm basename from the
24
+ * public asset upload.
23
25
  */
24
26
  readonly outdir: string;
27
+ /**
28
+ * Wasm modules emitted beside the input bundle. Relative paths resolve from
29
+ * the input bundle's directory and each module is copied into the Worker
30
+ * package under its basename, then added to `.assetsignore` so it remains a
31
+ * Worker module rather than a public static asset.
32
+ */
33
+ readonly assets?: readonly string[];
25
34
  }
26
35
  /**
27
36
  * Output paths the emitter produced. Returned for callers that want to
@@ -30,24 +39,30 @@ export interface EmitWorkerInput {
30
39
  export interface EmitWorkerOutput {
31
40
  readonly workerPath: string;
32
41
  readonly innerBundlePath: string;
42
+ readonly assetsIgnorePath: string;
33
43
  }
34
44
  /**
35
- * Emit a Cloudflare Pages `_worker.js` that wraps the zfb input bundle.
45
+ * Emit a Cloudflare Workers Static Assets `_worker.js` that wraps the zfb
46
+ * input bundle. Cloudflare Pages advanced mode is unverified.
36
47
  *
37
- * Output shape (two files in `outdir`):
48
+ * Output shape (two generated JavaScript files, `.assetsignore`, and zero or
49
+ * more copied Wasm assets in `outdir`):
38
50
  *
39
- * _worker.js — entry imported by Cloudflare Pages advanced mode
51
+ * _worker.js — Worker entry point (`main` in wrangler.toml)
40
52
  * _zfb_inner.mjs — the input bundle, copied verbatim
53
+ * <asset>.wasm — each bundle-relative Wasm input, copied by basename
54
+ * .assetsignore — excludes every generated JavaScript and Wasm basename
55
+ * from the asset upload so they are only reachable
56
+ * through the Worker's module graph
41
57
  *
42
58
  * The wrapper imports the inner bundle via the relative path
43
59
  * `./_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.
60
+ * imports inside the `_worker.js` directory, so this layout works
61
+ * without re-bundling.
46
62
  *
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.
63
+ * Why two bundle files instead of one: re-bundling here would require a
64
+ * second esbuild pass and would force the adapter to ship its own
65
+ * esbuild binary slot. The two-file layout keeps the adapter
66
+ * dependency-free at runtime — it is just `node:fs` glue.
51
67
  */
52
68
  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,35 @@ 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 `_worker.js` that wraps the zfb
24
+ * input bundle. Cloudflare Pages advanced mode is unverified.
21
25
  *
22
- * Output shape (two files in `outdir`):
26
+ * Output shape (two generated JavaScript files, `.assetsignore`, and zero or
27
+ * more copied Wasm assets in `outdir`):
23
28
  *
24
- * _worker.js — entry imported by Cloudflare Pages advanced mode
29
+ * _worker.js — Worker entry point (`main` in wrangler.toml)
25
30
  * _zfb_inner.mjs — the input bundle, copied verbatim
31
+ * <asset>.wasm — each bundle-relative Wasm input, copied by basename
32
+ * .assetsignore — excludes every generated JavaScript and Wasm basename
33
+ * from the asset upload so they are only reachable
34
+ * through the Worker's module graph
26
35
  *
27
36
  * The wrapper imports the inner bundle via the relative path
28
37
  * `./_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.
38
+ * imports inside the `_worker.js` directory, so this layout works
39
+ * without re-bundling.
31
40
  *
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.
41
+ * Why two bundle files instead of one: re-bundling here would require a
42
+ * second esbuild pass and would force the adapter to ship its own
43
+ * esbuild binary slot. The two-file layout keeps the adapter
44
+ * dependency-free at runtime — it is just `node:fs` glue.
36
45
  */
37
46
  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 };
47
+ return _emitWorker({
48
+ inputBundlePath: input.inputBundlePath,
49
+ outdir: input.outdir,
50
+ assets: input.assets,
51
+ workerWrapperSource: WORKER_WRAPPER_SOURCE,
52
+ });
48
53
  }
49
54
  //# 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;AAyChE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;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,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), copied Wasm assets, and `.assetsignore` into it. The ignore\n * file protects every generated JavaScript and Wasm basename from the\n * public asset upload.\n */\n readonly outdir: string;\n /**\n * Wasm modules emitted beside the input bundle. Relative paths resolve from\n * the input bundle's directory and each module is copied into the Worker\n * package under its basename, then added to `.assetsignore` so it remains a\n * Worker module rather than a public static asset.\n */\n readonly assets?: readonly 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 `_worker.js` that wraps the zfb\n * input bundle. Cloudflare Pages advanced mode is unverified.\n *\n * Output shape (two generated JavaScript files, `.assetsignore`, and zero or\n * more copied Wasm assets in `outdir`):\n *\n * _worker.js — Worker entry point (`main` in wrangler.toml)\n * _zfb_inner.mjs — the input bundle, copied verbatim\n * <asset>.wasm — each bundle-relative Wasm input, copied by basename\n * .assetsignore — excludes every generated JavaScript and Wasm basename\n * from the asset upload so they are only reachable\n * through the Worker's module graph\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 assets: input.assets,\n workerWrapperSource: WORKER_WRAPPER_SOURCE,\n }) as Promise<EmitWorkerOutput>;\n}\n"]}
@@ -0,0 +1,156 @@
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, lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+
15
+ // `.assetsignore` tells the Workers Static Assets uploader to skip the
16
+ // generated JavaScript files. Every copied Wasm basename is appended later,
17
+ // so it too is reachable only through the Worker's module graph and never
18
+ // served as a public static asset.
19
+ const ASSETS_IGNORE_BASE_ENTRIES = ["_worker.js", "_zfb_inner.mjs"];
20
+ const RESERVED_OUTPUT_NAMES = new Set([...ASSETS_IGNORE_BASE_ENTRIES, ".assetsignore"]);
21
+
22
+ function isPathWithin(parent, candidate) {
23
+ const pathFromParent = relative(parent, candidate);
24
+ return (
25
+ pathFromParent === "" ||
26
+ (!pathFromParent.startsWith(`..${sep}`) &&
27
+ pathFromParent !== ".." &&
28
+ !isAbsolute(pathFromParent))
29
+ );
30
+ }
31
+
32
+ async function resolveAssets(inputBundlePath, assetPaths) {
33
+ const inputDir = dirname(inputBundlePath);
34
+ const canonicalInputDir = await realpath(inputDir);
35
+ const names = new Set();
36
+ const resolvedAssets = [];
37
+
38
+ for (const assetPath of assetPaths) {
39
+ if (typeof assetPath !== "string" || isAbsolute(assetPath)) {
40
+ throw new Error("asset path must be bundle-relative: " + String(assetPath));
41
+ }
42
+ const sourcePath = resolve(inputDir, assetPath);
43
+ if (!isPathWithin(inputDir, sourcePath)) {
44
+ throw new Error("asset path escapes the input bundle directory: " + assetPath);
45
+ }
46
+
47
+ const sourceInfo = await stat(sourcePath);
48
+ if (!sourceInfo.isFile()) {
49
+ throw new Error("asset is not a file: " + sourcePath);
50
+ }
51
+ const canonicalSourcePath = await realpath(sourcePath);
52
+ if (!isPathWithin(canonicalInputDir, canonicalSourcePath)) {
53
+ throw new Error("asset path resolves outside the input bundle directory: " + assetPath);
54
+ }
55
+
56
+ const outputName = basename(sourcePath);
57
+ if (!outputName || outputName === "." || outputName === "..") {
58
+ throw new Error("asset path has no valid basename: " + assetPath);
59
+ }
60
+ if (RESERVED_OUTPUT_NAMES.has(outputName)) {
61
+ throw new Error("asset basename collides with generated adapter output: " + outputName);
62
+ }
63
+ if (names.has(outputName)) {
64
+ throw new Error("asset basename collision: " + outputName);
65
+ }
66
+ names.add(outputName);
67
+ resolvedAssets.push({ sourcePath: canonicalSourcePath, outputName });
68
+ }
69
+
70
+ return resolvedAssets;
71
+ }
72
+
73
+ async function pathExists(path) {
74
+ try {
75
+ await lstat(path);
76
+ return true;
77
+ } catch (error) {
78
+ if (error && typeof error === "object" && error.code === "ENOENT") {
79
+ return false;
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ async function readExistingAssetsIgnore(path) {
86
+ try {
87
+ return await readFile(path, "utf8");
88
+ } catch (error) {
89
+ if (error && typeof error === "object" && error.code === "ENOENT") {
90
+ return "";
91
+ }
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ function mergeAssetsIgnore(existing, requiredEntries) {
97
+ const listed = new Set(existing.split(/\r?\n/));
98
+ const missing = requiredEntries.filter((entry) => !listed.has(entry));
99
+ if (missing.length === 0) {
100
+ return existing;
101
+ }
102
+
103
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? existing + "\n" : existing;
104
+ return prefix + missing.map((entry) => entry + "\n").join("");
105
+ }
106
+
107
+ /**
108
+ * Emit a Cloudflare Workers Static Assets `_worker.js` that wraps the zfb
109
+ * input bundle. Cloudflare Pages advanced mode is unverified.
110
+ *
111
+ * Output shape (two generated JavaScript files, `.assetsignore`, and zero or
112
+ * more copied Wasm assets in `outdir`):
113
+ *
114
+ * _worker.js — Worker entry point (`main` in wrangler.toml)
115
+ * _zfb_inner.mjs — the input bundle, copied verbatim
116
+ * <asset>.wasm — each bundle-relative Wasm input, copied by basename
117
+ * .assetsignore — excludes every generated JavaScript and Wasm basename
118
+ * from the asset upload
119
+ *
120
+ * @param {{ inputBundlePath: string; outdir: string; assets?: readonly string[]; workerWrapperSource: string }} input
121
+ * @returns {Promise<{ workerPath: string; innerBundlePath: string; assetsIgnorePath: string }>}
122
+ */
123
+ export async function emitWorker({ inputBundlePath, outdir, assets = [], workerWrapperSource }) {
124
+ const outdirAbs = resolve(outdir);
125
+ const inputAbs = resolve(inputBundlePath);
126
+ const resolvedAssets = await resolveAssets(inputAbs, assets);
127
+
128
+ await mkdir(outdirAbs, { recursive: true });
129
+ for (const asset of resolvedAssets) {
130
+ const destination = join(outdirAbs, asset.outputName);
131
+ if (await pathExists(destination)) {
132
+ throw new Error(
133
+ "asset basename collision: " + asset.outputName + " would overwrite " + destination,
134
+ );
135
+ }
136
+ }
137
+
138
+ const innerBundlePath = join(outdirAbs, "_zfb_inner.mjs");
139
+ await copyFile(inputAbs, innerBundlePath);
140
+
141
+ const workerPath = join(outdirAbs, "_worker.js");
142
+ await writeFile(workerPath, workerWrapperSource, "utf8");
143
+
144
+ const assetsIgnorePath = join(outdirAbs, ".assetsignore");
145
+ const existingAssetsIgnore = await readExistingAssetsIgnore(assetsIgnorePath);
146
+ for (const asset of resolvedAssets) {
147
+ await copyFile(asset.sourcePath, join(outdirAbs, asset.outputName));
148
+ }
149
+ const assetsIgnore = mergeAssetsIgnore(existingAssetsIgnore, [
150
+ ...ASSETS_IGNORE_BASE_ENTRIES,
151
+ ...resolvedAssets.map((asset) => asset.outputName),
152
+ ]);
153
+ await writeFile(assetsIgnorePath, assetsIgnore, "utf8");
154
+
155
+ return { workerPath, innerBundlePath, assetsIgnorePath };
156
+ }
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.9",
3
+ "version": "0.1.0-next.91",
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,156 @@
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, lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+
15
+ // `.assetsignore` tells the Workers Static Assets uploader to skip the
16
+ // generated JavaScript files. Every copied Wasm basename is appended later,
17
+ // so it too is reachable only through the Worker's module graph and never
18
+ // served as a public static asset.
19
+ const ASSETS_IGNORE_BASE_ENTRIES = ["_worker.js", "_zfb_inner.mjs"];
20
+ const RESERVED_OUTPUT_NAMES = new Set([...ASSETS_IGNORE_BASE_ENTRIES, ".assetsignore"]);
21
+
22
+ function isPathWithin(parent, candidate) {
23
+ const pathFromParent = relative(parent, candidate);
24
+ return (
25
+ pathFromParent === "" ||
26
+ (!pathFromParent.startsWith(`..${sep}`) &&
27
+ pathFromParent !== ".." &&
28
+ !isAbsolute(pathFromParent))
29
+ );
30
+ }
31
+
32
+ async function resolveAssets(inputBundlePath, assetPaths) {
33
+ const inputDir = dirname(inputBundlePath);
34
+ const canonicalInputDir = await realpath(inputDir);
35
+ const names = new Set();
36
+ const resolvedAssets = [];
37
+
38
+ for (const assetPath of assetPaths) {
39
+ if (typeof assetPath !== "string" || isAbsolute(assetPath)) {
40
+ throw new Error("asset path must be bundle-relative: " + String(assetPath));
41
+ }
42
+ const sourcePath = resolve(inputDir, assetPath);
43
+ if (!isPathWithin(inputDir, sourcePath)) {
44
+ throw new Error("asset path escapes the input bundle directory: " + assetPath);
45
+ }
46
+
47
+ const sourceInfo = await stat(sourcePath);
48
+ if (!sourceInfo.isFile()) {
49
+ throw new Error("asset is not a file: " + sourcePath);
50
+ }
51
+ const canonicalSourcePath = await realpath(sourcePath);
52
+ if (!isPathWithin(canonicalInputDir, canonicalSourcePath)) {
53
+ throw new Error("asset path resolves outside the input bundle directory: " + assetPath);
54
+ }
55
+
56
+ const outputName = basename(sourcePath);
57
+ if (!outputName || outputName === "." || outputName === "..") {
58
+ throw new Error("asset path has no valid basename: " + assetPath);
59
+ }
60
+ if (RESERVED_OUTPUT_NAMES.has(outputName)) {
61
+ throw new Error("asset basename collides with generated adapter output: " + outputName);
62
+ }
63
+ if (names.has(outputName)) {
64
+ throw new Error("asset basename collision: " + outputName);
65
+ }
66
+ names.add(outputName);
67
+ resolvedAssets.push({ sourcePath: canonicalSourcePath, outputName });
68
+ }
69
+
70
+ return resolvedAssets;
71
+ }
72
+
73
+ async function pathExists(path) {
74
+ try {
75
+ await lstat(path);
76
+ return true;
77
+ } catch (error) {
78
+ if (error && typeof error === "object" && error.code === "ENOENT") {
79
+ return false;
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ async function readExistingAssetsIgnore(path) {
86
+ try {
87
+ return await readFile(path, "utf8");
88
+ } catch (error) {
89
+ if (error && typeof error === "object" && error.code === "ENOENT") {
90
+ return "";
91
+ }
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ function mergeAssetsIgnore(existing, requiredEntries) {
97
+ const listed = new Set(existing.split(/\r?\n/));
98
+ const missing = requiredEntries.filter((entry) => !listed.has(entry));
99
+ if (missing.length === 0) {
100
+ return existing;
101
+ }
102
+
103
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? existing + "\n" : existing;
104
+ return prefix + missing.map((entry) => entry + "\n").join("");
105
+ }
106
+
107
+ /**
108
+ * Emit a Cloudflare Workers Static Assets `_worker.js` that wraps the zfb
109
+ * input bundle. Cloudflare Pages advanced mode is unverified.
110
+ *
111
+ * Output shape (two generated JavaScript files, `.assetsignore`, and zero or
112
+ * more copied Wasm assets in `outdir`):
113
+ *
114
+ * _worker.js — Worker entry point (`main` in wrangler.toml)
115
+ * _zfb_inner.mjs — the input bundle, copied verbatim
116
+ * <asset>.wasm — each bundle-relative Wasm input, copied by basename
117
+ * .assetsignore — excludes every generated JavaScript and Wasm basename
118
+ * from the asset upload
119
+ *
120
+ * @param {{ inputBundlePath: string; outdir: string; assets?: readonly string[]; workerWrapperSource: string }} input
121
+ * @returns {Promise<{ workerPath: string; innerBundlePath: string; assetsIgnorePath: string }>}
122
+ */
123
+ export async function emitWorker({ inputBundlePath, outdir, assets = [], workerWrapperSource }) {
124
+ const outdirAbs = resolve(outdir);
125
+ const inputAbs = resolve(inputBundlePath);
126
+ const resolvedAssets = await resolveAssets(inputAbs, assets);
127
+
128
+ await mkdir(outdirAbs, { recursive: true });
129
+ for (const asset of resolvedAssets) {
130
+ const destination = join(outdirAbs, asset.outputName);
131
+ if (await pathExists(destination)) {
132
+ throw new Error(
133
+ "asset basename collision: " + asset.outputName + " would overwrite " + destination,
134
+ );
135
+ }
136
+ }
137
+
138
+ const innerBundlePath = join(outdirAbs, "_zfb_inner.mjs");
139
+ await copyFile(inputAbs, innerBundlePath);
140
+
141
+ const workerPath = join(outdirAbs, "_worker.js");
142
+ await writeFile(workerPath, workerWrapperSource, "utf8");
143
+
144
+ const assetsIgnorePath = join(outdirAbs, ".assetsignore");
145
+ const existingAssetsIgnore = await readExistingAssetsIgnore(assetsIgnorePath);
146
+ for (const asset of resolvedAssets) {
147
+ await copyFile(asset.sourcePath, join(outdirAbs, asset.outputName));
148
+ }
149
+ const assetsIgnore = mergeAssetsIgnore(existingAssetsIgnore, [
150
+ ...ASSETS_IGNORE_BASE_ENTRIES,
151
+ ...resolvedAssets.map((asset) => asset.outputName),
152
+ ]);
153
+ await writeFile(assetsIgnorePath, assetsIgnore, "utf8");
154
+
155
+ return { workerPath, innerBundlePath, assetsIgnorePath };
156
+ }
@@ -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"}