@bascik/adapter-cloudflare 1.0.0-rc.1 → 1.0.0-rc.11
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/LICENSE +18 -92
- package/README.md +75 -4
- package/dist/build.d.ts +8 -0
- package/dist/build.js +416 -102
- package/dist/build.js.map +1 -1
- package/dist/compat.d.ts +32 -1
- package/dist/compat.js +63 -6
- package/dist/compat.js.map +1 -1
- package/dist/component-host.d.ts +16 -0
- package/dist/component-host.js +113 -0
- package/dist/component-host.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +301 -16
- package/dist/config.js.map +1 -1
- package/dist/headers-file.d.ts +83 -0
- package/dist/headers-file.js +298 -0
- package/dist/headers-file.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +32 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +84 -10
- package/dist/runtime.js +288 -53
- package/dist/runtime.js.map +1 -1
- package/package.json +17 -11
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 {
|
|
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 {
|
|
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: ["
|
|
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(
|
|
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
|
|
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]) =>
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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({
|
|
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
|
-
|
|
220
|
-
|
|
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
|