@bascik/adapter-cloudflare 1.0.0-rc.1 → 1.0.0-rc.10

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/dist/build.js CHANGED
@@ -3,13 +3,17 @@
3
3
  *
4
4
  * Cloudflare adapter build implementation.
5
5
  */
6
- import { pageAliasesFor } from "@bascik/bascik/adapter";
6
+ import { pageAliasesFor, } from "@bascik/bascik/adapter";
7
7
  import { resolve, dirname, join, relative, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { mkdir, copyFile, writeFile } from "node:fs/promises";
9
+ import { createRequire } from "node:module";
10
+ import { existsSync, realpathSync } from "node:fs";
11
+ import { mkdir, copyFile, readFile, realpath, writeFile, } from "node:fs/promises";
10
12
  import { CLOUDFLARE_COMPATIBILITY_DATE, CLOUDFLARE_COMPATIBILITY_FLAGS, PAGES_ROUTES_LIMIT, classifyNodeBuiltin, formatUnsupportedImport, } from "./compat.js";
11
- import { resolveWorkerName } from "./config.js";
13
+ import { deploymentCompatSettings, discoverWranglerConfig, generateWranglerConfig, resolveWorkerName, } from "./config.js";
14
+ import { buildHeadersFile, workerPageHeaderOverrides } from "./headers-file.js";
12
15
  import { build as esbuildBuild } from "esbuild";
16
+ import { isBascikFolderName, manifestHasComponents, } from "@bascik/bascik/runtime";
13
17
  export { pageAliasesFor };
14
18
  const withBasePath = (path, base) => {
15
19
  if (base === "/" || !base)
@@ -22,30 +26,86 @@ export const buildInvocationRoutes = (input) => {
22
26
  for (const path of input.dynamicPagePaths)
23
27
  for (const alias of pageAliasesFor(path, input.base))
24
28
  include.add(alias);
29
+ if (input.hasStaticNotFoundPage)
30
+ for (const alias of pageAliasesFor("/404", input.base))
31
+ include.add(alias);
25
32
  if (input.hasApiRoutes)
26
33
  include.add(withBasePath("/api/*", input.base));
27
34
  include.add(withBasePath("/_routes.json", input.base));
28
35
  include.add(withBasePath("/_worker.js", input.base));
29
36
  const sorted = Array.from(include).sort();
30
37
  if (sorted.length > PAGES_ROUTES_LIMIT) {
31
- return { include: [withBasePath("/*", input.base)], exclude: [], overflowed: true };
38
+ return {
39
+ include: [withBasePath("/*", input.base)],
40
+ exclude: [],
41
+ overflowed: true,
42
+ };
32
43
  }
33
44
  return { include: sorted, exclude: [], overflowed: false };
34
45
  };
46
+ /**
47
+ * Public paths a deployment reserves. Pages reads `_worker.js` and
48
+ * `_routes.json` from the public root; the Worker answers 404 for both on
49
+ * every target (runtime.ts, GENERATED_CONTROL_PATHS), so on Workers an
50
+ * authored one would deploy and never be served. The Workers variant's
51
+ * worker.js and wrangler.jsonc sit beside public/, never in it.
52
+ */
35
53
  const CONTROL_FILES = {
36
54
  pages: ["_worker.js", "_routes.json"],
37
- workers: ["wrangler.jsonc", "worker.js"],
55
+ workers: ["_worker.js", "_routes.json"],
38
56
  };
57
+ const ASSET_CONTROL_FILES = new Set(["_headers", "_redirects"]);
39
58
  export const detectControlCollisions = (publicPaths, variant) => {
40
59
  const control = new Set(CONTROL_FILES[variant] ?? []);
41
60
  const problems = [];
42
61
  for (const path of [...publicPaths].sort()) {
43
62
  if (control.has(path)) {
44
- problems.push(`[bascik] --target cloudflare-${variant}: authored file "${path}" collides with a generated control file. Rename it or remove it from directory.pages.`);
63
+ problems.push(variant === "pages"
64
+ ? `[bascik] --target cloudflare-${variant}: authored file "${path}" collides with a generated control file. Rename it or remove it from directory.pages.`
65
+ : `[bascik] --target cloudflare-${variant}: authored file "${path}" would never be served: the Worker answers 404 for it, a Cloudflare control path. Rename it or remove it from directory.pages.`);
45
66
  }
46
67
  }
47
68
  return problems;
48
69
  };
70
+ /**
71
+ * Whether the authored `assets.run_worker_first` sends every request of the
72
+ * site to the Worker first: `true`, or a list with `/*` (or the base's
73
+ * `/shop/*`) and no `!` exclusion.
74
+ */
75
+ export const runsWorkerFirst = (assets, base) => {
76
+ if (typeof assets !== "object" || assets === null)
77
+ return false;
78
+ const value = assets.run_worker_first;
79
+ if (value === true)
80
+ return true;
81
+ if (!Array.isArray(value))
82
+ return false;
83
+ if (value.some((p) => typeof p !== "string" || p.startsWith("!")))
84
+ return false;
85
+ return value.includes("/*") || value.includes(withBasePath("/*", base));
86
+ };
87
+ /**
88
+ * The wrangler/config-schema.json installed for the project, for the
89
+ * generated config's `$schema` (editor help only; Wrangler ignores it).
90
+ * Undefined when wrangler is not installed where the project resolves it.
91
+ */
92
+ const wranglerSchemaPath = (projectRoot) => {
93
+ try {
94
+ const pkg = createRequire(join(projectRoot, "package.json")).resolve("wrangler/package.json");
95
+ const schema = join(dirname(pkg), "config-schema.json");
96
+ return existsSync(schema) ? schema : undefined;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ };
102
+ const NO_COMPONENTS = "bascik-no-components";
103
+ /**
104
+ * component-host.ts's exports for a site without components: nothing to
105
+ * render. Output scoping stays in runtime.ts, since page jobs carry a scope
106
+ * without any renderable manifest.
107
+ */
108
+ const COMPONENT_HOST_STUB = "export const componentExecution = () => null;";
49
109
  export const build = async (context) => {
50
110
  const { graph, distDir, outDir, projectRoot, variant = "workers" } = context;
51
111
  if (variant !== "pages" && variant !== "workers") {
@@ -53,11 +113,30 @@ export const build = async (context) => {
53
113
  }
54
114
  const publicDir = join(outDir, "public");
55
115
  await mkdir(publicDir, { recursive: true });
116
+ // Discover authored user config and resolve worker name in priority order:
117
+ // 1. Environment variables (CLOUDFLARE_WORKER_NAME, WORKER_NAME, CF_PAGES_PROJECT_NAME)
118
+ // 2. Authored wrangler.jsonc / wrangler.json / wrangler.toml in project root
119
+ // 3. Project package.json "name"
120
+ // 4. Fallback to "bascik-site"
121
+ // The config is discovered even when the name comes from the
122
+ // environment: its other keys are carried into the generated config.
123
+ const workerConfig = variant === "workers"
124
+ ? await resolveWorkerName(projectRoot, await discoverWranglerConfig(projectRoot))
125
+ : undefined;
126
+ // The author asked for the Worker before every static file: it sends the
127
+ // static files' headers itself, so nothing is written into _headers for
128
+ // them (Cloudflare's rule and line limits no longer apply).
129
+ const workerFirst = variant === "workers" &&
130
+ runsWorkerFirst(workerConfig?.discoveredConfig?.values?.assets, graph.base);
56
131
  const collisions = detectControlCollisions(graph.publicFiles, variant);
57
132
  if (collisions.length)
58
133
  throw new Error(collisions.join("\n"));
134
+ // esbuild names importers by their real path: a project reached through
135
+ // a symlinked folder (macOS tmpdir, a linked workspace) is keyed and
136
+ // reported by it too, or no import chain would find its owner.
137
+ const realRoot = await realpath(projectRoot).catch(() => projectRoot);
59
138
  const owners = new Map();
60
- const addOwner = (file, owner) => {
139
+ const addOwnerAt = (file, owner) => {
61
140
  let set = owners.get(file);
62
141
  if (!set) {
63
142
  set = new Set();
@@ -65,28 +144,72 @@ export const build = async (context) => {
65
144
  }
66
145
  set.add(owner);
67
146
  };
147
+ const addOwner = (file, owner) => {
148
+ addOwnerAt(file, owner);
149
+ let real = file;
150
+ try {
151
+ real = realpathSync(file);
152
+ }
153
+ catch {
154
+ // Not on disk (yet): only the spelling the graph gave is known.
155
+ }
156
+ if (real !== file)
157
+ addOwnerAt(real, owner);
158
+ };
159
+ // A dynamic /404 page already answers 404 from the worker. A static one is
160
+ // served by the asset layer, which answers 200 at its own URL.
161
+ const hasStaticNotFoundPage = graph.publicFiles.includes("404.html") && graph.pages["/404"] === undefined;
68
162
  const here = fileURLToPath(import.meta.url);
69
163
  const runtimeFile = resolve(here, `../runtime${here.endsWith(".ts") ? ".ts" : ".js"}`);
70
164
  const lines = [];
71
165
  lines.push(`import { createCloudflareWorker } from ${JSON.stringify(runtimeFile)};`);
166
+ const specifierOf = (job) => job.source.kind === "module" ? job.source.path : job.source.stagedPath;
167
+ // `outputScope` and `owner` are written only when given, so a site
168
+ // without component scripts emits the entry it always did. The owner goes
169
+ // with component jobs only: the Worker's log of a failing one names the
170
+ // component file from it, since the bundled stack never does.
171
+ const jobLiteral = (id, mode, outputScope, specifier, owner) =>
172
+ // codeql[js/bad-code-sanitization] Emits a standalone JS module (never inline HTML), and JSON.stringify yields valid JS string literals.
173
+ `${JSON.stringify(id)}: { id: ${JSON.stringify(id)}, mode: ${JSON.stringify(mode)}, ${outputScope ? `outputScope: ${JSON.stringify(outputScope)}, ` : ""}${owner ? `owner: ${JSON.stringify(owner)}, ` : ""}load: () => import(${JSON.stringify(specifier)}) }`;
174
+ // Request-time components (prompt 157): the manifest and the component
175
+ // scripts it names, emitted only when the manifest has a component the
176
+ // renderer can expand (the runtime's own predicate, so the bundle never
177
+ // carries a renderer the Worker would never call). A static site, or one
178
+ // whose components are all unsupported, keeps its entry byte for byte.
179
+ // The scripts are owned by their component file, so an unsupported import
180
+ // is reported there.
181
+ const components = manifestHasComponents(graph.components?.manifest)
182
+ ? graph.components
183
+ : undefined;
184
+ let manifestLiteral;
185
+ if (components) {
186
+ const manifestScopes = new Map();
187
+ for (const entry of Object.values(components.manifest.components))
188
+ for (const job of entry.jobs)
189
+ manifestScopes.set(job.id, job.outputScope);
190
+ const componentJobLiterals = [];
191
+ for (const [id, job] of Object.entries(components.jobs)) {
192
+ const specifier = specifierOf(job);
193
+ addOwner(specifier, job.owner);
194
+ componentJobLiterals.push(jobLiteral(id, job.mode, job.outputScope ?? manifestScopes.get(id), specifier, job.owner));
195
+ }
196
+ manifestLiteral = JSON.stringify(components.manifest);
197
+ lines.push(`const componentManifest = ${manifestLiteral};`);
198
+ lines.push(`const componentJobs = { ${componentJobLiterals.join(",\n ")} };`);
199
+ }
72
200
  const pageLiterals = [];
73
- for (const [httpPath, page] of Object.entries(graph.pages).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
201
+ for (const [httpPath, page] of Object.entries(graph.pages).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
74
202
  const jobLiterals = [];
75
203
  for (const [id, job] of Object.entries(page.jobs)) {
76
- let specifier;
77
- if (job.source.kind === "module") {
78
- specifier = job.source.path;
79
- }
80
- else {
81
- specifier = job.source.stagedPath;
82
- }
204
+ const specifier = specifierOf(job);
83
205
  addOwner(specifier, job.owner);
84
- jobLiterals.push(`${JSON.stringify(id)}: { id: ${JSON.stringify(id)}, mode: ${JSON.stringify(job.mode)}, load: () => import(${JSON.stringify(specifier)}) }`);
206
+ jobLiterals.push(jobLiteral(id, job.mode, job.outputScope, specifier));
85
207
  }
86
208
  pageLiterals.push(`${JSON.stringify(httpPath)}: { path: ${JSON.stringify(httpPath)}, is404: ${page.is404}, segments: ${JSON.stringify(page.segments)}, jobs: { ${jobLiterals.join(", ")} } }`);
87
209
  }
88
210
  const routeLiterals = graph.apiRoutes.map((r) => {
89
211
  addOwner(r.filePath, `API route ${r.path}`);
212
+ // codeql[js/bad-code-sanitization] Emits a standalone JS module (never inline HTML), and JSON.stringify yields valid JS string literals.
90
213
  return `{ path: ${JSON.stringify(r.path)}, filePath: ${JSON.stringify(relative(projectRoot, r.filePath).replace(/\\/g, "/"))}, paramNames: ${JSON.stringify(r.paramNames)}, isDynamic: ${r.isDynamic}, load: () => import(${JSON.stringify(r.filePath)}) }`;
91
214
  });
92
215
  lines.push(`const graph = {`);
@@ -97,129 +220,320 @@ export const build = async (context) => {
97
220
  lines.push(` release: ${JSON.stringify(graph.release)},`);
98
221
  if (graph.custom500 !== undefined)
99
222
  lines.push(` custom500: ${JSON.stringify(graph.custom500)},`);
223
+ if (hasStaticNotFoundPage)
224
+ lines.push(` staticNotFoundPage: true,`);
100
225
  lines.push(` pages: { ${pageLiterals.join(",\n ")} },`);
101
226
  lines.push(` apiRoutes: [ ${routeLiterals.join(",\n ")} ],`);
227
+ if (components)
228
+ lines.push(` components: { manifest: componentManifest, jobs: componentJobs },`);
229
+ // Opt-in contentSecurityPolicy: the policy and the hashes of the pages
230
+ // the Worker renders (static pages get _headers rules below, or, when the
231
+ // Worker runs first, theirs too).
232
+ const csp = graph.contentSecurityPolicy;
233
+ if (csp) {
234
+ const workerHashes = {};
235
+ const paths = [
236
+ ...Object.keys(graph.pages),
237
+ ...(workerFirst ? csp.staticRoutes : []),
238
+ ];
239
+ for (const path of paths)
240
+ if (csp.hashes[path])
241
+ workerHashes[path] = csp.hashes[path];
242
+ lines.push(` contentSecurityPolicy: { policy: ${JSON.stringify(csp.policy)}, hashes: ${JSON.stringify(workerHashes)} },`);
243
+ }
244
+ if (workerFirst)
245
+ lines.push(` assetHeaders: { cspRoutes: ${JSON.stringify(csp ? csp.staticRoutes : [])} },`);
246
+ // A static 404.html's policy goes into an exact-path _headers rule, which
247
+ // the asset layer matches on the request path: a miss for any other URL
248
+ // is answered with the page but without its policy. Only a Worker in front
249
+ // of every static file can send it there (runtime.ts, withAssetHeaders).
250
+ const staticNotFoundRoutes = (csp?.staticRoutes ?? []).filter((route) => route === "/404" || route.endsWith("/404"));
251
+ if (csp && !workerFirst && staticNotFoundRoutes.length)
252
+ context.log(`[bascik] --target cloudflare-${variant}: the Content-Security-Policy of the static 404 page ` +
253
+ `(${staticNotFoundRoutes.map((route) => withBasePath(route, graph.base)).join(", ")}) is sent at its own URL only. ` +
254
+ `Cloudflare's _headers rules match the request path, not the response status, so a 404 for any other URL ` +
255
+ `is served with the page but without its policy. ` +
256
+ (variant === "workers"
257
+ ? `Set "assets": { "run_worker_first": true } in your wrangler config to have the Worker send it on every 404.`
258
+ : `Cloudflare Pages cannot run the Worker before static files: build with --target cloudflare-workers and ` +
259
+ `"assets": { "run_worker_first": true } to send it on every 404.`));
260
+ // The build's security headers, dotfile rule, ANSI handling and render
261
+ // limits (Collin 2026-10-29): the Worker has no config of its own, so it
262
+ // applies what the build resolved. A graph without them is today's Worker.
263
+ // codeql[js/bad-code-sanitization] Emits a standalone JS module (never inline HTML), and JSON.stringify yields valid JS literals.
264
+ if (graph.responseSecurity !== undefined)
265
+ lines.push(` responseSecurity: ${JSON.stringify(graph.responseSecurity)},`);
266
+ if (graph.hideDotfiles !== undefined)
267
+ lines.push(` hideDotfiles: ${graph.hideDotfiles === false ? "false" : "true"},`);
268
+ if (graph.stripAnsi !== undefined)
269
+ lines.push(` stripAnsi: ${graph.stripAnsi === false ? "false" : "true"},`);
270
+ if (graph.renderLimits !== undefined)
271
+ lines.push(` renderLimits: ${JSON.stringify(graph.renderLimits)},`);
102
272
  lines.push(`};`);
103
273
  lines.push(`export default createCloudflareWorker(graph);`);
274
+ const invocationRoutes = buildInvocationRoutes({
275
+ base: graph.base,
276
+ dynamicPagePaths: Object.keys(graph.pages),
277
+ hasApiRoutes: graph.apiRoutes.length > 0,
278
+ hasStaticNotFoundPage,
279
+ });
280
+ // The Workers variant's config is generated before bundling: node:*
281
+ // imports are checked against the compatibility date and flags it deploys
282
+ // with (the authored ones when set), not only the adapter's pinned date.
283
+ // Pages takes them from the Pages project's settings, which the adapter
284
+ // cannot read: the pinned ones are what the docs tell you to set there.
285
+ const pinned = {
286
+ compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE,
287
+ compatibilityFlags: CLOUDFLARE_COMPATIBILITY_FLAGS,
288
+ };
289
+ let wrangler;
290
+ let deploymentCompat = [pinned];
291
+ if (variant === "workers") {
292
+ const { workerName, discoveredConfig } = workerConfig ?? (await resolveWorkerName(projectRoot));
293
+ // The authored config is carried over whole: the adapter owns only the
294
+ // keys it must (config.ts, generateWranglerConfig).
295
+ const generated = generateWranglerConfig({
296
+ authored: discoveredConfig,
297
+ projectRoot,
298
+ outDir,
299
+ workerName,
300
+ runWorkerFirst: workerFirst ? true : invocationRoutes.include,
301
+ schemaPath: wranglerSchemaPath(projectRoot),
302
+ compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE,
303
+ compatibilityFlags: CLOUDFLARE_COMPATIBILITY_FLAGS,
304
+ });
305
+ wrangler = generated.config;
306
+ for (const warning of generated.warnings)
307
+ context.log(warning);
308
+ deploymentCompat = deploymentCompatSettings(wrangler, discoveredConfig?.filename, pinned);
309
+ }
104
310
  const stagingDir = join(outDir, ".staging");
105
311
  await mkdir(stagingDir, { recursive: true });
106
312
  const entryPath = join(stagingDir, "worker-entry.mjs");
107
313
  await writeFile(entryPath, lines.join("\n"), "utf8");
108
314
  // Bundle with esbuild owned by this adapter
109
315
  const unsupported = [];
110
- const result = await esbuildBuild({
111
- entryPoints: [entryPath],
112
- bundle: true,
113
- write: false,
114
- format: "esm",
115
- platform: "neutral",
116
- target: "es2022",
117
- mainFields: ["workerd", "worker", "browser", "module", "main"],
118
- conditions: ["workerd", "worker", "browser", "import", "module", "default"],
119
- logLevel: "silent",
120
- metafile: true,
121
- absWorkingDir: projectRoot,
122
- plugins: [
123
- {
124
- name: "bascik-request-time-graph",
125
- setup(buildInstance) {
126
- buildInstance.onResolve({ filter: /^@\// }, (args) => ({
127
- errors: [
128
- {
129
- text: `"${args.path}" cannot be resolved from a module file. The @/ alias is available inside ` +
130
- `<script> blocks only; in ${relative(projectRoot, args.importer).replace(/\\/g, "/")} use a relative import.`,
131
- },
132
- ],
133
- }));
134
- buildInstance.onResolve({ filter: /^file:/ }, (args) => {
135
- const resolved = fileURLToPath(args.path);
136
- for (const owner of owners.get(args.importer) ?? [])
137
- addOwner(resolved, owner);
138
- return { path: resolved };
139
- });
140
- buildInstance.onResolve({ filter: /^\.\.?\// }, (args) => {
141
- const resolved = resolve(dirname(args.importer), args.path);
142
- for (const owner of owners.get(args.importer) ?? [])
143
- addOwner(resolved, owner);
144
- return undefined;
145
- });
146
- buildInstance.onResolve({ filter: /^(node:)?[a-z_/]+$/ }, (args) => {
147
- const kind = classifyNodeBuiltin(args.path);
148
- if (kind === "not-builtin")
316
+ // esbuild rejects when a build has errors (a missing import, a syntax
317
+ // error past the core's checks): its own message names only the staging
318
+ // path, so the errors are formatted here with the authored owner.
319
+ const bundlingFailed = (errors) => new Error(`[bascik] --target cloudflare-${variant}: bundling failed:\n${errors
320
+ .map((e) => {
321
+ const at = e.location
322
+ ? `${e.location.file}:${e.location.line}:${e.location.column}: `
323
+ : "";
324
+ const owned = e.location
325
+ ? owners.get(resolve(projectRoot, e.location.file))
326
+ : undefined;
327
+ const from = owned?.size
328
+ ? `\n Reached from: ${[...owned].join(", ")}`
329
+ : "";
330
+ return ` ${at}${e.text}${from}`;
331
+ })
332
+ .join("\n")}`);
333
+ let result;
334
+ try {
335
+ result = await esbuildBuild({
336
+ entryPoints: [entryPath],
337
+ bundle: true,
338
+ write: false,
339
+ format: "esm",
340
+ platform: "neutral",
341
+ target: "es2022",
342
+ mainFields: ["workerd", "worker", "browser", "module", "main"],
343
+ conditions: [
344
+ "workerd",
345
+ "worker",
346
+ "browser",
347
+ "import",
348
+ "module",
349
+ "default",
350
+ ],
351
+ logLevel: "silent",
352
+ metafile: true,
353
+ absWorkingDir: projectRoot,
354
+ plugins: [
355
+ {
356
+ name: "bascik-request-time-graph",
357
+ setup(buildInstance) {
358
+ // A site whose manifest has nothing to render never renders a
359
+ // component, so its Worker gets a stub in place of the component
360
+ // host (prompt 157): no renderer and no `node:crypto` in its
361
+ // bundle. (Not byte for byte the bundle before prompt 157:
362
+ // runtime.ts itself changed.)
363
+ if (!components) {
364
+ buildInstance.onResolve({ filter: /^\.\/component-host\.js$/ }, (args) => args.importer === runtimeFile
365
+ ? { path: "component-host", namespace: NO_COMPONENTS }
366
+ : undefined);
367
+ buildInstance.onLoad({ filter: /.*/, namespace: NO_COMPONENTS }, () => ({ contents: COMPONENT_HOST_STUB, loader: "js" }));
368
+ }
369
+ buildInstance.onResolve({ filter: /^@\// }, (args) => ({
370
+ errors: [
371
+ {
372
+ text: `"${args.path}" cannot be resolved from a module file. The @/ alias is available inside ` +
373
+ `<script> blocks only; in ${relative(realRoot, args.importer).replace(/\\/g, "/")} use a relative import.`,
374
+ },
375
+ ],
376
+ }));
377
+ buildInstance.onResolve({ filter: /^file:/ }, (args) => {
378
+ const resolved = fileURLToPath(args.path);
379
+ for (const owner of owners.get(args.importer) ?? [])
380
+ addOwner(resolved, owner);
381
+ return { path: resolved };
382
+ });
383
+ buildInstance.onResolve({ filter: /^\.\.?\// }, (args) => {
384
+ const resolved = resolve(dirname(args.importer), args.path);
385
+ for (const owner of owners.get(args.importer) ?? [])
386
+ addOwner(resolved, owner);
149
387
  return undefined;
150
- if (kind === "provided")
151
- return { path: args.path.startsWith("node:") ? args.path : `node:${args.path}`, external: true };
152
- const ownerList = [...(owners.get(args.importer) ?? [])];
153
- unsupported.push(formatUnsupportedImport({ specifier: args.path, importer: args.importer, projectRoot, owners: ownerList }));
154
- return { path: args.path, external: true };
155
- });
388
+ });
389
+ // Digits too: `node:http2` and `v8` are builtins, and esbuild's
390
+ // own error for them would suggest `platform: "node"`.
391
+ buildInstance.onResolve({ filter: /^(node:)?[a-z0-9_/]+$/ }, (args) => {
392
+ if (classifyNodeBuiltin(args.path) === "not-builtin")
393
+ return undefined;
394
+ // Every deployment of the config (the top level and each
395
+ // env.<name>) must provide it.
396
+ const missingIn = deploymentCompat.find((compat) => classifyNodeBuiltin(args.path, compat) !== "provided");
397
+ if (!missingIn)
398
+ return {
399
+ path: args.path.startsWith("node:")
400
+ ? args.path
401
+ : `node:${args.path}`,
402
+ external: true,
403
+ };
404
+ const ownerList = [...(owners.get(args.importer) ?? [])];
405
+ unsupported.push(formatUnsupportedImport({
406
+ specifier: args.path,
407
+ importer: args.importer,
408
+ projectRoot: realRoot,
409
+ owners: ownerList,
410
+ compat: missingIn,
411
+ }));
412
+ return { path: args.path, external: true };
413
+ });
414
+ },
156
415
  },
157
- },
158
- ],
159
- });
416
+ ],
417
+ });
418
+ }
419
+ catch (err) {
420
+ // A failure to bundle (not a crash of esbuild itself) carries its errors.
421
+ const errors = err.errors;
422
+ if (!Array.isArray(errors) || !errors.length)
423
+ throw err;
424
+ // An unsupported builtin found on the way is reported too.
425
+ throw new Error([...new Set(unsupported), bundlingFailed(errors).message].join("\n"));
426
+ }
160
427
  if (unsupported.length)
161
428
  throw new Error([...new Set(unsupported)].join("\n"));
162
- if (result.errors.length) {
163
- throw new Error(`[bascik] --target cloudflare-${variant}: bundling failed:\n${result.errors.map((e) => ` ${e.location?.file ? `${e.location.file}: ` : ""}${e.text}`).join("\n")}`);
164
- }
429
+ if (result.errors.length)
430
+ throw bundlingFailed(result.errors);
165
431
  const workerCode = result.outputFiles?.[0]?.text ?? "";
166
- // Copy public files
432
+ // Copy public files. The asset layer maps a request URL straight to a file
433
+ // under public/, and every URL of a non-root base site carries the prefix,
434
+ // so assets are staged below it. Control files stay at the public root,
435
+ // where the platform reads them.
436
+ const assetRoot = graph.base && graph.base !== "/"
437
+ ? join(publicDir, ...graph.base.split("/").filter(Boolean))
438
+ : publicDir;
167
439
  for (const rel of graph.publicFiles) {
440
+ // Bascik's private build folder is never deployed, whatever the graph
441
+ // lists and however a case-insensitive filesystem spells it.
442
+ if (isBascikFolderName(rel.split("/")[0] ?? ""))
443
+ continue;
168
444
  const from = join(distDir, rel);
169
- const to = join(publicDir, rel);
445
+ const to = join(ASSET_CONTROL_FILES.has(rel) ? publicDir : assetRoot, rel);
170
446
  if (!resolve(to).startsWith(publicDir + sep))
171
447
  continue;
172
448
  await mkdir(dirname(to), { recursive: true });
173
449
  await copyFile(from, to);
174
450
  }
175
- const invocationRoutes = buildInvocationRoutes({
176
- base: graph.base,
177
- dynamicPagePaths: Object.keys(graph.pages),
178
- hasApiRoutes: graph.apiRoutes.length > 0,
179
- });
451
+ // Static files are served without the Worker: the security headers (one
452
+ // site-wide rule) and each static page's CSP header go into
453
+ // public/_headers, merged with the author's own rules (headers-file.ts).
454
+ // When the Worker runs first it sends them, and the file stays the author's.
455
+ {
456
+ const headersPath = join(publicDir, "_headers");
457
+ let authored = "";
458
+ try {
459
+ authored = await readFile(headersPath, "utf8");
460
+ }
461
+ catch {
462
+ authored = "";
463
+ }
464
+ if (!workerFirst) {
465
+ const text = buildHeadersFile({
466
+ authored,
467
+ security: graph.responseSecurity,
468
+ base: graph.base,
469
+ csp: csp
470
+ ? {
471
+ policy: csp.policy,
472
+ hashes: csp.hashes,
473
+ staticRoutes: csp.staticRoutes,
474
+ }
475
+ : undefined,
476
+ variant,
477
+ });
478
+ if (text !== "" && text !== authored)
479
+ await writeFile(headersPath, text, "utf8");
480
+ }
481
+ // _headers reaches static files only: say so where an author's rule
482
+ // would change a security header on a page the Worker renders.
483
+ for (const warning of workerPageHeaderOverrides({
484
+ authored,
485
+ security: graph.responseSecurity,
486
+ base: graph.base,
487
+ workerPages: Object.keys(graph.pages),
488
+ }))
489
+ context.log(warning);
490
+ }
180
491
  let workerPath;
181
492
  if (variant === "pages") {
182
493
  workerPath = join(publicDir, "_worker.js");
183
494
  await writeFile(workerPath, workerCode, "utf8");
184
- await writeFile(join(publicDir, "_routes.json"), JSON.stringify({ version: 1, include: invocationRoutes.include, exclude: invocationRoutes.exclude }, null, 2), "utf8");
495
+ await writeFile(join(publicDir, "_routes.json"), JSON.stringify({
496
+ version: 1,
497
+ include: invocationRoutes.include,
498
+ exclude: invocationRoutes.exclude,
499
+ }, null, 2), "utf8");
185
500
  }
186
501
  else {
187
502
  workerPath = join(outDir, "worker.js");
188
503
  await writeFile(workerPath, workerCode, "utf8");
189
- // Discover authored user config and resolve worker name in priority order:
190
- // 1. Environment variables (CLOUDFLARE_WORKER_NAME, WORKER_NAME, CF_PAGES_PROJECT_NAME)
191
- // 2. Authored wrangler.jsonc / wrangler.json / wrangler.toml in project root
192
- // 3. Project package.json "name"
193
- // 4. Fallback to "bascik-site"
194
- const { workerName, discoveredConfig } = await resolveWorkerName(projectRoot);
195
- const authoredFlags = Array.isArray(discoveredConfig?.values?.compatibility_flags)
196
- ? discoveredConfig.values.compatibility_flags.filter((f) => typeof f === "string" && f.trim().length > 0)
197
- : [];
198
- const wrangler = {
199
- $schema: "node_modules/wrangler/config-schema.json",
200
- name: workerName,
201
- main: "worker.js",
202
- compatibility_date: discoveredConfig?.values?.compatibility_date ?? CLOUDFLARE_COMPATIBILITY_DATE,
203
- compatibility_flags: [...new Set([...authoredFlags, ...CLOUDFLARE_COMPATIBILITY_FLAGS])],
204
- assets: {
205
- directory: "./public",
206
- binding: "ASSETS",
207
- not_found_handling: "404-page",
208
- run_worker_first: invocationRoutes.include,
209
- },
210
- };
211
504
  await writeFile(join(outDir, "wrangler.jsonc"), `${JSON.stringify(wrangler, null, 2)}\n`, "utf8");
212
505
  }
213
506
  const bundleBytes = Buffer.byteLength(workerCode, "utf8");
507
+ // What the Worker deploys with: the generated config's top level for
508
+ // Workers (tools such as e2e/fixture/serve.ts read these notes), the
509
+ // pinned settings Pages projects are told to use.
510
+ const notes = [
511
+ `compatibility date: ${deploymentCompat[0].compatibilityDate}`,
512
+ `compatibility flags: ${deploymentCompat[0].compatibilityFlags.join(", ")}`,
513
+ workerFirst
514
+ ? "invocation routes: every request (run_worker_first: true); the Worker sends the static files' headers"
515
+ : `invocation routes: ${invocationRoutes.include.length} rules`,
516
+ ];
517
+ // Pages reads the compatibility date and flags from the project, not the
518
+ // upload, and `wrangler pages deploy` has no option for them: without
519
+ // nodejs_compat, every page routed to the Worker fails at request time.
520
+ if (variant === "pages")
521
+ notes.push(`Pages project settings: compatibility date ${CLOUDFLARE_COMPATIBILITY_DATE} or later and the ` +
522
+ `${CLOUDFLARE_COMPATIBILITY_FLAGS.join(", ")} flag, for Production and Preview. wrangler pages deploy ` +
523
+ `does not set them: set them in the Pages project's settings in the Cloudflare dashboard before the first deploy.`);
524
+ // The manifest is part of the bundle; its share of bundleBytes is shown so
525
+ // a growing component library is visible against the Worker size limit.
526
+ if (components && manifestLiteral !== undefined) {
527
+ const count = Object.keys(components.manifest.components).length;
528
+ notes.push(`component manifest: ${Buffer.byteLength(manifestLiteral, "utf8")} bytes (${count} ${count === 1 ? "component" : "components"})`);
529
+ }
214
530
  return {
215
531
  workerPath,
216
532
  publicDir,
217
533
  bundleBytes,
218
- notes: [
219
- `compatibility date: ${CLOUDFLARE_COMPATIBILITY_DATE}`,
220
- `compatibility flags: ${CLOUDFLARE_COMPATIBILITY_FLAGS.join(", ")}`,
221
- `invocation routes: ${invocationRoutes.include.length} rules`,
222
- ],
534
+ notes,
535
+ // The runtime renders component tags in script output (prompt 157).
536
+ components: true,
223
537
  };
224
538
  };
225
539
  //# sourceMappingURL=build.js.map