@somewhere-tech/cli 0.28.5 → 0.29.1

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.
Files changed (59) hide show
  1. package/README.md +58 -13
  2. package/dist/commands/deploy.js +33 -8
  3. package/dist/commands/deploy.js.map +1 -1
  4. package/dist/commands/dev.js +298 -43
  5. package/dist/commands/dev.js.map +1 -1
  6. package/dist/commands/env.js +1 -1
  7. package/dist/commands/env.js.map +1 -1
  8. package/dist/commands/init.js +1 -1
  9. package/dist/commands/init.js.map +1 -1
  10. package/dist/commands/project.js +2 -1
  11. package/dist/commands/project.js.map +1 -1
  12. package/dist/commands/promote.js +11 -12
  13. package/dist/commands/promote.js.map +1 -1
  14. package/dist/commands/rollback.js +1 -1
  15. package/dist/commands/rollback.js.map +1 -1
  16. package/dist/commands/status.js +2 -2
  17. package/dist/commands/status.js.map +1 -1
  18. package/dist/lib/envfile-write.js +1 -1
  19. package/dist/lib/envfile-write.js.map +1 -1
  20. package/dist/lib/output.js +2 -2
  21. package/dist/lib/output.js.map +1 -1
  22. package/dist/local/compiler-core.js +24 -0
  23. package/dist/local/compiler-core.js.map +1 -0
  24. package/dist/local/compiler.js +444 -0
  25. package/dist/local/compiler.js.map +1 -0
  26. package/dist/local/dev-server.js +487 -0
  27. package/dist/local/dev-server.js.map +1 -0
  28. package/dist/local/loader.js +70 -9
  29. package/dist/local/loader.js.map +1 -1
  30. package/dist/local/loopback.js +54 -0
  31. package/dist/local/loopback.js.map +1 -0
  32. package/dist/local/runtime.js +1 -1
  33. package/dist/local/runtime.js.map +1 -1
  34. package/dist/local/server.js +12 -22
  35. package/dist/local/server.js.map +1 -1
  36. package/node_modules/esbuild-wasm/LICENSE.md +21 -0
  37. package/node_modules/esbuild-wasm/README.md +3 -0
  38. package/node_modules/esbuild-wasm/bin/esbuild +91 -0
  39. package/node_modules/esbuild-wasm/esbuild.wasm +0 -0
  40. package/node_modules/esbuild-wasm/esm/browser.d.ts +705 -0
  41. package/node_modules/esbuild-wasm/esm/browser.js +2393 -0
  42. package/node_modules/esbuild-wasm/esm/browser.min.js +20 -0
  43. package/node_modules/esbuild-wasm/lib/browser.d.ts +705 -0
  44. package/node_modules/esbuild-wasm/lib/browser.js +2438 -0
  45. package/node_modules/esbuild-wasm/lib/browser.min.js +22 -0
  46. package/node_modules/esbuild-wasm/lib/main.d.ts +705 -0
  47. package/node_modules/esbuild-wasm/lib/main.js +2051 -0
  48. package/node_modules/esbuild-wasm/package.json +19 -0
  49. package/node_modules/esbuild-wasm/wasm_exec.js +561 -0
  50. package/node_modules/esbuild-wasm/wasm_exec_node.js +39 -0
  51. package/npm-shrinkwrap.json +17 -2
  52. package/package.json +7 -5
  53. package/runtime/VENDOR.json +7 -0
  54. package/runtime/compiler/VENDOR.json +27 -0
  55. package/runtime/compiler/compile-core.cjs +1775 -0
  56. package/runtime/compiler/graph-contract.cjs +124 -0
  57. package/runtime/compiler/typed-functions.cjs +545 -0
  58. package/runtime/platform-context.mjs +5422 -1802
  59. package/runtime/sw-init.mjs +51 -5
@@ -0,0 +1,1775 @@
1
+ /**
2
+ * compile-core — the somewhere.tech compiler, as a host-parameterized module.
3
+ *
4
+ * This file IS the compiler: source validation, the esbuild project/function/
5
+ * transform builds, the svg + url-asset plugins, Tailwind v3/v4 via real
6
+ * PostCSS, import.meta.env VITE_* / process.env.REACT_APP_* defines, tsconfig
7
+ * path aliases, dependency resolution semantics, phantom-import detection,
8
+ * dependency review, and the artifact manifest. It was extracted verbatim from
9
+ * containers/compile/server.js so that BOTH callers run one implementation:
10
+ *
11
+ * - the compile container (server.js) — deploy, over HTTP;
12
+ * - the CLI's `somewhere dev` local loop — which vendors this file.
13
+ *
14
+ * server.js keeps only what is genuinely the container's: the HTTP server, the
15
+ * build queue, the BUILD_TIMEOUT controller, direct-to-storage artifact upload,
16
+ * and the baked-image identity (which node_modules trees exist, how an install
17
+ * runs, what the compiler stamp is).
18
+ *
19
+ * ── The boundary ──────────────────────────────────────────────────────────
20
+ * Everything at module scope below is PURE: it depends only on its arguments,
21
+ * node builtins, and esbuild. Everything inside createCompileCore(host) is
22
+ * ENVIRONMENT-BOUND: it needs to know where the baked dependency trees live,
23
+ * how to require the toolchain (typescript / postcss / autoprefixer /
24
+ * tailwindcss / semver), how to install a missing dependency, and what stamp
25
+ * identifies this compiler build. That is the entire difference between the
26
+ * container and the CLI, and `host` is the entire list of it.
27
+ *
28
+ * host = {
29
+ * imageNodeModules, // string | string[] — the dependency search path, in
30
+ * // order, behind the build root; esbuild nodePaths
31
+ * react19NodeModules, // string|null — isolated React 19 set, or null
32
+ * tw4TailwindDir, // string|null — dir to link as node_modules/tailwindcss for v4
33
+ * esbuild, // optional — the esbuild module to compile with.
34
+ * // Defaults to require('esbuild'). The CLI passes
35
+ * // esbuild-wasm: same version, byte-identical
36
+ * // output, and ONE package instead of 24
37
+ * // platform-native ones, which is what lets the
38
+ * // published CLI keep every production dependency
39
+ * // inside its signed artifact.
40
+ * requireImage(spec), // (spec) => module, resolved from the toolchain tree
41
+ * requireTw4(spec), // (spec) => module, resolved from the v4 engine tree
42
+ * installPackages({ root, specs, ctx }), // => Promise<void>; throws on failure
43
+ * requiresPackageProxy, // default TRUE. The compile container is egress-
44
+ * // locked and can only reach the registry through a
45
+ * // per-build scoped proxy, so with no proxy in the
46
+ * // request it must refuse rather than build with
47
+ * // unresolved pins. A host that reaches npm
48
+ * // directly (the CLI, on the developer's own
49
+ * // machine, through their own npm config) sets
50
+ * // false. Defaulting to true keeps that refusal
51
+ * // something a host must deliberately opt out of.
52
+ * uploadArtifacts, // optional (artifactUpload, chunks) => Promise<manifest[]>
53
+ * stamp: { source, toolchain }, // compiler identity for this build
54
+ * }
55
+ *
56
+ * createCompileCore(host) => { compile, compileProject, compileTransforms,
57
+ * parseSources, ... } — the same function signatures server.js exported
58
+ * before the extraction, so its consumers and contract tests are unchanged.
59
+ */
60
+ 'use strict';
61
+ const fs = require('fs');
62
+ const fsp = require('fs/promises');
63
+ const os = require('os');
64
+ const path = require('path');
65
+ const crypto = require('crypto');
66
+ const graphContract = require('./graph-contract.cjs');
67
+ const typedFunctions = require('./typed-functions.cjs');
68
+ const {
69
+ GRAPH_MAX_EDGES,
70
+ GRAPH_MAX_BYTES,
71
+ importGraphFromMetafile,
72
+ graphFromMetafile,
73
+ } = graphContract;
74
+
75
+ const COMPILER_CONTRACT = 'somewhere-compiler-v2';
76
+
77
+ /* ══════════════════════════════════════════════════════════════════════════
78
+ * PURE — no knowledge of where anything is installed.
79
+ * ══════════════════════════════════════════════════════════════════════════ */
80
+
81
+ const MAX_TOTAL_SOURCE_BYTES = 5 * 1024 * 1024;
82
+ const MAX_FILE_COUNT = 1500;
83
+ const MAX_BINARY_BYTES = 25 * 1024 * 1024;
84
+ const MAX_ARTIFACT_COUNT = 3000;
85
+
86
+ // Node-builtin → browser-polyfill aliases (pre-baked in the image). esbuild for
87
+ // the browser doesn't polyfill Node builtins, so a dep that `import`s `buffer`
88
+ // (pdf-lib, docx, xlsx, …) otherwise hard-errors "Could not resolve buffer".
89
+ // Both bare + `node:`-prefixed forms are mapped here.
90
+ const NODE_POLYFILLS = {
91
+ buffer: 'buffer', 'node:buffer': 'buffer',
92
+ process: 'process/browser', 'node:process': 'process/browser',
93
+ stream: 'stream-browserify', 'node:stream': 'stream-browserify',
94
+ util: 'util', 'node:util': 'util',
95
+ events: 'events', 'node:events': 'events',
96
+ path: 'path-browserify', 'node:path': 'path-browserify',
97
+ crypto: 'crypto-browserify', 'node:crypto': 'crypto-browserify',
98
+ };
99
+
100
+ // .svg is handled by svgPlugin (dual binding + ?react component), NOT a flat
101
+ // data-URL loader — so `import { ReactComponent }` / `?react` work like svgr.
102
+ const ASSET_LOADERS = {
103
+ '.png': 'dataurl', '.jpg': 'dataurl', '.jpeg': 'dataurl', '.gif': 'dataurl',
104
+ '.webp': 'dataurl', '.avif': 'dataurl', '.ico': 'dataurl', '.bmp': 'dataurl',
105
+ '.woff': 'dataurl', '.woff2': 'dataurl', '.ttf': 'dataurl',
106
+ '.otf': 'dataurl', '.eot': 'dataurl', '.mp3': 'dataurl', '.mp4': 'dataurl',
107
+ '.webm': 'dataurl', '.wav': 'dataurl', '.ogg': 'dataurl', '.pdf': 'dataurl',
108
+ };
109
+
110
+ function stableMapEntries(value) {
111
+ return Object.entries(value || {}).sort(([a], [b]) => a.localeCompare(b));
112
+ }
113
+
114
+ function sourceDigest(body) {
115
+ const canonical = {
116
+ entry: typeof body.entry === 'string' ? body.entry : null,
117
+ function_entries: [...(body.function_entries || [])].sort(),
118
+ function_paths: [...(body.function_paths || [])].sort(),
119
+ transform_entries: [...(body.transform_entries || [])].sort(),
120
+ files: stableMapEntries(body.files),
121
+ package_json: typeof body.package_json === 'string' ? body.package_json : null,
122
+ tsconfig: typeof body.tsconfig === 'string' ? body.tsconfig : null,
123
+ binary_files: stableMapEntries(body.binary_files),
124
+ binary_paths: [...(body.binary_paths || [])].sort(),
125
+ vite_env: stableMapEntries(body.vite_env),
126
+ };
127
+ return crypto.createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
128
+ }
129
+
130
+ /* ─── SVG → React component (Vite/svgr-compatible) ────────────────────────
131
+ * `import Logo from './logo.svg?react'` → a component (default export).
132
+ * `import url, { ReactComponent } from './logo.svg'` → URL default + component.
133
+ * dangerouslySetInnerHTML so the SVG body never goes through the JSX parser
134
+ * (real SVGs carry <style>{…}</style>, xmlns:xlink, etc. that break naive JSX). */
135
+ const SVG_ATTR_RENAMES = {
136
+ 'class': 'className', 'for': 'htmlFor', 'tabindex': 'tabIndex',
137
+ 'stroke-width': 'strokeWidth', 'stroke-linecap': 'strokeLinecap', 'stroke-linejoin': 'strokeLinejoin',
138
+ 'stroke-dasharray': 'strokeDasharray', 'stroke-dashoffset': 'strokeDashoffset', 'stroke-miterlimit': 'strokeMiterlimit',
139
+ 'stroke-opacity': 'strokeOpacity', 'fill-opacity': 'fillOpacity', 'fill-rule': 'fillRule',
140
+ 'clip-path': 'clipPath', 'clip-rule': 'clipRule', 'vector-effect': 'vectorEffect',
141
+ 'shape-rendering': 'shapeRendering', 'text-anchor': 'textAnchor', 'font-family': 'fontFamily',
142
+ 'font-size': 'fontSize', 'font-weight': 'fontWeight', 'stop-color': 'stopColor', 'stop-opacity': 'stopOpacity',
143
+ };
144
+ function svgToReactComponent(svgText, componentName, exportDefault) {
145
+ const cleaned = svgText
146
+ .replace(/<\?xml[^?]*\?>/g, '').replace(/<!DOCTYPE[^>]*>/g, '').replace(/<!--[\s\S]*?-->/g, '').trim();
147
+ const rootMatch = cleaned.match(/^<svg\b([^>]*)>([\s\S]*)<\/svg>\s*$/i);
148
+ const reactAttrs = {};
149
+ let inner = cleaned;
150
+ if (rootMatch) {
151
+ inner = rootMatch[2];
152
+ const attrRe = /([A-Za-z][\w:-]*)\s*=\s*"([^"]*)"/g;
153
+ let m;
154
+ while ((m = attrRe.exec(rootMatch[1]))) {
155
+ const key = SVG_ATTR_RENAMES[m[1]] || (m[1].includes(':') ? null : m[1]);
156
+ if (key) reactAttrs[key] = m[2];
157
+ }
158
+ }
159
+ const decl = `import * as React from 'react';
160
+ function ${componentName}(props) {
161
+ return React.createElement('svg', Object.assign(
162
+ ${JSON.stringify(reactAttrs)}, props,
163
+ { dangerouslySetInnerHTML: { __html: ${JSON.stringify(inner)} } },
164
+ ));
165
+ }
166
+ `;
167
+ return exportDefault === false ? decl : decl + `export default ${componentName};\n`;
168
+ }
169
+
170
+ function svgDataUrl(svgText) {
171
+ return `data:image/svg+xml;base64,${Buffer.from(svgText, 'utf8').toString('base64')}`;
172
+ }
173
+
174
+ /**
175
+ * esbuild plugin for the two svg import shapes.
176
+ *
177
+ * `root` is the build root, and the `?react` branch keys its namespace on the
178
+ * ROOT-RELATIVE path rather than the absolute one. esbuild stamps
179
+ * `namespace:path` into the bundle banner and the sourcemap, and every build
180
+ * materializes to a fresh mkdtemp dir — so the absolute form put a random tmp
181
+ * segment in the OUTPUT BYTES, and identical source compiled to a different
182
+ * content hash every single time (tsk_cdd7c47c). Files loaded through the
183
+ * normal pipeline are already normalized against absWorkingDir; a custom
184
+ * namespace is not, so it has to normalize itself.
185
+ */
186
+ function svgPlugin(root) {
187
+ let realRoot = root;
188
+ try { realRoot = fs.realpathSync(root); } catch { /* keep root */ }
189
+ // esbuild reports resolveDir as a REAL path (symlinks resolved); root may
190
+ // contain one (macOS /var → /private/var), so compare like with like.
191
+ const toAbs = (rel) => path.resolve(realRoot, rel);
192
+ return {
193
+ name: 'somewhere-svg',
194
+ setup(build) {
195
+ // `./x.svg?react` — strip the query, resolve on disk, emit a component.
196
+ build.onResolve({ filter: /\.svg\?react$/ }, (args) => {
197
+ const clean = args.path.replace(/\?react$/, '');
198
+ const abs = path.isAbsolute(clean) ? clean : path.resolve(args.resolveDir, clean);
199
+ const rel = path.relative(realRoot, abs);
200
+ // Outside the build root (nothing normal reaches here) — keep the
201
+ // absolute path rather than emit a path that escapes the root.
202
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return { path: abs, namespace: 'svg-react' };
203
+ return { path: rel, namespace: 'svg-react' };
204
+ });
205
+ build.onLoad({ filter: /.*/, namespace: 'svg-react' }, async (args) => {
206
+ const abs = path.isAbsolute(args.path) ? args.path : toAbs(args.path);
207
+ const svg = await fsp.readFile(abs, 'utf8');
208
+ const base = (abs.split('/').pop() || 'Icon').replace(/\.svg$/i, '').replace(/[^A-Za-z0-9]/g, '_').replace(/^([0-9])/, '_$1');
209
+ const name = (base.charAt(0).toUpperCase() + base.slice(1)) || 'SvgIcon';
210
+ return { contents: svgToReactComponent(svg, name, true), loader: 'jsx', resolveDir: path.dirname(abs) };
211
+ });
212
+ // bare `./x.svg` — URL default + named ReactComponent (svgr dual binding).
213
+ build.onLoad({ filter: /\.svg$/ }, async (args) => {
214
+ const svg = await fsp.readFile(args.path, 'utf8');
215
+ return {
216
+ contents:
217
+ svgToReactComponent(svg, '__SvgReactComponent', false) +
218
+ `\nexport const ReactComponent = __SvgReactComponent;` +
219
+ `\nexport default ${JSON.stringify(svgDataUrl(svg))};\n`,
220
+ loader: 'jsx',
221
+ resolveDir: path.dirname(args.path),
222
+ };
223
+ });
224
+ },
225
+ };
226
+ }
227
+
228
+ /* ─── URL-export resolution for large binary assets (tsk_ad51a448) ───────────
229
+ * The worker withholds the BYTES of large binaries (the multi-MB base64 map
230
+ * was the memory spike that killed ~25MB deploy payloads) and sends a
231
+ * `binary_paths` manifest instead. Imports of those paths resolve to a URL
232
+ * export — `import img from './hero.png'` → "/hero.png" — which is what the
233
+ * asset's canonical serving path is anyway (Vite emits URLs, not data URLs,
234
+ * for anything past its inline threshold too). CSS url() tokens stay as
235
+ * external absolute URLs. Small assets keep arriving with bytes and ride the
236
+ * dataurl loaders, unchanged. */
237
+ function urlAssetPlugin(root, binaryPaths) {
238
+ const rels = new Set(binaryPaths || []);
239
+ // esbuild reports resolveDir as a REAL path (symlinks resolved); root may
240
+ // contain one (macOS /var → /private/var in local dev). Compare like with
241
+ // like or every relative() result walks out of the tree.
242
+ let realRoot = root;
243
+ try { realRoot = fs.realpathSync(root); } catch { /* keep root */ }
244
+ return {
245
+ name: 'somewhere-url-asset',
246
+ setup(build) {
247
+ if (rels.size === 0) return;
248
+ build.onResolve({ filter: /\.(png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|otf|eot|mp3|mp4|webm|wav|ogg|pdf)$/i }, (args) => {
249
+ if (args.kind === 'entry-point') return null;
250
+ const abs = path.isAbsolute(args.path) ? args.path : path.resolve(args.resolveDir, args.path);
251
+ const rel = path.relative(realRoot, abs);
252
+ if (!rels.has(rel)) return null; // bytes were provided → default pipeline
253
+ const url = `/${rel}`;
254
+ if (args.kind === 'url-token') return { path: url, external: true }; // css url(...)
255
+ return { path: url, namespace: 'sw-url-asset' };
256
+ });
257
+ build.onLoad({ filter: /.*/, namespace: 'sw-url-asset' }, (args) => ({
258
+ contents: `export default ${JSON.stringify(args.path)};`,
259
+ loader: 'js',
260
+ }));
261
+ },
262
+ };
263
+ }
264
+
265
+ /* ─── function-vs-static classification (mirror of deploy.ts:isFunctionPath) ─ */
266
+ function isFunctionPath(p) {
267
+ if (!/\.(ts|js|mjs|tsx)$/i.test(p)) return false;
268
+ if (p.startsWith('api/') || p.startsWith('_lib/')) return true;
269
+ if (/^\[[^/]+\]\.(ts|js|mjs)$/.test(p)) return true;
270
+ return false;
271
+ }
272
+
273
+ /* ─── Tailwind detection ────────────────────────────────────────────────── */
274
+ function detectTailwind(files) {
275
+ for (const [p, content] of Object.entries(files)) {
276
+ if (typeof content !== 'string') continue;
277
+ if (!/\.(?:css|scss|sass|less)$/i.test(p)) continue;
278
+ if (/@import\s+["']tailwindcss(?:\/[^"']*)?["']/.test(content)) return 4;
279
+ if (/@(?:theme|utility|custom-variant|source)\b/.test(content)) return 4;
280
+ }
281
+ for (const p of Object.keys(files)) {
282
+ if (/(?:^|\/)tailwind\.config\.(?:js|cjs|mjs|ts)$/.test(p)) return 3;
283
+ }
284
+ for (const [p, content] of Object.entries(files)) {
285
+ if (typeof content !== 'string') continue;
286
+ if (/(?:^|\/)postcss\.config\.(?:js|cjs|mjs|ts)$/.test(p) && /tailwind/i.test(content)) return 3;
287
+ if (!/\.(?:css|scss|sass|less)$/i.test(p)) continue;
288
+ if (/@tailwind\b/.test(content)) return 3;
289
+ if (/@apply\b/.test(content)) return 3;
290
+ if (/@layer\s+(?:base|components|utilities)\b/.test(content)) return 3;
291
+ }
292
+ return 0;
293
+ }
294
+
295
+ /* ─── import.meta.env / VITE_* defines ──────────────────────────────────── */
296
+ function buildViteEnvDefines(env, sourceFiles) {
297
+ const out = {};
298
+ for (const [k, v] of Object.entries(env || {})) {
299
+ if (!/^VITE_[A-Z0-9_]+$/.test(k)) continue;
300
+ out[`import.meta.env.${k}`] = JSON.stringify(v);
301
+ }
302
+ // referenced-but-unset VITE_* → "" so `VITE_X || 'fallback'` survives bundling
303
+ // instead of becoming `undefined.VITE_X` (the railtime blank-page class).
304
+ const referenced = new Set();
305
+ const rx = /import\.meta\.env\.(VITE_[A-Z0-9_]+)/g;
306
+ for (const src of Object.values(sourceFiles || {})) {
307
+ if (typeof src !== 'string') continue;
308
+ let m;
309
+ while ((m = rx.exec(src)) !== null) referenced.add(m[1]);
310
+ }
311
+ for (const name of referenced) {
312
+ const key = `import.meta.env.${name}`;
313
+ if (out[key] === undefined) out[key] = '""';
314
+ }
315
+ return out;
316
+ }
317
+
318
+ /** CRA-style env: every referenced `process.env.REACT_APP_*` is defined from
319
+ * the project env map (or "" when unset) — react-scripts baked these into
320
+ * the build the same way. Without a define, the identifier survives into
321
+ * the bundle and `process` is undefined in the browser → blank page, so
322
+ * this can only FIX projects, never change a working one (an existing
323
+ * guarded `typeof process !== 'undefined'` check still short-circuits
324
+ * first). (tsk_4d9c865a) */
325
+ function buildReactEnvDefines(env, sourceFiles) {
326
+ const out = {};
327
+ const referenced = new Set();
328
+ const rx = /process\.env\.(REACT_APP_[A-Z0-9_]+)/g;
329
+ for (const src of Object.values(sourceFiles || {})) {
330
+ if (typeof src !== 'string') continue;
331
+ let m;
332
+ while ((m = rx.exec(src)) !== null) referenced.add(m[1]);
333
+ }
334
+ for (const name of referenced) {
335
+ const v = (env || {})[name];
336
+ out[`process.env.${name}`] = v === undefined ? '""' : JSON.stringify(v);
337
+ }
338
+ return out;
339
+ }
340
+
341
+ /** Write the in-memory file map (+ base64 binaries) to a fresh tmp dir. */
342
+ async function materialize(files, binaryFiles) {
343
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'swc-'));
344
+ for (const [rel, content] of Object.entries(files || {})) {
345
+ if (typeof content !== 'string') continue;
346
+ const abs = path.join(root, rel);
347
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
348
+ await fsp.writeFile(abs, content, 'utf8');
349
+ }
350
+ for (const [rel, b64] of Object.entries(binaryFiles || {})) {
351
+ if (typeof b64 !== 'string') continue;
352
+ const abs = path.join(root, rel);
353
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
354
+ await fsp.writeFile(abs, Buffer.from(b64, 'base64'));
355
+ }
356
+ return root;
357
+ }
358
+
359
+ function assertSourcePath(rel) {
360
+ if (typeof rel !== 'string' || !rel || rel.includes('\0') || rel.includes('\\') || path.isAbsolute(rel)) {
361
+ throw codedError('INVALID_SOURCE_PATH', `invalid source path: ${JSON.stringify(rel)}`);
362
+ }
363
+ const normalized = path.posix.normalize(rel);
364
+ if (normalized !== rel || normalized === '..' || normalized.startsWith('../')) {
365
+ throw codedError('INVALID_SOURCE_PATH', `source path is not canonical: ${rel}`);
366
+ }
367
+ }
368
+
369
+ function validateCompileInput(body) {
370
+ if (!body || typeof body !== 'object') throw new Error('request body is required');
371
+ if (!body.files || typeof body.files !== 'object' || Array.isArray(body.files)) {
372
+ throw new Error('`files` map is required');
373
+ }
374
+ if (body.binary_files !== undefined && (!body.binary_files || typeof body.binary_files !== 'object' || Array.isArray(body.binary_files))) {
375
+ throw new Error('`binary_files` must be a map');
376
+ }
377
+ if (body.binary_paths !== undefined && !Array.isArray(body.binary_paths)) {
378
+ throw new Error('`binary_paths` must be an array');
379
+ }
380
+ for (const field of ['function_entries', 'function_paths', 'transform_entries']) {
381
+ if (body[field] !== undefined && !Array.isArray(body[field])) throw new Error(`\`${field}\` must be an array`);
382
+ for (const rel of body[field] || []) assertSourcePath(rel);
383
+ }
384
+ if ((body.operation || 'project') === 'project') {
385
+ if (typeof body.project_id !== 'string' || !body.project_id.trim()) throw new Error('`project_id` is required');
386
+ if (typeof body.build_id !== 'string' || !body.build_id.trim()) throw new Error('`build_id` is required');
387
+ }
388
+ const textPaths = Object.keys(body.files);
389
+ const binaryFilePaths = Object.keys(body.binary_files || {});
390
+ const binaryPaths = body.binary_paths || [];
391
+ const paths = [...new Set([...textPaths, ...binaryFilePaths, ...binaryPaths])];
392
+ if (paths.length > MAX_FILE_COUNT) throw codedError('SOURCE_LIMIT', `too many files: ${paths.length} > ${MAX_FILE_COUNT}`);
393
+ for (const rel of paths) assertSourcePath(rel);
394
+ for (const rel of binaryFilePaths) {
395
+ if (Object.prototype.hasOwnProperty.call(body.files, rel)) throw codedError('INVALID_SOURCE', `path is both text and binary: ${rel}`);
396
+ }
397
+ for (const rel of binaryPaths) {
398
+ if (typeof rel !== 'string') throw codedError('INVALID_SOURCE_PATH', `invalid source path: ${JSON.stringify(rel)}`);
399
+ if (Object.prototype.hasOwnProperty.call(body.files, rel) || Object.prototype.hasOwnProperty.call(body.binary_files || {}, rel)) {
400
+ throw codedError('INVALID_SOURCE', `path is both inline and withheld binary: ${rel}`);
401
+ }
402
+ }
403
+ let totalBytes = 0;
404
+ for (const [rel, content] of Object.entries(body.files)) {
405
+ if (typeof content !== 'string') throw codedError('INVALID_SOURCE', `source file must contain text: ${rel}`);
406
+ totalBytes += Buffer.byteLength(content, 'utf8');
407
+ }
408
+ if (typeof body.package_json === 'string' && body.files['package.json'] !== body.package_json) totalBytes += Buffer.byteLength(body.package_json, 'utf8');
409
+ if (typeof body.tsconfig === 'string' && body.files['tsconfig.json'] !== body.tsconfig) totalBytes += Buffer.byteLength(body.tsconfig, 'utf8');
410
+ if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
411
+ throw codedError('SOURCE_LIMIT', `source bytes ${totalBytes} > ${MAX_TOTAL_SOURCE_BYTES}`);
412
+ }
413
+ let binaryBytes = 0;
414
+ for (const [rel, b64] of Object.entries(body.binary_files || {})) {
415
+ if (typeof b64 !== 'string') throw codedError('INVALID_SOURCE', `binary file must contain base64 text: ${rel}`);
416
+ binaryBytes += Buffer.from(b64, 'base64').length;
417
+ }
418
+ if (binaryBytes > MAX_BINARY_BYTES) {
419
+ throw codedError('BINARY_LIMIT', `binary bytes ${binaryBytes} > ${MAX_BINARY_BYTES}`);
420
+ }
421
+ }
422
+
423
+ function cleanRange(range) {
424
+ // `^18.2.0` → `18.2.0`; keep it installable but pin to the declared version.
425
+ return String(range || '').replace(/^[\^~>=<v\s]+/, '').split(/\s/)[0] || 'latest';
426
+ }
427
+
428
+ /**
429
+ * Is `name` resolvable the way ESBUILD will resolve it — i.e. as a direct child
430
+ * package of one of the exact search dirs (the per-build install + the baked
431
+ * image set, mirroring esbuild's nodePaths)? We deliberately do NOT use Node's
432
+ * require.resolve here: its `paths` option walks UP ancestor directories, so in
433
+ * the monorepo it finds deps in a parent node_modules that esbuild's strict
434
+ * nodePaths can't see — over-reporting "resolvable" and skipping an install
435
+ * esbuild then needs. An isolated container has no such ancestor; this fs check
436
+ * is correct in both environments.
437
+ */
438
+ function isResolvable(name, searchDirs) {
439
+ return searchDirs.some((dir) => fs.existsSync(path.join(dir, name, 'package.json')));
440
+ }
441
+
442
+ // Build-toolchain packages that legacy scaffolds declare under `dependencies`
443
+ // (CRA puts react-scripts there by default). The browser bundle never imports
444
+ // them, and installing react-scripts alone drags in a ~100MB tree that can
445
+ // blow the per-build install timeout — the platform IS the build toolchain.
446
+ // (tsk_4d9c865a)
447
+ const TOOLCHAIN_DEPS = new Set(['react-scripts', 'react-app-rewired', 'craco', '@craco/craco', 'vite', 'webpack', 'parcel']);
448
+
449
+ /* ─── Phantom-import detection (tsk_8e460b69) ─────────────────────────────────
450
+ * esbuild 0.24 tree-shakes a bare import whose named bindings are ALL unused
451
+ * before it attempts to resolve the module. An `import { nothing } from
452
+ * 'missing-pkg'` where `nothing` is never referenced silently succeeds — no
453
+ * "Could not resolve" error and no warning. The customer's deploy
454
+ * goes live; if that code path ever executes in the browser it fails at runtime.
455
+ *
456
+ * This scanner runs AFTER the esbuild build succeeds and catches that class:
457
+ * every bare specifier found in the frontend source that is NOT resolvable
458
+ * through the same set of sources esbuild would use (per-build install, baked
459
+ * image, React-19 baked set, NODE_POLYFILLS aliases) is surfaced as a warning.
460
+ *
461
+ * RULE 9 — WARN-FIRST: never a build failure. A phantom import the customer
462
+ * hasn't reached yet doesn't break today's page. The warning names the package
463
+ * and the file so the developer or their agent can add it to package.json.
464
+ *
465
+ * GRANDFATHER: the full resolvability chain (build root → IMAGE_NODE_MODULES →
466
+ * REACT19_NODE_MODULES → NODE_POLYFILLS → node: prefix → relative/URL paths)
467
+ * mirrors exactly what esbuild can resolve, so a working import NEVER warns.
468
+ */
469
+
470
+ /**
471
+ * Extract bare import/export specifiers from JS-family source.
472
+ * "Bare" = not relative (`./`, `../`), not root-anchored (`/`), not a URL.
473
+ * Handles static import, re-export, bare side-effect import, and dynamic import().
474
+ */
475
+ function extractBareSpecifiers(content) {
476
+ if (typeof content !== 'string') return [];
477
+ const out = new Set();
478
+ const re = /(?:\bimport\b[^'";]*?\bfrom\s*|\bexport\b[^'";]*?\bfrom\s*|\bimport\s*\(\s*|\bimport\s*)(['"])([^'"]+)\1/g;
479
+ let m;
480
+ while ((m = re.exec(content)) !== null) {
481
+ const spec = m[2];
482
+ if (!spec) continue;
483
+ if (spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/')) continue;
484
+ if (/^[a-z][a-z0-9+.-]*:/i.test(spec)) continue; // http:, data:, blob:, node:, virtual:, …
485
+ out.add(spec);
486
+ }
487
+ return [...out];
488
+ }
489
+
490
+ /**
491
+ * Return the root package name for a specifier: `@scope/pkg/subpath` → `@scope/pkg`,
492
+ * `pkg/subpath` → `pkg`, `pkg` → `pkg`.
493
+ */
494
+ function specRootPkg(spec) {
495
+ return spec.startsWith('@')
496
+ ? spec.split('/').slice(0, 2).join('/')
497
+ : spec.split('/')[0];
498
+ }
499
+
500
+ /* ─── Dependency review (PR1, tsk_35befef9 / npm supply-chain) ────────────────
501
+ * After deps are installed (with --ignore-scripts), walk the customer's RESOLVED
502
+ * dependency tree and report what actually shipped: package count, lifecycle
503
+ * scripts that were SKIPPED, native modules, and any resolved version that
504
+ * matches a known-compromised release. Surfaced at deploy as the "dependency
505
+ * review" block + (for a known-bad hit) a non-blocking warning.
506
+ *
507
+ * RULE 9 — this is WARN-FIRST: we report, we never refuse the build. Promotion
508
+ * of any signal to an actual block is a separate, gated step (PR4).
509
+ *
510
+ * No network: reads only the installed package.json files on the build fs. The
511
+ * AUTHORITATIVE live advisory scan (OSV/npm-advisory) ships with the R2
512
+ * dependency cache (PR2), running on cache-entry; this is the certain, feed-free
513
+ * signal + a small hand-verified known-bad backstop so a famous worm can't slip
514
+ * through before that feed lands.
515
+ *
516
+ * Scope = the customer's installed tree (build-root node_modules) — the long
517
+ * tail npm pulled in. The pre-baked image deps (our toolchain + react) are our
518
+ * own trusted pins, not the customer's supply-chain surface, so they're excluded.
519
+ */
520
+
521
+ // Hand-verified backstop of CONFIRMED-compromised exact versions. NOT the
522
+ // authoritative source (PR2's live OSV scan is) — every entry is an exact
523
+ // name@version confirmed across independent advisories. Rule 7: no guessed or
524
+ // approximate entry, because a wrong known-bad becomes a false BLOCK the moment
525
+ // these promote past warn-first.
526
+ //
527
+ // Sept 8 2025 "crypto-clipper" cluster — chalk/debug/ansi maintainer phished
528
+ // (npmjs.help 2FA-reset lure); injected a browser wallet-drainer. Sources:
529
+ // Sonatype, Wiz, Semgrep, Vercel, Palo Alto Unit 42.
530
+ const KNOWN_BAD_VERSIONS = {
531
+ debug: ['4.4.2'], chalk: ['5.6.1'], 'ansi-styles': ['6.2.2'], 'ansi-regex': ['6.2.1'],
532
+ 'strip-ansi': ['7.1.1'], 'wrap-ansi': ['9.0.1'], 'supports-color': ['10.2.1'],
533
+ 'supports-hyperlinks': ['4.1.1'], color: ['5.0.1'], 'color-name': ['2.0.1'],
534
+ 'color-convert': ['3.1.1'], 'color-string': ['2.1.1'], 'has-ansi': ['6.0.1'],
535
+ 'slice-ansi': ['7.1.1'], 'chalk-template': ['1.1.1'], backslash: ['0.2.1'],
536
+ 'is-arrayish': ['0.3.3'], 'error-ex': ['1.3.3'], 'simple-swizzle': ['0.2.3'],
537
+ 'proto-tinker-wc': ['0.1.87'], duckdb: ['1.3.3'], '@duckdb/node-api': ['1.3.3'],
538
+ '@duckdb/node-bindings': ['1.3.3'], '@duckdb/duckdb-wasm': ['1.29.2'],
539
+ };
540
+
541
+ const MAX_REVIEW_PACKAGES = 5000; // pathological-tree guard
542
+ const NATIVE_BUILD_TOOLS = /node-gyp|node-pre-gyp|prebuild|prebuildify|cmake-js/;
543
+
544
+ // Walk a node_modules dir → {name, version, dir, pkg} per installed package.
545
+ // Handles @scope packages and nested node_modules (npm dedupe conflicts).
546
+ function* walkInstalled(nmDir, depth) {
547
+ if (depth > 6 || !fs.existsSync(nmDir)) return;
548
+ let entries;
549
+ try { entries = fs.readdirSync(nmDir, { withFileTypes: true }); } catch { return; }
550
+ for (const e of entries) {
551
+ if (e.name.startsWith('.')) continue;
552
+ const full = path.join(nmDir, e.name);
553
+ if (e.name.startsWith('@')) {
554
+ let scoped;
555
+ try { scoped = fs.readdirSync(full, { withFileTypes: true }); } catch { continue; }
556
+ for (const s of scoped) {
557
+ if (s.name.startsWith('.')) continue;
558
+ yield* emitInstalledPkg(path.join(full, s.name), depth);
559
+ }
560
+ } else {
561
+ yield* emitInstalledPkg(full, depth);
562
+ }
563
+ }
564
+ }
565
+ function* emitInstalledPkg(dir, depth) {
566
+ let pkg = null;
567
+ try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { /* not a package dir */ }
568
+ if (pkg && typeof pkg.name === 'string') yield { name: pkg.name, version: pkg.version || '0.0.0', dir, pkg };
569
+ yield* walkInstalled(path.join(dir, 'node_modules'), depth + 1);
570
+ }
571
+
572
+ function reviewDependencies(root, pkg) {
573
+ const directDeps = (pkg && pkg.dependencies) || {};
574
+ const review = {
575
+ directDependencies: Object.keys(directDeps).length,
576
+ scanned: 0,
577
+ installScriptsExecuted: 0, // always 0 — --ignore-scripts
578
+ installScriptsSkipped: [], // [{name, version, hooks}]
579
+ nativeModules: [], // [{name, version}]
580
+ knownBad: [], // [{name, version, severity, incident}]
581
+ packages: [], // [{name, version}] — resolved set, for the
582
+ // worker's live OSV advisory scan (PR4). Capped.
583
+ };
584
+ const seen = new Set();
585
+ for (const { name, version, dir, pkg: p } of walkInstalled(path.join(root, 'node_modules'), 0)) {
586
+ const key = `${name}@${version}`;
587
+ if (seen.has(key)) continue;
588
+ seen.add(key);
589
+ if (seen.size > MAX_REVIEW_PACKAGES) break;
590
+ review.scanned++;
591
+ review.packages.push({ name, version });
592
+
593
+ const scripts = (p && p.scripts) || {};
594
+ const hooks = ['preinstall', 'install', 'postinstall'].filter((h) => typeof scripts[h] === 'string' && scripts[h].trim());
595
+ if (hooks.length) review.installScriptsSkipped.push({ name, version, hooks });
596
+
597
+ const isNative = (p && p.gypfile === true)
598
+ || fs.existsSync(path.join(dir, 'binding.gyp'))
599
+ || hooks.some((h) => NATIVE_BUILD_TOOLS.test(scripts[h] || ''));
600
+ if (isNative) review.nativeModules.push({ name, version });
601
+
602
+ const bad = KNOWN_BAD_VERSIONS[name];
603
+ if (bad && bad.includes(version)) {
604
+ review.knownBad.push({ name, version, severity: 'critical', incident: 'confirmed-compromised npm release' });
605
+ }
606
+ }
607
+ return review;
608
+ }
609
+
610
+ /* ─── Manifest path (tsk_118c1ecc) — direct-to-storage artifact upload ──────
611
+ * A heavy compiled result (~36MB for project `tools`) cannot safely ride back
612
+ * inline. When the request carries `artifact_upload` {url, token}, the
613
+ * container PUTs every chunk to the worker's /v1/compile-artifacts route
614
+ * (gated by the scoped single-build token — same pattern as the npm-proxy
615
+ * per-build token) and the response carries only a MANIFEST: chunk names,
616
+ * sizes, sha256 hashes. No chunk bodies. */
617
+
618
+ /** Throw an Error carrying a machine-readable `code` the worker branches on. */
619
+ function codedError(code, message) {
620
+ const err = new Error(message);
621
+ err.code = code;
622
+ return err;
623
+ }
624
+
625
+ function artifactDescriptor(pathname, kind, text) {
626
+ const bytes = Buffer.from(text, 'utf8');
627
+ const mime = pathname.endsWith('.css')
628
+ ? 'text/css; charset=utf-8'
629
+ : 'application/javascript; charset=utf-8';
630
+ return {
631
+ path: pathname,
632
+ kind,
633
+ bytes: bytes.length,
634
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
635
+ mime,
636
+ };
637
+ }
638
+
639
+ function inlineResultBodyBytes(result) {
640
+ let bytes = 0;
641
+ for (const text of Object.values(result.chunks || {})) bytes += Buffer.byteLength(text, 'utf8');
642
+ for (const artifact of Object.values(result.functions || {})) bytes += Buffer.byteLength(artifact.js, 'utf8');
643
+ for (const artifact of Object.values(result.transforms || {})) bytes += Buffer.byteLength(artifact.js, 'utf8');
644
+ return bytes;
645
+ }
646
+
647
+ function enforceInlineResultCap(body, result) {
648
+ const cap = body.inline_result_cap_bytes;
649
+ if (!Number.isFinite(cap) || cap <= 0) return;
650
+ const inlineBytes = inlineResultBodyBytes(result);
651
+ const serializedBytes = Buffer.byteLength(JSON.stringify(result), 'utf8');
652
+ if (serializedBytes <= cap) return;
653
+ const mb = (serializedBytes / (1024 * 1024)).toFixed(1);
654
+ const inlineMb = (inlineBytes / (1024 * 1024)).toFixed(1);
655
+ const capMb = (cap / (1024 * 1024)).toFixed(0);
656
+ throw codedError(
657
+ 'RESULT_TOO_LARGE',
658
+ `compiled result is ${mb}MB (${inlineMb}MB inline bodies) across ${result.artifact_manifest.artifacts.length} artifacts, above the ${capMb}MB limit`,
659
+ );
660
+ }
661
+
662
+ function resolvedDependencyDigest(review) {
663
+ const packages = [...(review && review.packages || [])]
664
+ .map((item) => `${item.name}@${item.version}`)
665
+ .sort();
666
+ return crypto.createHash('sha256').update(packages.join('\n')).digest('hex');
667
+ }
668
+
669
+ function sourceLoader(filename) {
670
+ if (/\.tsx$/i.test(filename)) return 'tsx';
671
+ if (/\.(?:ts|mts|cts)$/i.test(filename)) return 'ts';
672
+ if (/\.jsx$/i.test(filename)) return 'jsx';
673
+ if (/\.(?:js|mjs|cjs)$/i.test(filename)) return 'js';
674
+ if (/\.json$/i.test(filename)) return 'json';
675
+ return null;
676
+ }
677
+
678
+ function resolveLocalFunctionImport(root, spec) {
679
+ if (typeof spec !== 'string' || /^(?:node|cloudflare|workerd):/.test(spec)) return null;
680
+ if (spec.startsWith('.') || /^[a-z][a-z0-9+.-]*:/i.test(spec)) return null;
681
+ const base = path.resolve(root, spec.replace(/^\/+/, ''));
682
+ if (base !== root && !base.startsWith(root + path.sep)) return null;
683
+ const candidates = [
684
+ base,
685
+ ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'].map((ext) => base + ext),
686
+ ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'].map((ext) => path.join(base, 'index' + ext)),
687
+ ];
688
+ return candidates.find((candidate) => {
689
+ try { return fs.statSync(candidate).isFile(); } catch { return false; }
690
+ }) || null;
691
+ }
692
+
693
+ function functionLocalImportPlugin(root) {
694
+ return {
695
+ name: 'somewhere-function-local-root',
696
+ setup(build) {
697
+ build.onResolve({ filter: /.*/ }, (args) => {
698
+ if (args.kind === 'entry-point') return null;
699
+ if (/^(?:node|cloudflare|workerd):/.test(args.path)) return { path: args.path, external: true };
700
+ const local = resolveLocalFunctionImport(root, args.path);
701
+ return local ? { path: local } : null;
702
+ });
703
+ },
704
+ };
705
+ }
706
+ /* ══════════════════════════════════════════════════════════════════════════
707
+ * ENVIRONMENT-BOUND — everything that needs to know where the baked toolchain
708
+ * and dependency trees live, how an install runs, and what stamps this build.
709
+ * ══════════════════════════════════════════════════════════════════════════ */
710
+
711
+ /**
712
+ * Build a compiler instance bound to one host environment.
713
+ * @param {object} host — see the file header for the complete contract.
714
+ */
715
+ function createCompileCore(host) {
716
+ if (!host || typeof host !== 'object') throw new Error('createCompileCore(host) requires a host');
717
+ for (const required of ['imageNodeModules', 'requireImage', 'installPackages']) {
718
+ if (host[required] === undefined || host[required] === null) {
719
+ throw new Error(`createCompileCore(host): host.${required} is required`);
720
+ }
721
+ }
722
+ // One dir or several, in search order. The container has exactly one (its
723
+ // baked image tree); the CLI's local dev loop has up to two (the project's
724
+ // own node_modules first, then its managed cache), which is why this is a
725
+ // list rather than the single path the container needed.
726
+ // esbuild IS the compiler: same version in, same bytes out. A host may hand
727
+ // us a different BUILD of that exact version (the CLI uses esbuild-wasm
728
+ // 0.24.0, which the parity fixture proves emits identical output to the
729
+ // container's native 0.24.0) but never a different version — the compiler
730
+ // stamp carries esbuild.version and the vendor step refuses a pin mismatch.
731
+ const esbuild = host.esbuild || require('esbuild');
732
+ const IMAGE_NODE_MODULES = Array.isArray(host.imageNodeModules)
733
+ ? host.imageNodeModules.filter(Boolean)
734
+ : [host.imageNodeModules];
735
+ if (!IMAGE_NODE_MODULES.length) throw new Error('createCompileCore(host): host.imageNodeModules is empty');
736
+ const REACT19_NODE_MODULES = host.react19NodeModules || null;
737
+ const imageRequire = host.requireImage;
738
+ const tw4Require = host.requireTw4 || (() => { throw new Error('this host has no Tailwind v4 engine'); });
739
+ const stamp = host.stamp || {};
740
+
741
+ function compilerStamp(sourceDigest) {
742
+ return {
743
+ contract: COMPILER_CONTRACT,
744
+ esbuild: esbuild.version,
745
+ source: stamp.source,
746
+ toolchain: stamp.toolchain,
747
+ ...(sourceDigest ? { source_digest: sourceDigest } : {}),
748
+ };
749
+ }
750
+
751
+ // semver is lazily required from the baked image and backs ensureDeps'
752
+ // version-aware resolution. If it somehow can't load, getSemver returns null and
753
+ // bakedSatisfies degrades to "does not satisfy" → per-build install (slower but
754
+ // correct) — never a crash, and never a false baked match.
755
+ let _semver;
756
+ function getSemver() {
757
+ if (_semver !== undefined) return _semver;
758
+ try { _semver = imageRequire('semver'); } catch { _semver = null; }
759
+ return _semver;
760
+ }
761
+
762
+ /**
763
+ * The prefer-baked-but-respect-the-pin rule for ONE baked dependency: return
764
+ * true only when the image's pre-baked copy of `name` satisfies the project's
765
+ * requested `range`. When it returns false the caller installs the project's
766
+ * declared version per-build into the build root, and esbuild resolves the build
767
+ * root's node_modules BEFORE the baked nodePaths — so the project's version wins
768
+ * and a baked dep is never forced onto an app that pinned another.
769
+ *
770
+ * Bias is deliberate and one-directional: a FALSE POSITIVE (claiming baked
771
+ * satisfies when it doesn't) would ship the wrong version to the customer, so we
772
+ * never risk it. Anything we cannot positively prove satisfies — a missing baked
773
+ * version, a range semver can't parse (`latest`, git/alias specs), or semver
774
+ * being unavailable — returns false and falls back to a per-build install, which
775
+ * is correct, just slower. A false NEGATIVE only costs an unnecessary install.
776
+ */
777
+ function bakedSatisfies(name, range, baseDir) {
778
+ // No explicit dir = ask the whole search path, in order. Any dir whose copy
779
+ // satisfies the range is a hit, because that is the copy esbuild resolves.
780
+ if (baseDir === undefined) return IMAGE_NODE_MODULES.some((dir) => bakedSatisfies(name, range, dir));
781
+ let bakedVersion;
782
+ try {
783
+ bakedVersion = JSON.parse(fs.readFileSync(path.join(baseDir, name, 'package.json'), 'utf8')).version;
784
+ } catch { return false; }
785
+ if (!bakedVersion) return false;
786
+ const semver = getSemver();
787
+ if (!semver) return false;
788
+ // Empty / missing range means "any version" — the baked copy is fine.
789
+ const wanted = (typeof range === 'string' && range.trim()) ? range.trim() : '*';
790
+ if (semver.validRange(wanted) === null) return false; // 'latest', git/alias specs, etc.
791
+ try { return semver.satisfies(bakedVersion, wanted, { includePrerelease: false }); }
792
+ catch { return false; }
793
+ }
794
+
795
+ // Packages the React 19 isolated set provides. ensureDeps consults the baked 19
796
+ // for these BEFORE deciding to install — but ONLY when the baked 18 in
797
+ // IMAGE_NODE_MODULES does NOT satisfy the pin (i.e. the app actually wants 19).
798
+ const REACT19_PACKAGES = new Set(['react', 'react-dom']);
799
+
800
+ /**
801
+ * For a react/react-dom dependency that the default baked 18 does NOT satisfy:
802
+ * can the baked React-19 set satisfy it? Returns the absolute dir of the baked
803
+ * 19 package to symlink, or null (→ fall through to a per-build install of the
804
+ * project's exact pin — the incompatible-pin path, RULE-6). The same prefer-
805
+ * baked-but-respect-the-pin discipline as bakedSatisfies, just against the
806
+ * isolated react19/ node_modules.
807
+ */
808
+ function react19BakedDir(name, range) {
809
+ if (!REACT19_PACKAGES.has(name)) return null;
810
+ // A host may have no isolated React-19 set (the CLI's dep cache is one flat
811
+ // tree). Then there is nothing to prefer and the pin installs normally.
812
+ if (!REACT19_NODE_MODULES) return null;
813
+ if (!bakedSatisfies(name, range, REACT19_NODE_MODULES)) return null;
814
+ return path.join(REACT19_NODE_MODULES, name);
815
+ }
816
+
817
+ /**
818
+ * Resolve the customer's `dependencies` (directive flag #1 + invariant #2:
819
+ * resolved versions match local), with version-aware prefer-baked resolution.
820
+ * Per dependency, in order:
821
+ * 1. Already in THIS build's node_modules → nothing to do.
822
+ * 2. Baked in the image AND the baked version satisfies the requested range
823
+ * (bakedSatisfies) → use the baked copy, no install. This is the hot path:
824
+ * react + the top long-tail libs ship in the image, so most builds do zero
825
+ * `npm install` (perf) and the container barely reaches the registry (good
826
+ * for the egress-locked compile container).
827
+ * 3. Otherwise → `npm install` the declared version into the build root. This
828
+ * covers both un-baked long-tail deps AND the case where the project pinned
829
+ * a version the baked copy does NOT satisfy (step 2 returned false): the
830
+ * per-build install lands in the build root, which esbuild resolves before
831
+ * the baked nodePaths, so the project's pin wins.
832
+ * devDependencies (tailwind, vite, etc.) are NOT installed — the build toolchain
833
+ * is the container's job, not the app's.
834
+ */
835
+
836
+
837
+ async function ensureDeps(root, pkg, warnings, ctx, scopedProxyReady = true) {
838
+ const deps = (pkg && pkg.dependencies) || {};
839
+ const buildRoot = path.join(root, 'node_modules');
840
+ const missing = [];
841
+ // react/react-dom whose pin the baked 18 misses but the baked 19 satisfies:
842
+ // collect them to symlink from the isolated react19 set instead of installing.
843
+ // This is the tsk_2553662f hot-path fix — a React-19 app no longer triggers a
844
+ // cold full install of react + react-dom (which dragged the whole heavy dep
845
+ // tree into one over-budget job).
846
+ const react19Links = [];
847
+ for (const [name, range] of Object.entries(deps)) {
848
+ if (TOOLCHAIN_DEPS.has(name)) continue;
849
+ if (isResolvable(name, [buildRoot])) continue;
850
+ if (isResolvable(name, IMAGE_NODE_MODULES) && bakedSatisfies(name, range)) continue;
851
+ const r19 = react19BakedDir(name, range);
852
+ if (r19) { react19Links.push({ name, dir: r19 }); continue; }
853
+ // First-party platform packages (@somewhere-tech/*) install the DECLARED
854
+ // RANGE verbatim so npm resolves the latest satisfying version from the
855
+ // live registry (pfb_f607d6b27453): cleanRange's floor-pinning froze
856
+ // every ^-range scaffold on the SDK version it was scaffolded with, so
857
+ // published SDK fixes never reached new deploys. Third-party deps keep
858
+ // the floor-pin — that determinism choice is unchanged here.
859
+ const spec = name.startsWith('@somewhere-tech/')
860
+ ? `${name}@${String(range || 'latest').trim() || 'latest'}`
861
+ : `${name}@${cleanRange(range)}`;
862
+ missing.push(spec);
863
+ }
864
+ // Symlink the baked React-19 packages into the build root so esbuild resolves
865
+ // them from root/node_modules (which it consults before the baked nodePaths) —
866
+ // a single React instance per build, the baked 19, with zero install. A
867
+ // symlink failure is non-fatal: drop it back into `missing` so the dep still
868
+ // installs per-build (slower but correct), never a broken build.
869
+ if (react19Links.length) {
870
+ try { fs.mkdirSync(buildRoot, { recursive: true }); } catch { /* exists */ }
871
+ for (const { name, dir } of react19Links) {
872
+ const link = path.join(buildRoot, name);
873
+ try {
874
+ if (!fs.existsSync(link)) fs.symlinkSync(dir, link, 'dir');
875
+ } catch (e) {
876
+ warnings.push(`react19 link skipped for ${name}; installing per-build: ${e && e.message ? e.message : String(e)}`);
877
+ missing.push(`${name}@${cleanRange(deps[name])}`);
878
+ }
879
+ }
880
+ }
881
+ if (!missing.length) return;
882
+ if (!scopedProxyReady) {
883
+ throw codedError('NPM_PROXY_REQUIRED', `the scoped package proxy is required to resolve ${missing.length} dependency pin(s)`);
884
+ }
885
+ // Mark the build STAGE so the BUILD_TIMEOUT handler can report where it
886
+ // stalled (tsk_2553662f: a timeout used to be a bare "build exceeded Nms"
887
+ // with no hint whether npm install or esbuild was the culprit).
888
+ if (ctx && ctx.setStage) ctx.setStage('installing');
889
+ // WHICH packages to install and WHY is decided above and is the same
890
+ // everywhere. HOW the install runs is the one host-specific part: the
891
+ // container shells out to npm through its per-build scoped proxy with the
892
+ // BUILD_TIMEOUT controller wired to the child, the CLI installs into its
893
+ // own cache with no proxy. host.installPackages owns that and owns the
894
+ // NPM_INSTALL_TIMEOUT / real-failure error classification with it.
895
+ await host.installPackages({ root, specs: missing, ctx });
896
+ }
897
+
898
+ /**
899
+ * Parse the tsconfig `paths` aliases into a list of matchers. esbuild resolves
900
+ * these at build time (buildOpts.tsconfig is set), so a specifier that matches
901
+ * an alias is NOT a phantom import — without this the ubiquitous `@/*` → `src/*`
902
+ * alias would false-warn on every deploy that uses it (the jsxApp fixture, and
903
+ * most real Vite apps). Malformed/absent tsconfig → no aliases (never a crash).
904
+ * Each matcher is the key with a trailing `/*` stripped; a spec matches when it
905
+ * equals the matcher OR starts with `matcher + '/'`.
906
+ */
907
+ function tsconfigAliasMatchers(tsconfigText) {
908
+ if (typeof tsconfigText !== 'string') return [];
909
+ try {
910
+ // ts.parseConfigFileTextToJson tolerates JSONC (comments/trailing commas)
911
+ // that real tsconfigs carry; it's the same parser used by runReferenceCheck.
912
+ const ts = imageRequire('typescript');
913
+ const cfg = ts.parseConfigFileTextToJson('tsconfig.json', tsconfigText).config || {};
914
+ const paths = (cfg.compilerOptions && cfg.compilerOptions.paths) || {};
915
+ return Object.keys(paths).map((k) => k.replace(/\/\*$/, ''));
916
+ } catch {
917
+ return [];
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Build-CONFIG files, which are never in the browser graph.
923
+ *
924
+ * The platform IS the bundler, so a project's vite/rollup/webpack/postcss/
925
+ * tailwind config is never executed by anything the compiler emits. Scanning
926
+ * them for phantom imports produced a warning whose own premise was false —
927
+ * "if that code path runs, your app will fail to load it", for a code path
928
+ * that cannot run — on our OWN init scaffold, whose vite.config.ts imports
929
+ * vite and @vitejs/plugin-react (tsk_424174be).
930
+ */
931
+ function isBuildConfigFile(filePath) {
932
+ const base = filePath.split('/').pop() || '';
933
+ return /^(?:vite|vitest|rollup|webpack|next|nuxt|svelte|astro|remix|tailwind|postcss|babel|jest|playwright|cypress|eslint|prettier|stylelint)\.config\.[cm]?[jt]sx?$/i.test(base);
934
+ }
935
+
936
+ /** Make the implicit project root explicit for path aliases. */
937
+ function normalizeTsconfigText(tsconfigText) {
938
+ if (typeof tsconfigText !== 'string') return tsconfigText;
939
+ try {
940
+ const parsed = imageRequire('typescript').parseConfigFileTextToJson('tsconfig.json', tsconfigText).config;
941
+ const options = parsed && typeof parsed === 'object' ? parsed.compilerOptions : null;
942
+ if (options && typeof options === 'object' && options.paths && options.baseUrl === undefined) {
943
+ return JSON.stringify({ ...parsed, compilerOptions: { ...options, baseUrl: '.' } });
944
+ }
945
+ } catch { /* preserve malformed input so the compiler reports it */ }
946
+ return tsconfigText;
947
+ }
948
+
949
+ /**
950
+ * Scan `frontendFiles` for bare imports that are NOT resolvable through any of
951
+ * the mechanisms esbuild uses. Returns a Map<rootPkg, filePaths[]>.
952
+ *
953
+ * Called after ensureDeps so build-root node_modules reflects installed deps.
954
+ * `aliasMatchers` are the tsconfig `paths` prefixes esbuild also resolves.
955
+ * Wrapped in compile() so any unexpected error degrades to 0 findings, never
956
+ * a crash (same discipline as reviewDependencies).
957
+ */
958
+ function detectPhantomImports(frontendFiles, root, aliasMatchers = [], declaredDevDeps = []) {
959
+ const buildRootNm = path.join(root, 'node_modules');
960
+ const polyfillKeys = new Set(Object.keys(NODE_POLYFILLS));
961
+ const devDeclared = new Set(declaredDevDeps);
962
+ const isAliased = (spec) =>
963
+ aliasMatchers.some((a) => spec === a || spec.startsWith(a + '/'));
964
+ // Map<rootPkg, string[]> — files that import a phantom package
965
+ const phantom = new Map();
966
+ for (const [filePath, content] of Object.entries(frontendFiles)) {
967
+ if (typeof content !== 'string') continue;
968
+ // Only scan JS-family source files — CSS @import is a different resolver
969
+ if (!/\.(tsx?|jsx?|mjs|cjs|js)$/i.test(filePath)) continue;
970
+ // A build config is not in the browser graph at all (tsk_424174be).
971
+ if (isBuildConfigFile(filePath)) continue;
972
+ for (const spec of extractBareSpecifiers(content)) {
973
+ // node: builtins are handled by NODE_POLYFILLS aliases; skip independently
974
+ // (node:path is not a polyfillKey but is caught by the URL-shaped :// test
975
+ // in extractBareSpecifiers already — this is belt-and-suspenders).
976
+ if (spec.startsWith('node:')) continue;
977
+ // Resolvable through a tsconfig `paths` alias (e.g. `@/App`) — esbuild
978
+ // resolves these, so they are never phantom. Check the FULL specifier
979
+ // (aliases like `@/App` have no npm-style root package).
980
+ if (isAliased(spec)) continue;
981
+ const rootPkg = specRootPkg(spec);
982
+ // DECLARED in devDependencies. ensureDeps deliberately installs only
983
+ // `dependencies` (the build toolchain is the platform's job, not the
984
+ // app's), so a devDependency is never resolvable here — but it IS
985
+ // declared, and "add it to your package.json" is already done. Warning
986
+ // about it is a false positive with wrong remediation (tsk_424174be).
987
+ if (devDeclared.has(rootPkg)) continue;
988
+ // Resolvable through NODE_POLYFILLS alias (e.g. 'buffer', 'process')
989
+ if (polyfillKeys.has(rootPkg)) continue;
990
+ // Resolvable from per-build install (declared in package.json + installed)
991
+ if (isResolvable(rootPkg, [buildRootNm])) continue;
992
+ // Resolvable from the pre-baked image (react@18, zustand, lucide-react, …)
993
+ if (isResolvable(rootPkg, IMAGE_NODE_MODULES)) continue;
994
+ // Resolvable from the isolated React-19 baked set
995
+ if (REACT19_NODE_MODULES && isResolvable(rootPkg, [REACT19_NODE_MODULES])) continue;
996
+ // Not resolvable through any means — phantom import
997
+ if (!phantom.has(rootPkg)) phantom.set(rootPkg, []);
998
+ phantom.get(rootPkg).push(filePath);
999
+ }
1000
+ }
1001
+ return phantom;
1002
+ }
1003
+
1004
+ /**
1005
+ * Real Tailwind + PostCSS over the customer's CSS — the headline of the
1006
+ * migration. v3 (`@tailwind`/config) and v4 (`@import "tailwindcss"`) run their
1007
+ * actual engines against the materialized filesystem, so JIT scanning sees the
1008
+ * source and emits the utilities the app uses. No runtime CDN, no `sw-css-noop`.
1009
+ * Returns an esbuild onLoad plugin scoped to `.css`.
1010
+ *
1011
+ * cwd is the build root for the duration of the build (set by caller), so
1012
+ * Tailwind v3 auto-loads the customer's tailwind.config.js (via jiti, ESM/TS
1013
+ * configs included) and resolves its `content` globs relative to the project,
1014
+ * exactly like `vite build` locally.
1015
+ */
1016
+ function tailwindCssPlugin(twVersion, root) {
1017
+ const postcss = imageRequire('postcss');
1018
+ const autoprefixer = imageRequire('autoprefixer');
1019
+
1020
+ function buildProcessor() {
1021
+ if (twVersion === 4) {
1022
+ const tw4 = tw4Require('@tailwindcss/postcss');
1023
+ return postcss([tw4({ base: root }), autoprefixer]);
1024
+ }
1025
+ const tailwind3 = imageRequire('tailwindcss');
1026
+ const hasConfig = ['tailwind.config.js', 'tailwind.config.cjs', 'tailwind.config.mjs', 'tailwind.config.ts']
1027
+ .some((f) => fs.existsSync(path.join(root, f)));
1028
+ // With a config present, tailwind v3 auto-loads it from cwd (=root). Without
1029
+ // one (utility classes but no config file), give it explicit content globs
1030
+ // so JIT still scans the source instead of erroring/emitting nothing.
1031
+ const tw = hasConfig
1032
+ ? tailwind3()
1033
+ : tailwind3({ content: [path.join(root, '**/*.{html,js,ts,jsx,tsx,mjs,cjs}')], theme: { extend: {} }, plugins: [] });
1034
+ return postcss([tw, autoprefixer]);
1035
+ }
1036
+
1037
+ return {
1038
+ name: 'somewhere-tailwind-css',
1039
+ setup(build) {
1040
+ const processor = buildProcessor();
1041
+ build.onLoad({ filter: /\.css$/ }, async (args) => {
1042
+ // CSS Modules (*.module.css) are class-name maps, not stylesheets to run
1043
+ // Tailwind over — let esbuild's native local-css loader handle them.
1044
+ // (Go RE2 has no lookbehind, so we filter all .css then bail here.)
1045
+ if (/\.module\.css$/i.test(args.path)) return undefined;
1046
+ let raw;
1047
+ try { raw = await fsp.readFile(args.path, 'utf8'); }
1048
+ catch (e) { return { errors: [{ text: `Cannot read CSS ${args.path}: ${e.message}` }] }; }
1049
+ try {
1050
+ const result = await processor.process(raw, { from: args.path, to: args.path });
1051
+ return { contents: result.css, loader: 'css' };
1052
+ } catch (e) {
1053
+ // Invariant #6: a Tailwind/PostCSS failure is a loud, actionable
1054
+ // compile error — never empty CSS served as "success".
1055
+ return { errors: [{ text: `Tailwind/PostCSS failed on ${path.relative(root, args.path)}: ${e.message}` }] };
1056
+ }
1057
+ });
1058
+ },
1059
+ };
1060
+ }
1061
+
1062
+ /**
1063
+ * PR5 — tsc --noEmit type checking (directive Tier 1, invariant #6). Opt-in per
1064
+ * request (`typecheck:true`) so the deploy hot-path stays fast; the /v1/compile
1065
+ * inspect-fix loop turns it on. Reports type errors in the customer's OWN typed
1066
+ * code. Module-resolution noise (TS2307) and implicit-any-from-untyped-deps
1067
+ * (TS7016) are filtered: esbuild already proved every import resolves, so a
1068
+ * TS2307 here only means "this dep ships no .d.ts" — not an app error. Untyped
1069
+ * externals collapse to `any`, so real errors in the customer's annotations
1070
+ * (wrong shapes, bad generics) still surface. NON-blocking: type errors are
1071
+ * reported as metadata, never fail the build (rule 9 — surface before gating).
1072
+ */
1073
+ const TSC_IGNORE_CODES = new Set([2307, 7016, 2792, 6053]);
1074
+ function runTypecheck(root, tsconfigText) {
1075
+ const ts = imageRequire('typescript');
1076
+ const tsFiles = [];
1077
+ (function walk(dir) {
1078
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1079
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
1080
+ const p = path.join(dir, e.name);
1081
+ if (e.isDirectory()) walk(p);
1082
+ else if (/\.tsx?$/.test(e.name)) tsFiles.push(p);
1083
+ }
1084
+ })(root);
1085
+ if (!tsFiles.length) return { errors: [], checkedFiles: 0 };
1086
+
1087
+ let userOpts = {};
1088
+ if (typeof tsconfigText === 'string') {
1089
+ try { userOpts = (ts.parseConfigFileTextToJson('tsconfig.json', tsconfigText).config || {}).compilerOptions || {}; }
1090
+ catch { /* malformed tsconfig — use the lenient default below */ }
1091
+ }
1092
+ const { options } = ts.convertCompilerOptionsFromJson(
1093
+ {
1094
+ jsx: 'react-jsx', target: 'es2022', module: 'esnext', moduleResolution: 'bundler',
1095
+ esModuleInterop: true, skipLibCheck: true, strict: false, noImplicitAny: false,
1096
+ allowJs: false, ...userOpts, noEmit: true,
1097
+ },
1098
+ root,
1099
+ );
1100
+ const program = ts.createProgram(tsFiles, options);
1101
+ const errors = [];
1102
+ for (const d of ts.getPreEmitDiagnostics(program)) {
1103
+ if (d.category !== ts.DiagnosticCategory.Error) continue;
1104
+ if (d.code && TSC_IGNORE_CODES.has(d.code)) continue;
1105
+ const message = ts.flattenDiagnosticMessageText(d.messageText, '\n');
1106
+ let loc = {};
1107
+ if (d.file && d.start != null) {
1108
+ const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
1109
+ loc = { file: path.relative(root, d.file.fileName), line: line + 1, col: character + 1 };
1110
+ }
1111
+ errors.push({ code: `TS${d.code}`, message, ...loc });
1112
+ }
1113
+ return { errors, checkedFiles: tsFiles.length };
1114
+ }
1115
+
1116
+ /**
1117
+ * Deploy-validation reference check (tsk §1 deploy-validation-gate). The
1118
+ * narrow, ALWAYS-SAFE cousin of runTypecheck, purpose-built for the deploy
1119
+ * gate that flags an UNDEFINED REFERENCE — the Bolo `sanitizeForSpeech is
1120
+ * not defined` class: code esbuild bundles past (valid syntax) but that
1121
+ * throws at runtime because nothing in the graph defines the name.
1122
+ *
1123
+ * Differences from runTypecheck that make it catch that class without
1124
+ * false-positiving on normal customer code:
1125
+ * - Walks .js/.jsx TOO (allowJs:true + checkJs:true). The Bolo bug was in
1126
+ * a .jsx; runTypecheck's allowJs:false skips those files entirely.
1127
+ * - Includes the DOM lib so window/fetch/localStorage/navigator/etc.
1128
+ * resolve — without it every browser global reads as an undefined name
1129
+ * (a wall of false positives on real frontend code).
1130
+ * - Filters module-resolution noise (TS2307 etc., same set as
1131
+ * runTypecheck) AND scopes the SURFACED set to reference codes only on
1132
+ * the worker side — here we return EVERY non-ignored error and let the
1133
+ * worker pick the reference subset, so the container stays the single
1134
+ * source of "what tsc said" and the gate's policy lives in one place.
1135
+ *
1136
+ * Returns the same {code, message, file, line, col} shape as runTypecheck.
1137
+ * Never throws into compile() — the caller wraps it.
1138
+ */
1139
+ function runReferenceCheck(root, tsconfigText) {
1140
+ const ts = imageRequire('typescript');
1141
+ const checkFiles = [];
1142
+ (function walk(dir) {
1143
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1144
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
1145
+ const p = path.join(dir, e.name);
1146
+ if (e.isDirectory()) walk(p);
1147
+ else if (/\.(?:tsx?|jsx?|mjs|cjs)$/.test(e.name)) checkFiles.push(p);
1148
+ }
1149
+ })(root);
1150
+ if (!checkFiles.length) return { errors: [], checkedFiles: 0 };
1151
+
1152
+ let userOpts = {};
1153
+ if (typeof tsconfigText === 'string') {
1154
+ try { userOpts = (ts.parseConfigFileTextToJson('tsconfig.json', tsconfigText).config || {}).compilerOptions || {}; }
1155
+ catch { /* malformed tsconfig — use the lenient default below */ }
1156
+ }
1157
+ const { options } = ts.convertCompilerOptionsFromJson(
1158
+ {
1159
+ jsx: 'react-jsx', target: 'es2022', module: 'esnext', moduleResolution: 'bundler',
1160
+ esModuleInterop: true, skipLibCheck: true, strict: false, noImplicitAny: false,
1161
+ // DOM + es2022 so browser globals (window/fetch/localStorage/…) resolve
1162
+ // and don't read as undefined names — the whole reason this differs from
1163
+ // runTypecheck. NOTE: convertCompilerOptionsFromJson expects the SHORT
1164
+ // lib names ('es2022','dom'), not the lib.*.d.ts filenames (those are
1165
+ // rejected → empty lib → every browser global false-positives).
1166
+ lib: ['es2022', 'dom', 'dom.iterable'],
1167
+ ...userOpts,
1168
+ // These are forced ON regardless of the customer's tsconfig — the gate
1169
+ // exists to check .js/.jsx for undefined refs, and a customer
1170
+ // allowJs:false would silently disable it.
1171
+ allowJs: true, checkJs: true, noEmit: true,
1172
+ },
1173
+ root,
1174
+ );
1175
+ const program = ts.createProgram(checkFiles, options);
1176
+ const errors = [];
1177
+ for (const d of ts.getPreEmitDiagnostics(program)) {
1178
+ if (d.category !== ts.DiagnosticCategory.Error) continue;
1179
+ if (d.code && TSC_IGNORE_CODES.has(d.code)) continue;
1180
+ const message = ts.flattenDiagnosticMessageText(d.messageText, '\n');
1181
+ let loc = {};
1182
+ if (d.file && d.start != null) {
1183
+ const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
1184
+ loc = { file: path.relative(root, d.file.fileName), line: line + 1, col: character + 1 };
1185
+ }
1186
+ errors.push({ code: `TS${d.code}`, message, ...loc });
1187
+ }
1188
+ return { errors, checkedFiles: checkFiles.length };
1189
+ }
1190
+
1191
+ async function compileTransforms(body) {
1192
+ const entries = Array.isArray(body.entries) ? body.entries : [];
1193
+ if (!entries.length) throw new Error('`entries` is required');
1194
+ const transforms = {};
1195
+ for (const entry of entries) {
1196
+ assertSourcePath(entry);
1197
+ const source = body.files[entry];
1198
+ const loader = sourceLoader(entry);
1199
+ if (typeof source !== 'string' || (loader !== 'jsx' && loader !== 'tsx')) {
1200
+ throw new Error(`transform entry ${JSON.stringify(entry)} must be a .jsx or .tsx source file`);
1201
+ }
1202
+ const out = await esbuild.transform(source, {
1203
+ loader,
1204
+ format: 'esm',
1205
+ target: 'es2022',
1206
+ jsx: 'automatic',
1207
+ sourcemap: 'inline',
1208
+ sourcefile: entry,
1209
+ logLevel: 'silent',
1210
+ });
1211
+ transforms[entry] = { js: out.code, warnings: out.warnings.map((warning) => warning.text) };
1212
+ }
1213
+ return { ok: true, transforms, warnings: [], compiler: compilerStamp() };
1214
+ }
1215
+
1216
+ async function parseSources(body) {
1217
+ const errors = [];
1218
+ for (const [file, source] of Object.entries(body.files)) {
1219
+ if (typeof source !== 'string') continue;
1220
+ const loader = sourceLoader(file);
1221
+ if (!loader || loader === 'json') continue;
1222
+ try {
1223
+ await esbuild.transform(source, { loader, target: 'esnext', format: 'esm', logLevel: 'silent', sourcefile: file });
1224
+ } catch (err) {
1225
+ const messages = Array.isArray(err && err.errors) ? err.errors : [];
1226
+ if (messages.length) {
1227
+ for (const message of messages.slice(0, 5)) {
1228
+ errors.push({
1229
+ file,
1230
+ line: message.location && message.location.line,
1231
+ column: message.location ? message.location.column + 1 : undefined,
1232
+ message: message.text,
1233
+ line_text: message.location && message.location.lineText,
1234
+ });
1235
+ }
1236
+ } else {
1237
+ errors.push({ file, message: err && err.message ? err.message : String(err) });
1238
+ }
1239
+ if (errors.length >= 20) break;
1240
+ }
1241
+ }
1242
+ return { ok: true, errors, warnings: [], compiler: compilerStamp() };
1243
+ }
1244
+
1245
+ async function compileFunctionEntries(root, entries, tsconfigText) {
1246
+ if (!entries.length) return { functions: {}, warnings: [], metafile: null, graph: { edges: [] } };
1247
+ const entryPoints = {};
1248
+ const outputNames = new Map();
1249
+ entries.forEach((entry, index) => {
1250
+ const name = `__sw_function_${String(index).padStart(4, '0')}`;
1251
+ entryPoints[name] = entry;
1252
+ outputNames.set(`${name}.js`, entry);
1253
+ });
1254
+ const buildOptions = {
1255
+ absWorkingDir: root,
1256
+ entryPoints,
1257
+ bundle: true,
1258
+ splitting: false,
1259
+ format: 'esm',
1260
+ platform: 'neutral',
1261
+ target: 'es2022',
1262
+ jsx: 'automatic',
1263
+ sourcemap: 'inline',
1264
+ write: false,
1265
+ metafile: true,
1266
+ outdir: 'function-out',
1267
+ entryNames: '[name]',
1268
+ nodePaths: IMAGE_NODE_MODULES,
1269
+ external: ['node:*', 'cloudflare:*', 'workerd:*'],
1270
+ define: {
1271
+ 'import.meta.env.DEV': 'false',
1272
+ 'import.meta.env.PROD': 'true',
1273
+ 'import.meta.env.MODE': '"production"',
1274
+ 'import.meta.env.SSR': 'true',
1275
+ 'import.meta.env.BASE_URL': '"/"',
1276
+ 'process.env.NODE_ENV': '"production"',
1277
+ },
1278
+ plugins: [functionLocalImportPlugin(root)],
1279
+ };
1280
+ const tsconfigPath = path.join(root, 'tsconfig.json');
1281
+ if (fs.existsSync(tsconfigPath)) buildOptions.tsconfig = tsconfigPath;
1282
+ const result = await esbuild.build(buildOptions);
1283
+ const completeGraph = graphFromMetafile(result.metafile, root);
1284
+ const functions = {};
1285
+ for (const output of result.outputFiles || []) {
1286
+ const entry = outputNames.get(path.basename(output.path));
1287
+ if (!entry) continue;
1288
+ functions[entry] = {
1289
+ js: output.text,
1290
+ inlined: [entry],
1291
+ warnings: [],
1292
+ };
1293
+ }
1294
+ for (const entry of entries) {
1295
+ if (!functions[entry]) throw new Error(`bundler emitted no output for ${entry}`);
1296
+ }
1297
+ return { functions, warnings: result.warnings.map((warning) => warning.text), metafile: result.metafile, graph: completeGraph };
1298
+ }
1299
+
1300
+ async function compile(body, ctx) {
1301
+ validateCompileInput(body);
1302
+ const operation = body.operation || 'project';
1303
+ if (operation === 'parse') return parseSources(body);
1304
+ if (operation !== 'project') throw new Error(`unsupported compile operation: ${operation}`);
1305
+ const result = await compileProject(body, ctx);
1306
+ const stamped = { ...result, compiler: compilerStamp(result.source_digest) };
1307
+ enforceInlineResultCap(body, stamped);
1308
+ return stamped;
1309
+ }
1310
+
1311
+ // ctx (tsk_2553662f) carries the BUILD_TIMEOUT plumbing: `signal` (the
1312
+ // AbortController the handler aborts on timeout), `setStage(name)` so the
1313
+ // timeout 504 can report which stage stalled, and `npmChild` (the in-flight npm
1314
+ // process, so the abort kills it). Optional — the in-process harness/test path
1315
+ // calls compile(body) with no ctx and just runs to completion.
1316
+ async function compileProject(body, ctx) {
1317
+ const files = { ...body.files };
1318
+ const requestedEntry = typeof body.entry === 'string' && body.entry ? body.entry : null;
1319
+ const entry = requestedEntry || '__somewhere_empty_entry.mjs';
1320
+ if (!files || typeof files !== 'object') throw new Error('`files` map is required');
1321
+ if (requestedEntry && files[entry] === undefined) throw new Error(`entry "${entry}" not in files map`);
1322
+ if (!requestedEntry) files[entry] = 'export default null;\n';
1323
+ const functionEntries = [...new Set(body.function_entries || [])].sort();
1324
+ const functionPaths = new Set(body.function_paths || functionEntries);
1325
+ const transformEntries = [...new Set(body.transform_entries || [])]
1326
+ .filter((item) => item !== requestedEntry)
1327
+ .sort();
1328
+ for (const functionEntry of functionEntries) {
1329
+ if (typeof files[functionEntry] !== 'string') throw new Error(`function entry ${JSON.stringify(functionEntry)} not in files map`);
1330
+ }
1331
+ for (const transformEntry of transformEntries) {
1332
+ if (typeof files[transformEntry] !== 'string') throw new Error(`transform entry ${JSON.stringify(transformEntry)} not in files map`);
1333
+ }
1334
+ const digest = sourceDigest(body);
1335
+ const parsed = await parseSources({ files: body.files });
1336
+ if (parsed.errors.length) {
1337
+ const err = codedError('SOURCE_PARSE_ERROR', parsed.errors.map((error) => `${error.file}: ${error.message}`).join('; '));
1338
+ // The joined message keeps its exact existing text (the worker's error
1339
+ // copy reads it), but the STRUCTURED errors ride along: {file, line,
1340
+ // column, message, line_text} per bad file. A caller that wants to point
1341
+ // at the offending line — the local dev loop's terminal and its in-page
1342
+ // overlay — should not have to regex a paragraph back apart.
1343
+ err.source_errors = parsed.errors;
1344
+ throw err;
1345
+ }
1346
+
1347
+ // Classify functions out of the frontend graph. Every routed entry is still
1348
+ // compiled below in THIS transaction, from the same materialized root and
1349
+ // resolved dependency tree.
1350
+ const functionRoutes = [];
1351
+ const frontendFiles = {};
1352
+ for (const p of Object.keys(files)) {
1353
+ if (functionPaths.has(p) || isFunctionPath(p)) functionRoutes.push(p);
1354
+ else frontendFiles[p] = files[p];
1355
+ }
1356
+
1357
+ const packageJsonText = typeof body.package_json === 'string'
1358
+ ? body.package_json
1359
+ : files['package.json'];
1360
+ const tsconfigText = normalizeTsconfigText(
1361
+ typeof body.tsconfig === 'string' ? body.tsconfig : files['tsconfig.json'],
1362
+ );
1363
+ let pkg = {};
1364
+ if (typeof packageJsonText === 'string') {
1365
+ try { pkg = JSON.parse(packageJsonText); } catch { /* malformed → no extra deps */ }
1366
+ }
1367
+ const viteEnv = body.vite_env || {};
1368
+ const twVersion = detectTailwind(files);
1369
+ const warnings = [];
1370
+
1371
+ // Per-phase wall-clock instrumentation (tsk_241c7c9f). Date.now() is fine
1372
+ // here — real Node in a container. Each value is ms for that phase; null =
1373
+ // phase did not run on this path. Echoed under metadata.timing_ms, purely
1374
+ // additive (no existing metadata field touched).
1375
+ const _t0 = Date.now();
1376
+ const timing = { materialize: null, install: null, typed_functions: null, bundle: null, refcheck: null, upload: null, total: null };
1377
+ const _ms = (start) => Date.now() - start;
1378
+
1379
+ const _tMat = Date.now();
1380
+ const root = await materialize(files, body.binary_files);
1381
+ if (typeof packageJsonText === 'string') {
1382
+ await fsp.writeFile(path.join(root, 'package.json'), packageJsonText, 'utf8').catch(() => {});
1383
+ }
1384
+ if (typeof tsconfigText === 'string') {
1385
+ await fsp.writeFile(path.join(root, 'tsconfig.json'), tsconfigText, 'utf8').catch(() => {});
1386
+ }
1387
+ timing.materialize = _ms(_tMat);
1388
+
1389
+ // ── validate_only fast path (tsk §1 deploy-validation-gate) ────────────
1390
+ // The deploy validation gate asks ONLY "does the module graph have an
1391
+ // undefined reference?" — it does NOT want a bundle, Tailwind, or npm
1392
+ // install (the slow parts). Run just the reference check over the
1393
+ // materialized graph and return metadata.validation_errors. No esbuild,
1394
+ // no chdir, no chunk emit. Wrapped so a tsc crash degrades to "no
1395
+ // validation errors" (ran-but-empty is indistinguishable from a clean
1396
+ // graph here, which is the safe direction — the worker treats an absent
1397
+ // validation_errors array as "not reference-checked", never as "clean").
1398
+ if (body.validate_only) {
1399
+ let validationErrors = null;
1400
+ const _tRef = Date.now();
1401
+ try {
1402
+ validationErrors = runReferenceCheck(root, tsconfigText).errors;
1403
+ } catch (e) {
1404
+ warnings.push(`reference check skipped: ${e && e.message ? e.message : String(e)}`);
1405
+ }
1406
+ timing.refcheck = _ms(_tRef);
1407
+ await fsp.rm(root, { recursive: true, force: true }).catch(() => {});
1408
+ timing.total = _ms(_t0);
1409
+ return {
1410
+ ok: true,
1411
+ validate_only: true,
1412
+ warnings,
1413
+ metadata: { validation_errors: validationErrors, timing_ms: timing },
1414
+ };
1415
+ }
1416
+
1417
+ const prevCwd = process.cwd();
1418
+ try {
1419
+ // PR2 npm proxy — when the deploy path supplies a registry+token, route this
1420
+ // build's `npm install` through the worker /v1/npm cache. Written as a
1421
+ // build-root .npmrc; ensureDeps runs npm with cwd=root so it's picked up.
1422
+ // always-auth so the bearer rides tarball GETs too. The token is a
1423
+ // short-lived proxy capability, NOT a registry credential (credential desert
1424
+ // preserved). Without it, a build that needs an install fails closed.
1425
+ const hasScopedProxy = typeof body.npm_registry === 'string' && !!body.npm_registry
1426
+ && typeof body.npm_token === 'string' && !!body.npm_token;
1427
+ if (hasScopedProxy) {
1428
+ try {
1429
+ const reg = body.npm_registry.endsWith('/') ? body.npm_registry : body.npm_registry + '/';
1430
+ const authKey = reg.replace(/^https?:/, '');
1431
+ await fsp.writeFile(
1432
+ path.join(root, '.npmrc'),
1433
+ `registry=${reg}\n${authKey}:_authToken=${body.npm_token}\nalways-auth=true\n`,
1434
+ 'utf8',
1435
+ );
1436
+ } catch (e) {
1437
+ warnings.push(`npm proxy config skipped: ${e && e.message ? e.message : String(e)}`);
1438
+ }
1439
+ }
1440
+
1441
+ // Resolve the long-tail deps before building (invariant #2). Awaited +
1442
+ // abortable now (tsk_2553662f): a heavy install can be killed by the
1443
+ // BUILD_TIMEOUT instead of blocking the event loop past the budget.
1444
+ const _tInstall = Date.now();
1445
+ // Two different questions. `hasScopedProxy` is "did this REQUEST carry
1446
+ // proxy credentials", and it decides the .npmrc above. Whether an
1447
+ // install may run AT ALL without them is a property of the HOST: the
1448
+ // container is egress-locked and must fail closed, the CLI reaches npm
1449
+ // directly through the developer's own config. Default is the container's
1450
+ // refusal, so opening it is always deliberate.
1451
+ await ensureDeps(root, pkg, warnings, ctx, hasScopedProxy || host.requiresPackageProxy === false);
1452
+ timing.install = _ms(_tInstall);
1453
+
1454
+ // Dependency review (PR1) — WARN-FIRST, non-blocking. A scanner bug must
1455
+ // never break a build (that's the exact rule-9 anti-pattern), so it's
1456
+ // wrapped: on error we say so in warnings and continue without the block.
1457
+ let dependencyReview = null;
1458
+ try {
1459
+ dependencyReview = reviewDependencies(root, pkg);
1460
+ if (dependencyReview.knownBad.length) {
1461
+ const list = dependencyReview.knownBad.map((b) => `${b.name}@${b.version}`).join(', ');
1462
+ const n = dependencyReview.knownBad.length;
1463
+ warnings.push(
1464
+ `Security: ${n} ${n === 1 ? 'dependency resolves' : 'dependencies resolve'} to a release flagged as compromised in a known npm supply-chain incident: ${list}. Your build completed — this is a warning, not a block — but we strongly recommend pinning a safe version.`,
1465
+ );
1466
+ }
1467
+ } catch (e) {
1468
+ warnings.push(`dependency review skipped: ${e && e.message ? e.message : String(e)}`);
1469
+ }
1470
+
1471
+ // Tailwind v3 config auto-load + content scanning resolve relative to cwd.
1472
+ process.chdir(root);
1473
+
1474
+ // v4's `@import "tailwindcss"` is resolved (by @tailwindcss/postcss's
1475
+ // resolver) relative to the importing CSS file — it expects the project to
1476
+ // have `tailwindcss` installed. We substitute the isolated v4 engine, so
1477
+ // link it into the build root's node_modules where the resolver walks up
1478
+ // to find it. (No-op if the customer somehow shipped their own.)
1479
+ if (twVersion === 4) {
1480
+ const nm = path.join(root, 'node_modules');
1481
+ await fsp.mkdir(nm, { recursive: true });
1482
+ const link = path.join(nm, 'tailwindcss');
1483
+ if (!fs.existsSync(link)) {
1484
+ if (host.tw4TailwindDir) await fsp.symlink(host.tw4TailwindDir, link, 'dir').catch(() => {});
1485
+ }
1486
+ }
1487
+
1488
+ // Typed server functions are sentinel-gated. analyzeTypedFunctions returns
1489
+ // after one string scan when no exact `somewhere:v1` candidate exists, so
1490
+ // ordinary deploys never construct a TypeScript Program. Stage 1 is
1491
+ // warning-only: an analyzer defect must not reject otherwise-working code.
1492
+ let typedAnalysis = {
1493
+ manifest: null,
1494
+ summary: { procedures: 0, contract_digest: null, warnings: 0 },
1495
+ warnings: [],
1496
+ declaration: null,
1497
+ runtime: typedFunctions.RUNTIME_SOURCE,
1498
+ timing_ms: { scan: 0, typecheck: 0, total: 0 },
1499
+ };
1500
+ try {
1501
+ typedAnalysis = typedFunctions.analyzeTypedFunctions({
1502
+ loadTypescript: () => imageRequire('typescript'),
1503
+ root,
1504
+ files,
1505
+ functionEntries,
1506
+ tsconfigText,
1507
+ });
1508
+ warnings.push(...typedAnalysis.warnings);
1509
+ } catch (e) {
1510
+ warnings.push(`Typed function check skipped; this deploy continued unchanged. Fix: retry the deploy, then contact support if the warning repeats. (${e && e.message ? e.message : String(e)})`);
1511
+ }
1512
+ timing.typed_functions = typedAnalysis.timing_ms;
1513
+
1514
+ const tsconfigPath = path.join(root, 'tsconfig.json');
1515
+ const buildOpts = {
1516
+ absWorkingDir: root,
1517
+ entryPoints: [entry],
1518
+ bundle: true,
1519
+ splitting: true,
1520
+ format: 'esm',
1521
+ target: 'es2022',
1522
+ jsx: 'automatic',
1523
+ sourcemap: 'inline',
1524
+ write: false,
1525
+ metafile: true,
1526
+ outdir: 'out',
1527
+ entryNames: '[name]-[hash]',
1528
+ chunkNames: 'chunk-[hash]',
1529
+ assetNames: 'asset-[hash]',
1530
+ // A .js entry only ever comes from the CRA adapter (module-script
1531
+ // detection is .jsx/.tsx-only, so no pre-existing project can reach
1532
+ // here with one) — CRA allows JSX in .js files, so parse all .js as
1533
+ // JSX for those builds. The jsx loader is a superset of js.
1534
+ loader: { ...ASSET_LOADERS, ...(/\.js$/i.test(entry) ? { '.js': 'jsx' } : {}) },
1535
+ // esbuild resolves bare deps from root/node_modules first, then the
1536
+ // pre-baked image set (flag #1). Single node_modules tree per build =
1537
+ // single React instance, without the retired CDN dependency workaround.
1538
+ nodePaths: IMAGE_NODE_MODULES,
1539
+ // Node builtins → browser polyfills (so packages importing buffer/stream/
1540
+ // etc. resolve instead of hard-erroring). esbuild resolves these targets
1541
+ // from the pre-baked node_modules via nodePaths.
1542
+ alias: { ...NODE_POLYFILLS },
1543
+ define: {
1544
+ 'import.meta.env.DEV': 'false',
1545
+ 'import.meta.env.PROD': 'true',
1546
+ 'import.meta.env.MODE': '"production"',
1547
+ 'import.meta.env.SSR': 'false',
1548
+ 'import.meta.env.BASE_URL': '"/"',
1549
+ 'process.env.NODE_ENV': '"production"',
1550
+ // Node polyfills (the buffer/process packages) reference the `global`
1551
+ // identifier, which the browser doesn't define. Map it to globalThis.
1552
+ global: 'globalThis',
1553
+ ...buildViteEnvDefines(viteEnv, files),
1554
+ ...buildReactEnvDefines(viteEnv, files),
1555
+ },
1556
+ plugins: [
1557
+ typedFunctions.virtualApiPlugin(esbuild),
1558
+ urlAssetPlugin(root, body.binary_paths),
1559
+ svgPlugin(root),
1560
+ ...(twVersion > 0 ? [tailwindCssPlugin(twVersion, root)] : []),
1561
+ ],
1562
+ };
1563
+ if (fs.existsSync(tsconfigPath)) buildOpts.tsconfig = tsconfigPath;
1564
+
1565
+ // Stage marker (tsk_2553662f): we're past install, now bundling. If the
1566
+ // BUILD_TIMEOUT fires during esbuild, its 504 reports stage='bundling'.
1567
+ if (ctx && ctx.setStage) ctx.setStage('bundling');
1568
+ // Use an esbuild CONTEXT so a BUILD_TIMEOUT abort can CANCEL the in-flight
1569
+ // build (and dispose the worker) instead of leaking it. Without a signal
1570
+ // (the local harness) this is a plain build()+dispose, behaviourally
1571
+ // identical to the old esbuild.build() call.
1572
+ let result;
1573
+ const _tBundle = Date.now();
1574
+ const bctx = await esbuild.context(buildOpts);
1575
+ try {
1576
+ const onAbort = () => { bctx.cancel().catch(() => {}); };
1577
+ if (ctx && ctx.signal) {
1578
+ if (ctx.signal.aborted) onAbort();
1579
+ else ctx.signal.addEventListener('abort', onAbort, { once: true });
1580
+ }
1581
+ result = await bctx.rebuild();
1582
+ if (ctx && ctx.signal) ctx.signal.removeEventListener('abort', onAbort);
1583
+ } finally {
1584
+ await bctx.dispose();
1585
+ timing.bundle = _ms(_tBundle);
1586
+ }
1587
+ for (const w of result.warnings) warnings.push(w.text);
1588
+
1589
+ // Phantom-import detection (tsk_8e460b69): esbuild tree-shakes unused named
1590
+ // imports before resolution, so `import { x } from 'missing-pkg'` where `x`
1591
+ // is never referenced silently succeeds. Scan the source for bare specifiers
1592
+ // that are NOT resolvable through any of esbuild's search paths and surface
1593
+ // them as warnings. RULE 9 — always non-blocking; scanner errors degrade to
1594
+ // 0 findings, never a crash.
1595
+ try {
1596
+ const aliasMatchers = tsconfigAliasMatchers(tsconfigText);
1597
+ const phantom = detectPhantomImports(
1598
+ frontendFiles, root, aliasMatchers, Object.keys((pkg && pkg.devDependencies) || {}),
1599
+ );
1600
+ for (const [pkg, files] of phantom) {
1601
+ const uniq = [...new Set(files)];
1602
+ const where = uniq.slice(0, 2).join(', ') + (uniq.length > 2 ? ` and ${uniq.length - 2} other file(s)` : '');
1603
+ warnings.push(
1604
+ `\`${pkg}\` is imported in ${where} but is not in your package.json and could not be resolved — if that code path runs, your app will fail to load it. Add it to the \`dependencies\` in your package.json.`,
1605
+ );
1606
+ }
1607
+ } catch (e) {
1608
+ warnings.push(`phantom-import scan skipped: ${e && e.message ? e.message : String(e)}`);
1609
+ }
1610
+
1611
+ const entryBase = entry.split('/').pop().replace(/\.[^.]+$/, '');
1612
+ const chunks = {};
1613
+ let entryChunk = null;
1614
+ for (const f of result.outputFiles || []) {
1615
+ const name = f.path.split('/').pop();
1616
+ if (requestedEntry) {
1617
+ chunks[name] = f.text;
1618
+ if (!entryChunk && name.startsWith(`${entryBase}-`) && name.endsWith('.js')) entryChunk = name;
1619
+ }
1620
+ }
1621
+ if (requestedEntry && !entryChunk) throw new Error('bundler emitted no entry chunk');
1622
+
1623
+ // Every routed function is compiled in one batched esbuild invocation from
1624
+ // this same root/dependency tree. No per-entry container request exists.
1625
+ if (ctx && ctx.setStage) ctx.setStage('bundling-functions');
1626
+ const functionBuild = await compileFunctionEntries(root, functionEntries, tsconfigText);
1627
+ warnings.push(...functionBuild.warnings);
1628
+
1629
+ // Independently referenced JSX/TSX module entries are transformation
1630
+ // artifacts of this same complete project transaction, not another
1631
+ // publication request.
1632
+ const transformBuild = transformEntries.length
1633
+ ? await compileTransforms({ entries: transformEntries, files })
1634
+ : { transforms: {} };
1635
+ const transforms = transformBuild.transforms || {};
1636
+
1637
+ // PR5 — opt-in type checking. Non-blocking: a type error is reported in
1638
+ // metadata, never a build failure (rule 9). A tsc crash degrades to a
1639
+ // warning, not a lost build.
1640
+ let typeErrors = null;
1641
+ if (body.typecheck) {
1642
+ try { typeErrors = runTypecheck(root, tsconfigText).errors; }
1643
+ catch (e) { warnings.push(`type check skipped: ${e && e.message ? e.message : String(e)}`); }
1644
+ }
1645
+
1646
+ // Manifest path (tsk_118c1ecc): when the worker handed us an upload
1647
+ // capability, write the chunks to storage OURSELVES and answer with a
1648
+ // manifest only — the result stays small no matter how heavy the build.
1649
+ // Otherwise enforce the worker's inline cap: a result the deploy Worker
1650
+ // provably cannot parse must be a LOUD deterministic error here, never a
1651
+ // 36MB body that hangs the request.
1652
+ let uploadedChunks = null;
1653
+ const functionArtifacts = Object.entries(functionBuild.functions).map(([pathname, artifact]) =>
1654
+ artifactDescriptor(pathname, 'function', artifact.js));
1655
+ const transformArtifacts = Object.entries(transforms).map(([pathname, artifact]) =>
1656
+ artifactDescriptor(pathname, 'transform', artifact.js));
1657
+ const staticArtifacts = Object.entries(chunks).map(([name, text]) =>
1658
+ artifactDescriptor(`_compiled/${name}`, 'static', text));
1659
+ const artifacts = [...staticArtifacts, ...functionArtifacts, ...transformArtifacts]
1660
+ .sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
1661
+ if (artifacts.length > MAX_ARTIFACT_COUNT) {
1662
+ throw codedError('RESULT_TOO_LARGE', `compiled artifact count ${artifacts.length} > ${MAX_ARTIFACT_COUNT}`);
1663
+ }
1664
+ if (body.artifact_upload && typeof body.artifact_upload.url === 'string' && typeof body.artifact_upload.token === 'string') {
1665
+ const _tUpload = Date.now();
1666
+ if (typeof host.uploadArtifacts !== 'function') {
1667
+ throw codedError('ARTIFACT_UPLOAD_FAILED', 'this compiler host cannot upload artifacts');
1668
+ }
1669
+ uploadedChunks = await host.uploadArtifacts(body.artifact_upload, chunks);
1670
+ timing.upload = _ms(_tUpload);
1671
+ }
1672
+
1673
+ timing.total = _ms(_t0);
1674
+ const dependencyDigest = resolvedDependencyDigest(dependencyReview);
1675
+ return {
1676
+ ok: true,
1677
+ entry_chunk: entryChunk,
1678
+ chunks: uploadedChunks ? {} : chunks,
1679
+ ...(uploadedChunks ? { uploaded_chunks: uploadedChunks } : {}),
1680
+ functions: functionBuild.functions,
1681
+ function_graph: functionBuild.graph,
1682
+ transforms,
1683
+ errors: [],
1684
+ source_digest: digest,
1685
+ artifact_manifest: {
1686
+ project_id: body.project_id,
1687
+ build_id: body.build_id,
1688
+ source_digest: digest,
1689
+ dependency_digest: dependencyDigest,
1690
+ compiler: compilerStamp(digest),
1691
+ artifacts,
1692
+ },
1693
+ // The compiler emits Tailwind in the CSS chunk — the worker must not inject
1694
+ // the runtime CDN. Always false; the compiled stylesheet rides in chunks.
1695
+ needs_tailwind: false,
1696
+ // Mirrored at top level (in addition to metadata.warnings) so the worker's
1697
+ // existing bundleProject(), which reads `body.warnings`, is drop-in on the
1698
+ // PR3 cutover without a response remap.
1699
+ warnings,
1700
+ graph: graphFromMetafile(result.metafile, root),
1701
+ metadata: {
1702
+ timing_ms: timing,
1703
+ importGraph: importGraphFromMetafile(result.metafile, root),
1704
+ routes: { static: Object.keys(frontendFiles).filter((item) => item !== '__somewhere_empty_entry.mjs'), functions: functionRoutes },
1705
+ tailwind: twVersion > 0 ? { version: twVersion, compiled: true } : null,
1706
+ typeErrors,
1707
+ typed_functions: {
1708
+ manifest: typedAnalysis.manifest,
1709
+ summary: typedAnalysis.summary,
1710
+ timing_ms: typedAnalysis.timing_ms,
1711
+ },
1712
+ // Dependency review (PR1) — what the customer's resolved tree contains.
1713
+ // Null only if the scan itself threw (a `dependency review skipped`
1714
+ // warning is then present). knownBad already echoed into warnings above.
1715
+ dependency_review: dependencyReview,
1716
+ dependency_digest: dependencyDigest,
1717
+ warnings,
1718
+ // TODO PR4: prerenderedHtml via renderToString in headless Chromium.
1719
+ // TODO: securityFindings, bundleAnalysis.
1720
+ },
1721
+ };
1722
+ } finally {
1723
+ process.chdir(prevCwd);
1724
+ await fsp.rm(root, { recursive: true, force: true }).catch(() => {});
1725
+ }
1726
+ }
1727
+ return {
1728
+ // The compile contract — identical signatures to the pre-extraction
1729
+ // server.js exports, so the container's consumers and contract tests are
1730
+ // untouched by the move.
1731
+ compile,
1732
+ compileProject,
1733
+ compileTransforms,
1734
+ parseSources,
1735
+ compilerStamp,
1736
+ // Exported for the container's regression fixtures (and reused by the CLI's
1737
+ // parity fixture). See server.js's module.exports for what each one guards.
1738
+ detectTailwind,
1739
+ reviewDependencies,
1740
+ runTypecheck,
1741
+ runReferenceCheck,
1742
+ ensureDeps,
1743
+ bakedSatisfies,
1744
+ react19BakedDir,
1745
+ detectPhantomImports,
1746
+ extractBareSpecifiers,
1747
+ tsconfigAliasMatchers,
1748
+ normalizeTsconfigText,
1749
+ isFunctionPath,
1750
+ sourceDigest,
1751
+ REACT19_NODE_MODULES,
1752
+ IMAGE_NODE_MODULES,
1753
+ };
1754
+ }
1755
+
1756
+ module.exports = {
1757
+ createCompileCore,
1758
+ COMPILER_CONTRACT,
1759
+ NODE_POLYFILLS,
1760
+ ASSET_LOADERS,
1761
+ KNOWN_BAD_VERSIONS,
1762
+ MAX_TOTAL_SOURCE_BYTES,
1763
+ MAX_FILE_COUNT,
1764
+ MAX_BINARY_BYTES,
1765
+ MAX_ARTIFACT_COUNT,
1766
+ GRAPH_MAX_EDGES,
1767
+ GRAPH_MAX_BYTES,
1768
+ importGraphFromMetafile,
1769
+ graphFromMetafile,
1770
+ isFunctionPath,
1771
+ detectTailwind,
1772
+ reviewDependencies,
1773
+ extractBareSpecifiers,
1774
+ sourceDigest,
1775
+ };