@let.com/cli 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,875 @@
1
+ // The Create edge packager (app hosting spec §7.4, slice E2). Runs INSIDE an edge build — in the pinned
2
+ // Node stage of the build pod's rootless BuildKit — after a framework's Cloudflare adapter has built
3
+ // the app. It turns the adapter's output into the Create edge bundle the control plane deploys
4
+ // (`create-edge.json` + `worker/*` + `public/*` [+ `cache/*`], see apps/api/src/apps-hosting/edge/bundle.ts):
5
+ //
6
+ // 1. locate the output — the Worker entry and the static-assets folder — for the adapter that ran
7
+ // (`resolveOutput`), preferring the wrangler config the Cloudflare Vite plugin / Nitro redirect to
8
+ // (`.wrangler/deploy/config.json`), else each adapter's documented output paths;
9
+ // 2. bundle the Worker into ES modules with `wrangler deploy --dry-run --outdir` (`bundleCommand`);
10
+ // 3. pack the bundle (`packBundle`): the modules, the static files, and for Next.js the prerendered
11
+ // incremental-cache entries keyed exactly as OpenNext's R2 cache reads them, plus the small entry
12
+ // wrapper Create needs for OpenNext on Workers for Platforms (`nextEntryWrapper`).
13
+ //
14
+ // Dependency-free on purpose (node: builtins only): it is copied into the build as it is, and the
15
+ // tests in apps/app-edge/tests import this same file.
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
19
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
20
+ import { gzipSync } from 'node:zlib';
21
+
22
+ export const EDGE_MANIFEST_FILE = 'create-edge.json';
23
+ export const DEFAULT_COMPAT_DATE = '2026-09-01';
24
+ /** OpenNext's binding names (@opennextjs/cloudflare 1.20.6 through 1.20.12) and the cache key shape its R2 cache reads. */
25
+ export const NEXT_BINDINGS = { incrementalCache: 'NEXT_INC_CACHE_R2_BUCKET', tagCache: 'NEXT_TAG_CACHE_D1' };
26
+ export const NEXT_CACHE_KEY = /^incremental-cache\/[A-Za-z0-9_.-]{1,128}\/[0-9a-f]{64}\.(cache|fetch)$/;
27
+ const BINDING_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
28
+ const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
29
+
30
+ /** A failure an agent can act on: `code` is stable (the control plane's build summary keys on it). */
31
+ export class EdgeBuildError extends Error {
32
+ constructor(code, message, fix) {
33
+ super(message);
34
+ this.code = code;
35
+ this.fix = fix ?? null;
36
+ }
37
+ }
38
+
39
+ const SERVER_FIX = 'Deploy it as a server instead (target "container"): every framework here also runs in a container.';
40
+
41
+ // ---------------------------------------------------------------------------------------------
42
+ // Reading configs
43
+ // ---------------------------------------------------------------------------------------------
44
+
45
+ /** JSON with comments and trailing commas (wrangler.jsonc). */
46
+ export function parseJsonc(text) {
47
+ let out = '';
48
+ let inString = false;
49
+ for (let i = 0; i < text.length; i++) {
50
+ const ch = text[i];
51
+ if (inString) {
52
+ out += ch;
53
+ if (ch === '\\') {
54
+ out += text[++i] ?? '';
55
+ } else if (ch === '"') inString = false;
56
+ continue;
57
+ }
58
+ if (ch === '"') {
59
+ inString = true;
60
+ out += ch;
61
+ } else if (ch === '/' && text[i + 1] === '/') {
62
+ while (i < text.length && text[i] !== '\n') i++;
63
+ out += '\n';
64
+ } else if (ch === '/' && text[i + 1] === '*') {
65
+ i += 2;
66
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
67
+ i++;
68
+ } else out += ch;
69
+ }
70
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
71
+ }
72
+
73
+ function readJsonc(path) {
74
+ return parseJsonc(readFileSync(path, 'utf8'));
75
+ }
76
+
77
+ /**
78
+ * The wrangler config an adapter left behind, if any: the redirect file the Cloudflare Vite plugin and
79
+ * Nitro write (`.wrangler/deploy/config.json` → `{ "configPath": "…" }`, relative to its folder), else
80
+ * a `wrangler.json`/`wrangler.jsonc` at the root. Paths inside it are relative to its own folder.
81
+ */
82
+ export function findWranglerConfig(root) {
83
+ const redirect = join(root, '.wrangler', 'deploy', 'config.json');
84
+ if (existsSync(redirect)) {
85
+ const { configPath } = readJsonc(redirect);
86
+ if (typeof configPath === 'string') {
87
+ const path = resolve(dirname(redirect), configPath);
88
+ if (!inside(root, path)) throw new EdgeBuildError('edge_output_invalid', 'The redirected wrangler config points outside the project');
89
+ if (existsSync(path)) return { path, config: readJsonc(path), redirected: true };
90
+ }
91
+ }
92
+ for (const name of ['wrangler.jsonc', 'wrangler.json']) {
93
+ const path = join(root, name);
94
+ if (existsSync(path)) return { path, config: readJsonc(path), redirected: false };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function inside(root, path) {
100
+ const rel = relative(resolve(root), resolve(path));
101
+ return rel === '' || (!rel.startsWith('..') && !rel.startsWith(sep) && !/^[A-Za-z]:/.test(rel));
102
+ }
103
+
104
+ /**
105
+ * Bindings the project's own wrangler config declares, mapped onto what a frontend can get: at most
106
+ * one KV namespace, one R2 bucket and one D1 database of its OWN (their ids/names in the config are
107
+ * ignored — the control plane provisions the frontend's), and Durable Objects of classes this Worker
108
+ * exports. Anything that would reach another Worker or an account-wide resource is refused.
109
+ */
110
+ export function bindingsFromConfig(config) {
111
+ const resources = {};
112
+ const one = (list, kind, label) => {
113
+ if (!Array.isArray(list) || list.length === 0) return;
114
+ if (list.length > 1) throw new EdgeBuildError('edge_bindings_unsupported', `The wrangler config declares ${list.length} ${label} bindings; a frontend gets exactly one ${label} of its own`, `Use one ${label} binding (the frontend’s own), or deploy as a server.`);
115
+ const name = list[0]?.binding;
116
+ if (typeof name !== 'string' || !BINDING_NAME.test(name)) throw new EdgeBuildError('edge_bindings_unsupported', `A ${label} binding has an invalid name`);
117
+ resources[kind] = name;
118
+ };
119
+ one(config.kv_namespaces, 'kv', 'KV namespace');
120
+ one(config.r2_buckets, 'r2', 'R2 bucket');
121
+ one(config.d1_databases, 'd1', 'D1 database');
122
+ const durableObjects = [];
123
+ for (const b of config.durable_objects?.bindings ?? []) {
124
+ if (b.script_name) throw new EdgeBuildError('edge_bindings_unsupported', `Durable Object binding ${b.name} points at another Worker (${b.script_name}); only classes this Worker exports can be bound`);
125
+ if (!BINDING_NAME.test(b.name ?? '') || !CLASS_NAME.test(b.class_name ?? '')) throw new EdgeBuildError('edge_bindings_unsupported', 'A Durable Object binding has an invalid name or class name');
126
+ durableObjects.push({ name: b.name, className: b.class_name });
127
+ }
128
+ for (const key of ['services', 'queues', 'hyperdrive', 'vectorize', 'analytics_engine_datasets', 'dispatch_namespaces', 'mtls_certificates', 'workflows']) {
129
+ const v = config[key];
130
+ const present = Array.isArray(v) ? v.length > 0 : v && typeof v === 'object' && Object.keys(v).length > 0;
131
+ if (present) throw new EdgeBuildError('edge_bindings_unsupported', `The wrangler config declares ${key} bindings, which edge frontends on Create do not offer yet`, SERVER_FIX);
132
+ }
133
+ return { resources, durableObjects };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------------------------
137
+ // Locating the adapter's output
138
+ // ---------------------------------------------------------------------------------------------
139
+
140
+ const firstExisting = (root, candidates) => candidates.map((c) => join(root, c)).find((p) => existsSync(p)) ?? null;
141
+
142
+ /**
143
+ * Where the Worker entry and the static files are, per adapter (§7.4 table). `plan` is the build plan
144
+ * the control plane derived from the detector: `{ framework, adapter: { name, mode }, outputDir, spa }`.
145
+ */
146
+ export function resolveOutput(root, plan) {
147
+ const adapter = plan.adapter?.name;
148
+ const mode = plan.adapter?.mode;
149
+ if (mode === 'static') {
150
+ const dir = plan.outputDir && plan.outputDir !== '.' ? join(root, plan.outputDir) : plan.outputDir === '.' ? root : firstExisting(root, ['dist', 'build', 'out', 'public']);
151
+ if (!dir || !existsSync(dir) || !statSync(dir).isDirectory()) throw new EdgeBuildError('edge_output_missing', `The build produced no static output folder${plan.outputDir ? ` (${plan.outputDir})` : ''}`, 'Check that the build script writes the site to that folder.');
152
+ return { main: null, assetsDir: dir, exclude: dir === root ? ['node_modules', '.git', EDGE_MANIFEST_FILE, 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'create.json'] : [], compatibilityDate: DEFAULT_COMPAT_DATE, compatibilityFlags: [], resources: {}, durableObjects: [], cacheDir: null, wrapper: null };
153
+ }
154
+ if (adapter === 'opennext') {
155
+ const main = join(root, '.open-next', 'worker.js');
156
+ if (!existsSync(main)) throw new EdgeBuildError('edge_output_missing', 'OpenNext did not produce .open-next/worker.js', 'Look at the build log above for the Next.js or OpenNext error.');
157
+ const own = findWranglerConfig(root);
158
+ const fromConfig = own ? bindingsFromConfig(own.config) : { resources: {}, durableObjects: [] };
159
+ return {
160
+ main,
161
+ assetsDir: join(root, '.open-next', 'assets'),
162
+ exclude: [],
163
+ compatibilityDate: own?.config.compatibility_date ?? DEFAULT_COMPAT_DATE,
164
+ compatibilityFlags: unique(['nodejs_compat', 'global_fetch_strictly_public', ...(own?.config.compatibility_flags ?? [])]),
165
+ // The ISR bindings are the frontend's own R2 bucket and D1 database, whatever the config names.
166
+ resources: { ...fromConfig.resources, r2: NEXT_BINDINGS.incrementalCache, d1: NEXT_BINDINGS.tagCache },
167
+ durableObjects: fromConfig.durableObjects.filter((d) => !['DOQueueHandler', 'DOShardedTagCache', 'BucketCachePurge'].includes(d.className)),
168
+ cacheDir: join(root, '.open-next', 'cache'),
169
+ wrapper: 'next',
170
+ };
171
+ }
172
+ // Every other Worker adapter: the wrangler config it generated is the source of truth when it
173
+ // exists (Cloudflare Vite plugin: React Router, Astro 6+; Nitro); otherwise its documented paths.
174
+ const cfg = findWranglerConfig(root);
175
+ if (cfg && typeof cfg.config.main === 'string') {
176
+ const base = dirname(cfg.path);
177
+ const main = resolve(base, cfg.config.main);
178
+ const assetsDir = cfg.config.assets?.directory ? resolve(base, cfg.config.assets.directory) : null;
179
+ if (!inside(root, main) || (assetsDir && !inside(root, assetsDir))) throw new EdgeBuildError('edge_output_invalid', 'The wrangler config points outside the project');
180
+ if (existsSync(main)) {
181
+ const b = bindingsFromConfig(cfg.config);
182
+ return { main, assetsDir: assetsDir && existsSync(assetsDir) ? assetsDir : null, exclude: pagesExcludes(main, assetsDir), compatibilityDate: cfg.config.compatibility_date ?? DEFAULT_COMPAT_DATE, compatibilityFlags: cfg.config.compatibility_flags ?? [], resources: b.resources, durableObjects: b.durableObjects, cacheDir: null, wrapper: null };
183
+ }
184
+ }
185
+ const known = {
186
+ 'astro-cloudflare': { main: ['dist/_worker.js/index.js', 'dist/_worker.js', 'dist/server/entry.mjs'], assets: ['dist/client', 'dist'] },
187
+ 'sveltekit-cloudflare': { main: ['.svelte-kit/cloudflare/_worker.js'], assets: ['.svelte-kit/cloudflare'] },
188
+ 'nitro-cloudflare': { main: ['.output/server/index.mjs'], assets: ['.output/public'] },
189
+ 'cloudflare-vite': { main: ['build/server/index.js', 'dist/server/index.js'], assets: ['build/client', 'dist/client'] },
190
+ }[adapter];
191
+ if (!known) throw new EdgeBuildError('edge_adapter_unknown', `No edge adapter is known for ${adapter ?? 'this project'}`, SERVER_FIX);
192
+ const mainFile = firstExisting(root, known.main);
193
+ if (!mainFile) throw new EdgeBuildError('edge_output_missing', `The ${adapter} build produced no Worker entry (looked for ${known.main.join(', ')})`, 'Check that the Cloudflare adapter is configured for the app, or deploy as a server.');
194
+ const main = statSync(mainFile).isDirectory() ? join(mainFile, 'index.js') : mainFile;
195
+ const assetsDir = firstExisting(root, known.assets);
196
+ const own = cfg ? bindingsFromConfig(cfg.config) : { resources: {}, durableObjects: [] };
197
+ return { main, assetsDir, exclude: pagesExcludes(main, assetsDir), compatibilityDate: cfg?.config.compatibility_date ?? DEFAULT_COMPAT_DATE, compatibilityFlags: unique(['nodejs_compat', ...(cfg?.config.compatibility_flags ?? [])]), resources: own.resources, durableObjects: own.durableObjects, cacheDir: null, wrapper: null };
198
+ }
199
+
200
+ /** Pages-style outputs keep the Worker and routing files inside the assets folder: never serve them. */
201
+ function pagesExcludes(main, assetsDir) {
202
+ const out = ['_worker.js', '_routes.json', '.assetsignore'];
203
+ if (assetsDir && inside(assetsDir, main)) out.push(relative(assetsDir, main).split(sep)[0]);
204
+ return unique(out);
205
+ }
206
+
207
+ const unique = (xs) => [...new Set(xs)];
208
+
209
+ // ---------------------------------------------------------------------------------------------
210
+ // Bundling the Worker
211
+ // ---------------------------------------------------------------------------------------------
212
+
213
+ /**
214
+ * The wrangler invocation that bundles `main` into ES modules in `outDir` (no upload: --dry-run). A
215
+ * generated config, so a project's own config cannot add routes, bindings or an account id.
216
+ */
217
+ export function bundleCommand(output, outDir, configPath) {
218
+ const config = { name: 'create-edge-build', main: output.main, compatibility_date: output.compatibilityDate, compatibility_flags: output.compatibilityFlags, no_bundle: false, workers_dev: false };
219
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
220
+ return { command: 'npx', args: ['--no-install', 'wrangler', 'deploy', '--dry-run', '--outdir', outDir, '--config', configPath], env: { WRANGLER_SEND_METRICS: 'false', CI: '1' } };
221
+ }
222
+
223
+ function walk(dir, rel = '') {
224
+ const out = [];
225
+ for (const name of readdirSync(dir).sort()) {
226
+ const abs = join(dir, name);
227
+ const r = rel ? `${rel}/${name}` : name;
228
+ const st = statSync(abs);
229
+ if (st.isDirectory()) out.push(...walk(abs, r));
230
+ else if (st.isFile()) out.push({ abs, rel: r, size: st.size });
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /** The bundled modules wrangler wrote: the entry (named after `main`) first, then the rest. */
236
+ export function collectModules(outDir, main) {
237
+ const files = walk(outDir).filter((f) => /\.(js|mjs|cjs|wasm)$/i.test(f.rel) && !f.rel.endsWith('.map'));
238
+ if (files.length === 0) throw new EdgeBuildError('edge_output_missing', 'Bundling produced no Worker module');
239
+ const stem = basename(main).replace(/\.[^.]+$/, '');
240
+ const entry = files.find((f) => !f.rel.includes('/') && f.rel.replace(/\.[^.]+$/, '') === stem) ?? (files.length === 1 ? files[0] : files.find((f) => !f.rel.includes('/') && /\.m?js$/.test(f.rel)));
241
+ if (!entry) throw new EdgeBuildError('edge_output_missing', `Bundling produced no entry module for ${basename(main)}`);
242
+ return [entry, ...files.filter((f) => f !== entry)];
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------------------------
246
+ // OpenNext specifics
247
+ // ---------------------------------------------------------------------------------------------
248
+
249
+ /**
250
+ * OpenNext's `computeCacheKey` (1.20.6 through 1.20.12, overrides/internal.js): `<prefix>/<buildId>/<sha256(key)>.<type>`.
251
+ */
252
+ export function nextCacheKey(key, buildId, cacheType, prefix = 'incremental-cache') {
253
+ const hash = createHash('sha256').update(key).digest('hex');
254
+ return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, '/');
255
+ }
256
+
257
+ /**
258
+ * The prerendered cache entries of an OpenNext build (`.open-next/cache`), as OpenNext's own
259
+ * `populateCache` reads them: `<buildId>/<route>.cache` → type `cache`, `__fetch/<buildId>/<key>` →
260
+ * type `fetch`. Returned with the R2 key each is stored under in the frontend's own bucket.
261
+ */
262
+ export function nextCacheEntries(cacheDir) {
263
+ if (!cacheDir || !existsSync(cacheDir)) return [];
264
+ const out = [];
265
+ for (const f of walk(cacheDir)) {
266
+ let key;
267
+ if (f.rel.startsWith('__fetch/')) {
268
+ const [, buildId, ...parts] = f.rel.split('/');
269
+ if (!buildId || parts.length === 0) throw new EdgeBuildError('edge_output_invalid', `Unexpected OpenNext cache file ${f.rel}`);
270
+ key = nextCacheKey(`/${parts.join('/')}`, buildId, 'fetch');
271
+ } else {
272
+ if (!f.rel.endsWith('.cache')) continue;
273
+ const [buildId, ...parts] = f.rel.slice(0, -'.cache'.length).split('/');
274
+ if (!buildId || parts.length === 0) throw new EdgeBuildError('edge_output_invalid', `Unexpected OpenNext cache file ${f.rel}`);
275
+ key = nextCacheKey(`/${parts.join('/')}`, buildId, 'cache');
276
+ }
277
+ if (!NEXT_CACHE_KEY.test(key)) throw new EdgeBuildError('edge_output_invalid', `OpenNext cache key ${key} has an unexpected shape`);
278
+ out.push({ key, abs: f.abs, size: f.size });
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * The entry module Create puts in front of an OpenNext Worker on Workers for Platforms:
285
+ *
286
+ * - `WORKER_SELF_REFERENCE`: OpenNext revalidates ISR pages by requesting them from itself through a
287
+ * service binding of that name; a user Worker in a dispatch namespace has none, so the wrapper hands
288
+ * it an in-process one (the same handler, a fresh invocation). Paired with OpenNext's memory queue.
289
+ * - `/_next/image`: local images are optimized through an `ASSETS` binding the dispatch model does not
290
+ * give a user Worker (Create serves static files from the dispatch Worker); the wrapper answers with
291
+ * a SAME-ORIGIN redirect to the original file, served unoptimized, and 400 for anything that would
292
+ * resolve to another origin (security review L1: no open redirect). Since 2026-10-20 the dispatch
293
+ * Worker answers `/_next/image` for a static file itself (the original, 200); this redirect remains
294
+ * for sources that are not static files and for anonymous deploys.
295
+ * - every named export (Durable Object classes) is re-exported unchanged.
296
+ * - `zlib.inflateSync`'s `maxOutputLength` is clamped to workerd's 128 MiB maximum (Next.js 16 asks for
297
+ * 500 MiB when it reads a PPR postponed state; found by the 2026-10-20 conformance run).
298
+ */
299
+ export function nextEntryWrapper(innerModule) {
300
+ const spec = `./${innerModule}`;
301
+ return `// Generated by Create's edge build (slice E2). See infra/app-cluster/builds/image/edge/pack.mjs.
302
+ import * as inner from ${JSON.stringify(spec)};
303
+ import letZlib from "node:zlib";
304
+ export * from ${JSON.stringify(spec)};
305
+ const worker = inner.default;
306
+ // workerd's node:zlib refuses maxOutputLength above 128 MiB; Next.js asks for 500 MiB when it inflates a
307
+ // partial-prerendering postponed state, so every PPR request lost its resume data cache ("Failed to
308
+ // parse postponed state", RangeError). The same zip-bomb guard, at the largest size workerd allows.
309
+ const letInflateSync = letZlib.inflateSync;
310
+ try {
311
+ letZlib.inflateSync = (buffer, options) => letInflateSync(buffer, options && options.maxOutputLength > 134217728 ? { ...options, maxOutputLength: 134217728 } : options);
312
+ } catch {}
313
+ function envWithSelf(env, ctx) {
314
+ const scoped = Object.assign({}, env);
315
+ scoped.WORKER_SELF_REFERENCE = { fetch: (input, init) => worker.fetch(new Request(input, init), scoped, ctx) };
316
+ return scoped;
317
+ }
318
+ export default {
319
+ ...worker,
320
+ async fetch(request, env, ctx) {
321
+ const url = new URL(request.url);
322
+ if (url.pathname === '/_next/image') {
323
+ // Only ever a same-origin redirect: the source is resolved the way the browser will resolve it
324
+ // (\\ is /, tabs and newlines are dropped), and anything that lands elsewhere is refused.
325
+ const src = url.searchParams.get('url');
326
+ let target = null;
327
+ try {
328
+ target = src && src.startsWith('/') ? new URL(src, url) : null;
329
+ } catch {
330
+ target = null;
331
+ }
332
+ if (!target || target.origin !== url.origin) return new Response('Bad image URL', { status: 400 });
333
+ return Response.redirect(target.toString(), 307);
334
+ }
335
+ return worker.fetch(request, envWithSelf(env, ctx), ctx);
336
+ },
337
+ };
338
+ `;
339
+ }
340
+
341
+ /**
342
+ * Next.js Cache Components on workerd: the stage timers.
343
+ *
344
+ * Next.js renders a Cache Components prerender in stages (`runInSequentialTasks`): one `setTimeout(…, 0)`
345
+ * per stage, all scheduled at once by `createAtomicTimerGroup` so that Node.js runs them back to back in
346
+ * one timers phase. Work a stage starts with `setImmediate` (React schedules its rendering that way) is
347
+ * run before the next stage by Next's "fast setImmediate" scheduler, which captures immediates until
348
+ * "all nextTicks and microtasks spawned from the current task are done" — it waits with
349
+ * `queueMicrotask(() => process.nextTick(…))`, which in Node.js runs after the microtask queue drains.
350
+ * In workerd `process.nextTick` is a plain microtask, so the scheduler stops capturing while the stage's
351
+ * promise chain (a `'use cache'` entry read from the resume data cache, then React's ping) is still
352
+ * running; React's next `setImmediate` becomes a native workerd immediate, which runs only after the
353
+ * stage timers that are already due. When a page is regenerated at request time (after `revalidateTag`
354
+ * on a prerendered page or a PPR shell) the last stage then aborts the final prerender before the page
355
+ * segment is written, the shell length promise in the payload never resolves, Next's segment collection
356
+ * waits on it for ever, and workerd cancels the request as hung (502; the isolate's next request to that
357
+ * page hangs too). Found by the 2026-10-21 conformance run (finding 4); the build-time prerender and
358
+ * `next start` are unaffected (Node.js).
359
+ *
360
+ * The fix: the edge build points the timer call inside Next's own `createAtomicTimerGroup` (found by its
361
+ * invariant message, in every copy of the server runtime in the bundle) at `__letStageTimeout`, which
362
+ * runs one group's timers in the same order, and before each next stage yields to the event loop until
363
+ * the native immediates scheduled so far have run (at least one turn, at most `NEXT_STAGE_MAX_HOPS`):
364
+ * what Next's scheduler does in Node.js. `clearTimeout` on such a timer still cancels it.
365
+ */
366
+ export const NEXT_STAGE_TIMER_ANCHOR = 'Cannot schedule more timers into a group that already executed';
367
+ export const NEXT_STAGE_MAX_HOPS = 64;
368
+ export const NEXT_STAGE_TIMERS_PRELUDE = `// Added by Let.com's edge build (pack.mjs, NEXT_STAGE_TIMERS_PRELUDE): Next.js stage timers on workerd.
369
+ (() => {
370
+ if (globalThis.__letStageTimeout) return;
371
+ const maxHops = ${NEXT_STAGE_MAX_HOPS};
372
+ const realSetTimeout = globalThis.setTimeout.bind(globalThis);
373
+ const realClearTimeout = globalThis.clearTimeout.bind(globalThis);
374
+ // Native immediates not yet run (Next's scheduler wraps this setImmediate, so it sees what Next does).
375
+ const realSetImmediate = globalThis.setImmediate;
376
+ const realClearImmediate = globalThis.clearImmediate;
377
+ const pending = new Map();
378
+ if (typeof realSetImmediate === 'function' && typeof realClearImmediate === 'function') {
379
+ const countedSetImmediate = function (callback, ...args) {
380
+ if (typeof callback !== 'function') return realSetImmediate.call(globalThis, callback, ...args);
381
+ const state = { done: false };
382
+ const handle = realSetImmediate.call(globalThis, function (...a) {
383
+ if (!state.done) { state.done = true; pending.delete(handle); }
384
+ return callback.apply(this, a);
385
+ }, ...args);
386
+ pending.set(handle, state);
387
+ return handle;
388
+ };
389
+ const custom = Symbol.for('nodejs.util.promisify.custom');
390
+ if (realSetImmediate[custom]) countedSetImmediate[custom] = realSetImmediate[custom];
391
+ globalThis.setImmediate = countedSetImmediate;
392
+ globalThis.clearImmediate = function (handle) {
393
+ const state = pending.get(handle);
394
+ if (state) { state.done = true; pending.delete(handle); }
395
+ return realClearImmediate.call(globalThis, handle);
396
+ };
397
+ }
398
+ class LetStageTimer {
399
+ constructor(callback, args) { this.callback = callback; this.args = args; this.cancelled = false; }
400
+ }
401
+ let open = null;
402
+ const runNext = (chain) => {
403
+ const timer = chain.timers.shift();
404
+ if (!timer) return;
405
+ try {
406
+ if (!timer.cancelled) { timer.cancelled = true; timer.callback(...timer.args); }
407
+ } finally {
408
+ if (chain.timers.length > 0) hop(chain, 1);
409
+ }
410
+ };
411
+ const hop = (chain, n) => realSetTimeout(() => (pending.size > 0 && n < maxHops ? hop(chain, n + 1) : runNext(chain)), 0);
412
+ globalThis.__letStageTimeout = (callback, delay, ...args) => {
413
+ const timer = new LetStageTimer(callback, args);
414
+ // The timers one group schedules in one synchronous run form one chain.
415
+ if (open === null) {
416
+ const chain = (open = { timers: [] });
417
+ queueMicrotask(() => { if (open === chain) open = null; });
418
+ realSetTimeout(() => runNext(chain), delay ?? 0);
419
+ }
420
+ open.timers.push(timer);
421
+ return timer;
422
+ };
423
+ globalThis.clearTimeout = (id) => (id instanceof LetStageTimer ? void (id.cancelled = true) : realClearTimeout(id));
424
+ })();
425
+ `;
426
+
427
+ /**
428
+ * Points Next.js's stage timers at `__letStageTimeout` in one bundled Worker module (see above).
429
+ * Returns the new text (with the prelude when anything changed), how many timer groups it found and how
430
+ * many it changed; a group it cannot change is left alone and counted.
431
+ */
432
+ export function patchNextStageTimers(text) {
433
+ // Already adjusted (a second pass): nothing to do.
434
+ if (text.includes('__letStageTimeout')) {
435
+ const anchors = text.split(NEXT_STAGE_TIMER_ANCHOR).length - 1;
436
+ return { text, anchors, patched: anchors };
437
+ }
438
+ let anchors = 0;
439
+ let patched = 0;
440
+ let out = '';
441
+ let from = 0;
442
+ for (let at = text.indexOf(NEXT_STAGE_TIMER_ANCHOR); at >= 0; at = text.indexOf(NEXT_STAGE_TIMER_ANCHOR, at + 1)) {
443
+ anchors += 1;
444
+ // The group's own setTimeout follows its invariant check within a few hundred characters.
445
+ const window = text.slice(at, at + 600);
446
+ const m = /([^\w$.])setTimeout\(/.exec(window);
447
+ if (!m) continue;
448
+ const pos = at + m.index + m[1].length;
449
+ out += text.slice(from, pos) + '(globalThis.__letStageTimeout ?? setTimeout)(';
450
+ from = pos + 'setTimeout('.length;
451
+ patched += 1;
452
+ }
453
+ if (patched === 0) return { text, anchors, patched };
454
+ return { text: NEXT_STAGE_TIMERS_PRELUDE + out + text.slice(from), anchors, patched };
455
+ }
456
+
457
+ /** Whether the Next.js build has Cache Components on (`.next/required-server-files.json`). */
458
+ export function nextCacheComponentsEnabled(root) {
459
+ const p = join(root, '.next', 'required-server-files.json');
460
+ if (!existsSync(p)) return false;
461
+ try {
462
+ const config = JSON.parse(readFileSync(p, 'utf8')).config ?? {};
463
+ return Boolean(config.cacheComponents || config.experimental?.cacheComponents || config.experimental?.ppr || config.experimental?.dynamicIO);
464
+ } catch {
465
+ return false;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * After bundling an OpenNext Worker: `patchNextStageTimers` on every bundled module. With Cache
471
+ * Components on, a timer group left unchanged would hang regenerated pages, so the build stops instead.
472
+ */
473
+ export function applyNextStageTimers(workerDir, cacheComponents) {
474
+ let anchors = 0;
475
+ let patched = 0;
476
+ for (const f of walk(workerDir)) {
477
+ if (!/\.(js|mjs|cjs)$/.test(f.rel)) continue;
478
+ const text = readFileSync(f.abs, 'utf8');
479
+ if (!text.includes(NEXT_STAGE_TIMER_ANCHOR)) continue;
480
+ const r = patchNextStageTimers(text);
481
+ anchors += r.anchors;
482
+ patched += r.patched;
483
+ if (r.patched > 0) writeFileSync(f.abs, r.text);
484
+ }
485
+ if (cacheComponents && patched < anchors) {
486
+ throw new EdgeBuildError(
487
+ 'edge_next_stage_timers',
488
+ `This Next.js version's Cache Components stage timers have a shape the edge build does not recognise (${anchors - patched} of ${anchors} not adjusted); regenerated pages would hang on the edge runtime`,
489
+ 'Deploy it as a server (target "container"), or use a Next.js version the edge build supports (see the Next.js conformance page).',
490
+ );
491
+ }
492
+ return { anchors, patched };
493
+ }
494
+
495
+ /** The open-next.config.ts Create writes when a project has none: R2 cache, D1 tag cache, memory queue. */
496
+ export const NEXT_OPEN_NEXT_CONFIG = `// Written by Create's edge build (slice E2): the frontend's own R2 bucket holds the incremental cache,
497
+ // its own D1 database the tag cache; revalidation runs in-process (see pack.mjs, nextEntryWrapper).
498
+ import { defineCloudflareConfig } from "@opennextjs/cloudflare";
499
+ import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
500
+ import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";
501
+ import memoryQueue from "@opennextjs/cloudflare/overrides/queue/memory-queue";
502
+
503
+ export default defineCloudflareConfig({
504
+ incrementalCache: r2IncrementalCache,
505
+ tagCache: d1NextTagCache,
506
+ queue: memoryQueue,
507
+ });
508
+ `;
509
+
510
+ /** `>=15.5.28 <16 || >=16.4.1`-style ranges (what OpenNext declares for next), for a plain x.y.z version. */
511
+ export function satisfiesSimpleRange(version, range) {
512
+ const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)/.exec(v) ?? []).slice(1, 4).map(Number);
513
+ const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
514
+ const v = parse(version);
515
+ if (v.length !== 3) return true;
516
+ return range.split('||').some((part) =>
517
+ part.trim().split(/\s+/).filter(Boolean).every((c) => {
518
+ const m = /^(>=|<=|>|<|=)?v?(\d+(?:\.\d+){0,2})/.exec(c);
519
+ if (!m) return true;
520
+ const t = m[2].split('.').map(Number);
521
+ while (t.length < 3) t.push(0);
522
+ const d = cmp(v, t);
523
+ return m[1] === '>=' ? d >= 0 : m[1] === '<=' ? d <= 0 : m[1] === '>' ? d > 0 : m[1] === '<' ? d < 0 : d === 0;
524
+ }),
525
+ );
526
+ }
527
+
528
+ /** The folders whose files carry Next.js route segment config (`runtime`). */
529
+ const NEXT_ROUTE_DIRS = ['app', 'src/app', 'pages', 'src/pages'];
530
+ const NEXT_ROUTE_FILE = /\.(js|jsx|mjs|ts|tsx|mts)$/;
531
+ /** App Router segment config: `export const runtime = 'edge'` (or the older 'experimental-edge'). */
532
+ const APP_EDGE_RUNTIME = /^(\s*export\s+const\s+runtime\s*(?::\s*[A-Za-z]+\s*)?=\s*)(['"`])(?:experimental-)?edge\2/gm;
533
+ /** Pages Router: `export const config = { runtime: 'edge' }` (pages use 'experimental-edge'). */
534
+ const PAGES_EDGE_RUNTIME = /(\bruntime\s*:\s*)(['"`])(?:experimental-)?edge\2/g;
535
+
536
+ /**
537
+ * Where `export const config = { … }` sits in a Pages Router file: the object literal's braces, found by
538
+ * depth (strings skipped), or null. Linear, and only the first such export counts.
539
+ */
540
+ function pagesConfigObject(text) {
541
+ const head = /export\s+const\s+config\s*(?::\s*[A-Za-z_$][\w$.<>, ]*)?=\s*\{/.exec(text);
542
+ if (!head) return null;
543
+ const start = head.index + head[0].length;
544
+ let depth = 1;
545
+ let quote = null;
546
+ for (let i = start; i < text.length; i++) {
547
+ const ch = text[i];
548
+ if (quote) {
549
+ if (ch === '\\') i += 1;
550
+ else if (ch === quote) quote = null;
551
+ continue;
552
+ }
553
+ if (ch === '"' || ch === "'" || ch === '`') quote = ch;
554
+ else if (ch === '{') depth += 1;
555
+ else if (ch === '}' && --depth === 0) return { start, end: i };
556
+ }
557
+ return null;
558
+ }
559
+
560
+ /** A Pages Router API route's default export, which the adapter below takes over. */
561
+ const DEFAULT_EXPORT = /^export\s+default\s+/m;
562
+
563
+ /**
564
+ * Appended to a Pages Router API route that was written for the Edge runtime: the Node.js API route
565
+ * signature `(req, res)` wraps the route's own `(request: NextRequest, ctx) => Response` handler — the
566
+ * request becomes a NextRequest (raw body: the body parser is turned off on the exported config), the Response (headers, every
567
+ * Set-Cookie, a streamed body) is written back to `res`. `@vercel/og` image routes are the usual case.
568
+ */
569
+ export const PAGES_EDGE_API_ADAPTER = `
570
+ // Added by Let.com's edge build: this Edge-runtime API route runs on the Node.js runtime in the same Worker.
571
+ import { NextRequest as __LetNextRequest } from 'next/server';
572
+ // The raw body for the Request (Next.js reads config.api.bodyParser at request time).
573
+ Reflect.set(config, 'api', Object.assign({}, Reflect.get(config, 'api'), { bodyParser: false }));
574
+ export default async function __letEdgeApiRoute(req, res) {
575
+ const headers = new Headers();
576
+ for (const [k, v] of Object.entries(req.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : String(v));
577
+ const proto = String(req.headers['x-forwarded-proto'] ?? 'https').split(',')[0].trim();
578
+ const url = new URL(req.url ?? '/', proto + '://' + (req.headers.host ?? 'localhost'));
579
+ const method = req.method ?? 'GET';
580
+ let body;
581
+ if (method !== 'GET' && method !== 'HEAD') {
582
+ const chunks = [];
583
+ for await (const chunk of req) chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
584
+ body = chunks.length ? new Blob(chunks) : undefined;
585
+ }
586
+ const pending = [];
587
+ const response = await __letEdgeHandler(new __LetNextRequest(url, { method, headers, body }), { waitUntil: (p) => pending.push(Promise.resolve(p).catch(() => {})) });
588
+ res.statusCode = response.status;
589
+ if (response.statusText) res.statusMessage = response.statusText;
590
+ const cookies = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : [];
591
+ response.headers.forEach((value, key) => { if (key !== 'set-cookie') res.setHeader(key, value); });
592
+ if (cookies.length) res.setHeader('set-cookie', cookies);
593
+ if (response.body && method !== 'HEAD') {
594
+ for await (const chunk of response.body) res.write(chunk);
595
+ }
596
+ res.end();
597
+ await Promise.all(pending);
598
+ }
599
+ `;
600
+
601
+ /**
602
+ * Before `next build` (an OpenNext app): routes and pages that ask for the Edge runtime are built for
603
+ * the Node.js runtime instead. OpenNext for Cloudflare bundles only Next's Node.js server into the
604
+ * Worker ("the edge runtime is not supported", @opennextjs/cloudflare bundle-server.js); an Edge-runtime
605
+ * route is compiled into `.next/server/edge/` where that server cannot load it, and answered 500 at
606
+ * request time (`Cannot read properties of undefined (reading 'default')`, observed 2026-10-03 and again
607
+ * with 1.20.12 on 2026-10-20). On Workers the Node.js runtime runs in the same V8 isolate at the same
608
+ * locations, and the Edge runtime's APIs are a subset of it, so the route behaves the same.
609
+ *
610
+ * App Router routes and pages, and Pages Router pages, keep their code (only the runtime changes). A
611
+ * Pages Router API route also changes signature with the runtime, so its default export is wrapped
612
+ * (`PAGES_EDGE_API_ADAPTER`); one whose default export is not `export default …` is left on the Edge
613
+ * runtime, and `assertNoEdgeFunctions` then refuses the build with the file named.
614
+ *
615
+ * Only the build's own copy of the source is changed. Returns the files changed (relative paths).
616
+ */
617
+ export function nextEdgeRuntimeToNode(root) {
618
+ const changed = [];
619
+ for (const dir of NEXT_ROUTE_DIRS) {
620
+ const abs = join(root, dir);
621
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) continue;
622
+ const pages = dir.endsWith('pages');
623
+ for (const f of walk(abs)) {
624
+ if (!NEXT_ROUTE_FILE.test(f.rel) || f.size > 2 * 1024 * 1024) continue;
625
+ const text = readFileSync(f.abs, 'utf8');
626
+ if (!text.includes('edge')) continue;
627
+ let next = text.replace(APP_EDGE_RUNTIME, (_m, head, q) => `${head}${q}nodejs${q}`);
628
+ const config = pages ? pagesConfigObject(next) : null;
629
+ if (config && new RegExp(PAGES_EDGE_RUNTIME.source).test(next.slice(config.start, config.end))) {
630
+ // Only the config object's own `runtime` changes (a handler may well return { runtime: 'edge' }).
631
+ const inConfig = (replacement) => next.slice(0, config.start) + next.slice(config.start, config.end).replace(new RegExp(PAGES_EDGE_RUNTIME.source), replacement) + next.slice(config.end);
632
+ if (f.rel.startsWith('api/')) {
633
+ // An API route: the handler signature changes with the runtime, so the adapter takes over.
634
+ if (DEFAULT_EXPORT.test(next)) {
635
+ next = inConfig((_m, head, q) => `${head}${q}nodejs${q}`);
636
+ next = `${next.replace(DEFAULT_EXPORT, 'const __letEdgeHandler = ')}\n;${PAGES_EDGE_API_ADAPTER}`;
637
+ // The adapter is plain JavaScript: a TypeScript route is not type-checked again (build copy only).
638
+ if (/\.(ts|tsx|mts)$/.test(f.rel)) next = `// @ts-nocheck\n${next}`;
639
+ }
640
+ } else {
641
+ next = inConfig((_m, head, q) => `${head}${q}nodejs${q}`);
642
+ }
643
+ }
644
+ if (next !== text) {
645
+ writeFileSync(f.abs, next);
646
+ changed.push(`${dir}/${f.rel}`);
647
+ }
648
+ }
649
+ }
650
+ return changed.sort();
651
+ }
652
+
653
+ /**
654
+ * After `next build`: what Next's own manifests say about the server code (the same files OpenNext reads).
655
+ *
656
+ * - `nodeMiddleware`: Node.js middleware (Next 15.2+ `runtime: 'nodejs'`) or Next 16's `proxy`. OpenNext
657
+ * 1.20.3+ bundles it into the Worker (experimental upstream; Let.com's conformance suite runs it:
658
+ * docs/apps/nextjs-compatibility.md).
659
+ * - `edgeFunctions`: pages and routes still compiled for the Edge runtime (none after
660
+ * `nextEdgeRuntimeToNode`, unless a route declares it in a form that function does not rewrite).
661
+ */
662
+ export function nextBuildFindings(root) {
663
+ const dotNext = join(root, '.next', 'server');
664
+ const read = (name) => {
665
+ const p = join(dotNext, name);
666
+ return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
667
+ };
668
+ const manifest = read('middleware-manifest.json');
669
+ const edgeMiddleware = manifest?.middleware?.['/'];
670
+ const node = read('functions-config-manifest.json')?.functions?.['/_middleware'];
671
+ const edgeFunctions = Object.values(manifest?.functions ?? {})
672
+ .map((fn) => (fn && typeof fn.page === 'string' ? fn.page : null))
673
+ .filter(Boolean)
674
+ .sort();
675
+ return { nodeMiddleware: Boolean(!edgeMiddleware && node), edgeFunctions };
676
+ }
677
+
678
+ /** An Edge-runtime route left after the rewrite would answer 500 on every request: refuse the build instead. */
679
+ export function assertNoEdgeFunctions(root) {
680
+ const { edgeFunctions } = nextBuildFindings(root);
681
+ if (edgeFunctions.length > 0) {
682
+ throw new EdgeBuildError(
683
+ 'edge_runtime_route',
684
+ `${edgeFunctions.join(', ')} ${edgeFunctions.length === 1 ? 'is' : 'are'} still built for the Edge runtime, which the Worker cannot load`,
685
+ "Remove the Edge runtime setting from those files (the Node.js runtime runs on Let.com's edge, in the same Worker), or deploy it as a server (target \"container\").",
686
+ );
687
+ }
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------------------------
691
+ // Packing
692
+ // ---------------------------------------------------------------------------------------------
693
+
694
+ const enc = new TextEncoder();
695
+
696
+ function tarHeader(name, size, type = '0') {
697
+ const h = Buffer.alloc(512, 0);
698
+ h.write(name, 0, 100, 'utf8');
699
+ h.write('0000644\0', 100, 'ascii');
700
+ h.write('0000000\0', 108, 'ascii');
701
+ h.write('0000000\0', 116, 'ascii');
702
+ h.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'ascii');
703
+ h.write('00000000000\0', 136, 'ascii');
704
+ h.write(' ', 148, 'ascii');
705
+ h.write(type, 156, 'ascii');
706
+ h.write('ustar\0', 257, 'ascii');
707
+ h.write('00', 263, 'ascii');
708
+ let sum = 0;
709
+ for (const b of h) sum += b;
710
+ h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii');
711
+ return h;
712
+ }
713
+
714
+ const padTo512 = (n) => Buffer.alloc((512 - (n % 512)) % 512, 0);
715
+
716
+ /** A deterministic ustar archive of regular files (PAX `path` records for long names), gzipped. */
717
+ export function tarGz(entries) {
718
+ const parts = [];
719
+ for (const { path, data } of entries) {
720
+ const body = Buffer.from(data);
721
+ if (Buffer.byteLength(path) > 100) {
722
+ const rec = (len) => `${len} path=${path}\n`;
723
+ let len = Buffer.byteLength(rec(0));
724
+ while (Buffer.byteLength(rec(len)) !== len) len = Buffer.byteLength(rec(len));
725
+ const pax = Buffer.from(rec(len), 'utf8');
726
+ parts.push(tarHeader('PaxHeader', pax.length, 'x'), pax, padTo512(pax.length));
727
+ parts.push(tarHeader(path.slice(-100).replace(/^\/+/, ''), body.length), body, padTo512(body.length));
728
+ } else {
729
+ parts.push(tarHeader(path, body.length), body, padTo512(body.length));
730
+ }
731
+ }
732
+ parts.push(Buffer.alloc(1024, 0));
733
+ return gzipSync(Buffer.concat(parts), { level: 9 });
734
+ }
735
+
736
+ /**
737
+ * Build the Create edge bundle from a located output and bundled modules.
738
+ *
739
+ * @param {object} input
740
+ * @param {object} input.output resolveOutput(...)
741
+ * @param {Array} input.modules collectModules(...) (empty for static)
742
+ * @param {object} input.plan { framework, spa, notFound?, maxScriptBytes }
743
+ * @returns {{ tarball: Buffer, sha256: string, manifest: object, stats: object }}
744
+ */
745
+ /**
746
+ * E5: the image sources a Next.js app declared (`images.remotePatterns` / `images.domains` in
747
+ * next.config), as `next build` recorded them in `.next/required-server-files.json`. Copied raw and
748
+ * capped; the CONTROL PLANE validates them (https only, no ports/IPs/internal names) — a build is
749
+ * never trusted to widen anything.
750
+ */
751
+ export function nextImagesConfig(root) {
752
+ const file = join(root, '.next', 'required-server-files.json');
753
+ if (!existsSync(file)) return null;
754
+ let images;
755
+ try {
756
+ images = JSON.parse(readFileSync(file, 'utf8'))?.config?.images;
757
+ } catch {
758
+ return null;
759
+ }
760
+ if (!images || typeof images !== 'object') return null;
761
+ const remotePatterns = (Array.isArray(images.remotePatterns) ? images.remotePatterns : [])
762
+ .filter((p) => typeof p === 'string' || (p && typeof p === 'object' && typeof p.hostname === 'string'))
763
+ .slice(0, 100)
764
+ .map((p) => (typeof p === 'string' ? p.slice(0, 300) : { protocol: typeof p.protocol === 'string' ? p.protocol.slice(0, 10) : undefined, hostname: p.hostname.slice(0, 300), port: typeof p.port === 'string' ? p.port.slice(0, 10) : undefined, pathname: typeof p.pathname === 'string' ? p.pathname.slice(0, 300) : undefined }));
765
+ const domains = (Array.isArray(images.domains) ? images.domains : []).filter((d) => typeof d === 'string').slice(0, 100).map((d) => d.slice(0, 300));
766
+ const qualities = Array.isArray(images.qualities) ? images.qualities.filter((q) => typeof q === 'number').slice(0, 20) : null;
767
+ if (!remotePatterns.length && !domains.length && !qualities) return null;
768
+ return { remotePatterns, domains, ...(qualities ? { qualities } : {}) };
769
+ }
770
+
771
+ /**
772
+ * The `headers()` rules of next.config, as `next build` recorded them (`.next/routes-manifest.json`),
773
+ * in the routing-rules shape (`source`, `headers`, `has`, `missing`: the same path-to-regexp dialect).
774
+ * Next.js applies them inside the Worker to everything it answers; Let.com's dispatch Worker serves
775
+ * static files (`/_next/static/*`, `public/`) before the Worker, so it applies these to those files too,
776
+ * as Vercel does. Capped; the CONTROL PLANE validates every rule (a build is never trusted).
777
+ */
778
+ export function nextStaticHeaders(root) {
779
+ const file = join(root, '.next', 'routes-manifest.json');
780
+ if (!existsSync(file)) return [];
781
+ let headers;
782
+ try {
783
+ headers = JSON.parse(readFileSync(file, 'utf8'))?.headers;
784
+ } catch {
785
+ return [];
786
+ }
787
+ if (!Array.isArray(headers)) return [];
788
+ const cond = (list) => (Array.isArray(list) ? list.filter((c) => c && typeof c.type === 'string').slice(0, 8).map((c) => ({ type: c.type, ...(typeof c.key === 'string' ? { key: c.key } : {}), ...(typeof c.value === 'string' ? { value: c.value } : {}) })) : []);
789
+ return headers
790
+ .filter((h) => h && typeof h.source === 'string' && Array.isArray(h.headers) && h.headers.length > 0 && !h.internal)
791
+ .slice(0, 256)
792
+ .map((h) => {
793
+ const has = cond(h.has);
794
+ const missing = cond(h.missing);
795
+ return {
796
+ source: h.source.slice(0, 1024),
797
+ headers: h.headers.filter((x) => x && typeof x.key === 'string' && typeof x.value === 'string').slice(0, 32).map((x) => ({ key: x.key.slice(0, 256), value: x.value.slice(0, 4096) })),
798
+ ...(has.length ? { has } : {}),
799
+ ...(missing.length ? { missing } : {}),
800
+ };
801
+ })
802
+ .filter((h) => h.headers.length > 0);
803
+ }
804
+
805
+ export function packBundle({ output, modules, plan, images = null, staticHeaders = null }) {
806
+ const entries = [];
807
+ const manifest = { version: 1, assets: 'public', compatibilityDate: output.compatibilityDate, compatibilityFlags: output.compatibilityFlags, spa: false, notFound: null };
808
+ if (plan.framework) manifest.framework = plan.framework;
809
+ if (images) manifest.images = images;
810
+ if (staticHeaders && staticHeaders.length > 0) manifest.headers = staticHeaders;
811
+ let scriptBytes = 0;
812
+ if (output.main) {
813
+ if (modules.length === 0) throw new EdgeBuildError('edge_output_missing', 'The Worker was not bundled');
814
+ const names = [];
815
+ for (const m of modules) {
816
+ const data = readFileSync(m.abs);
817
+ scriptBytes += data.length;
818
+ entries.push({ path: `worker/${m.rel}`, data });
819
+ names.push(`worker/${m.rel}`);
820
+ }
821
+ if (output.wrapper === 'next') {
822
+ const wrapper = enc.encode(nextEntryWrapper(modules[0].rel));
823
+ scriptBytes += wrapper.length;
824
+ entries.push({ path: 'worker/create-entry.mjs', data: wrapper });
825
+ manifest.main = 'worker/create-entry.mjs';
826
+ manifest.modules = names;
827
+ } else {
828
+ manifest.main = names[0];
829
+ manifest.modules = names.slice(1);
830
+ }
831
+ const r = Object.fromEntries(Object.entries(output.resources ?? {}).filter(([, v]) => v));
832
+ if (Object.keys(r).length > 0) manifest.resources = r;
833
+ if ((output.durableObjects ?? []).length > 0) manifest.durableObjects = output.durableObjects;
834
+ } else {
835
+ manifest.spa = Boolean(plan.spa);
836
+ if (plan.notFound) manifest.notFound = plan.notFound;
837
+ }
838
+ if (plan.maxScriptBytes && scriptBytes > plan.maxScriptBytes) {
839
+ throw new EdgeBuildError('edge_bundle_too_large', `The Worker is ${(scriptBytes / 1024 / 1024).toFixed(1)} MiB, over the edge limit of ${(plan.maxScriptBytes / 1024 / 1024).toFixed(1)} MiB`, SERVER_FIX);
840
+ }
841
+ let assetCount = 0;
842
+ let assetBytes = 0;
843
+ if (output.assetsDir && existsSync(output.assetsDir)) {
844
+ const excluded = new Set(output.exclude ?? []);
845
+ for (const f of walk(output.assetsDir)) {
846
+ const top = f.rel.split('/')[0];
847
+ if (excluded.has(top) || excluded.has(f.rel)) continue;
848
+ entries.push({ path: `public/${f.rel}`, data: readFileSync(f.abs) });
849
+ assetCount += 1;
850
+ assetBytes += f.size;
851
+ }
852
+ }
853
+ if (!manifest.spa && !output.main && entries.some((e) => e.path === 'public/404.html')) manifest.notFound = manifest.notFound ?? '404.html';
854
+ let cacheCount = 0;
855
+ if (output.cacheDir) {
856
+ for (const c of nextCacheEntries(output.cacheDir)) {
857
+ entries.push({ path: `cache/${c.key}`, data: readFileSync(c.abs) });
858
+ cacheCount += 1;
859
+ }
860
+ if (cacheCount > 0) manifest.cache = { r2: 'cache' };
861
+ }
862
+ if (!output.main && assetCount === 0) throw new EdgeBuildError('edge_output_missing', 'The build produced no files to serve');
863
+ entries.unshift({ path: EDGE_MANIFEST_FILE, data: enc.encode(JSON.stringify(manifest, null, 2)) });
864
+ const tarball = tarGz(entries);
865
+ return { tarball, sha256: createHash('sha256').update(tarball).digest('hex'), manifest, stats: { scriptBytes, assetCount, assetBytes, cacheCount, modules: modules.length } };
866
+ }
867
+
868
+ /** Writes the bundle and a small result file the build pod's shell reads. */
869
+ export function writeBundle(outDir, packed) {
870
+ mkdirSync(outDir, { recursive: true });
871
+ writeFileSync(join(outDir, 'bundle.tar.gz'), packed.tarball);
872
+ writeFileSync(join(outDir, 'result.json'), JSON.stringify({ sha256: packed.sha256, sizeBytes: packed.tarball.length, stats: packed.stats }));
873
+ }
874
+
875
+ export const _internal = { walk, inside, extname };