@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 +110 -19
- package/bin/cli.mjs +43 -20
- package/dist/build.d.ts +27 -12
- package/dist/build.js +24 -19
- package/dist/build.js.map +1 -1
- package/dist/emit-worker.mjs +156 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/worker-wrapper.mjs +104 -16
- package/package.json +5 -3
- package/src/emit-worker.mjs +156 -0
- package/src/worker-wrapper.mjs +104 -16
- package/dist/build.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
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
|
|
6
|
-
`@takazudo/zfb-runtime` page router into a
|
|
7
|
-
`_worker.js`
|
|
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
|
-
```
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
128
|
+
```sh
|
|
129
|
+
zfb build
|
|
130
|
+
wrangler dev # local Worker + assets simulation
|
|
131
|
+
wrangler deploy # ship it
|
|
132
|
+
```
|
|
76
133
|
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
10
|
-
// placed under <dir
|
|
11
|
-
//
|
|
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 {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
await writeFile(workerPath, WORKER_WRAPPER_SOURCE, "utf8");
|
|
36
|
+
import { emitWorker as _emitWorker } from "../src/emit-worker.mjs";
|
|
40
37
|
|
|
41
|
-
|
|
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
|
|
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(
|
|
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
|
|
22
|
-
*
|
|
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
|
|
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
|
|
48
|
+
* Output shape (two generated JavaScript files, `.assetsignore`, and zero or
|
|
49
|
+
* more copied Wasm assets in `outdir`):
|
|
38
50
|
*
|
|
39
|
-
* _worker.js — entry
|
|
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
|
|
45
|
-
*
|
|
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
|
|
48
|
-
* esbuild pass and would force the adapter to ship its own
|
|
49
|
-
* binary slot. The two-file layout keeps the adapter
|
|
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
|
|
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
|
|
26
|
+
* Output shape (two generated JavaScript files, `.assetsignore`, and zero or
|
|
27
|
+
* more copied Wasm assets in `outdir`):
|
|
23
28
|
*
|
|
24
|
-
* _worker.js — entry
|
|
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
|
|
30
|
-
*
|
|
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
|
|
33
|
-
* esbuild pass and would force the adapter to ship its own
|
|
34
|
-
* binary slot. The two-file layout keeps the adapter
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
|
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
|
|
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,
|
|
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"]}
|
package/dist/worker-wrapper.mjs
CHANGED
|
@@ -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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
// -
|
|
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" →
|
|
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.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// \`
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/src/worker-wrapper.mjs
CHANGED
|
@@ -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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
// -
|
|
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" →
|
|
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.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// \`
|
|
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
|
-
//
|
|
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
|
|
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/dist/build.d.ts.map
DELETED
|
@@ -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"}
|
package/dist/index.d.ts.map
DELETED
|
@@ -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"}
|