@somewhere-tech/cli 0.31.17 → 0.31.18

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 (77) hide show
  1. package/dist/commands/browser.js +1 -1
  2. package/dist/commands/browser.js.map +1 -1
  3. package/dist/commands/check.js +1 -2
  4. package/dist/commands/check.js.map +1 -1
  5. package/dist/commands/dev.js +18 -383
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/commands/status.js +0 -36
  8. package/dist/commands/status.js.map +1 -1
  9. package/dist/commands/typecheck.js +1 -1
  10. package/dist/commands/typecheck.js.map +1 -1
  11. package/dist/commands/verify.js +1 -1
  12. package/dist/commands/verify.js.map +1 -1
  13. package/dist/index.js +0 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/{local → lib}/browser-run.js +1 -1
  16. package/dist/lib/browser-run.js.map +1 -0
  17. package/dist/lib/chrome.js.map +1 -0
  18. package/dist/lib/envfile-write.js +2 -2
  19. package/dist/lib/envfile-write.js.map +1 -1
  20. package/dist/lib/frontend-dev.js +122 -0
  21. package/dist/lib/frontend-dev.js.map +1 -0
  22. package/dist/lib/init-agent-guide.js +15 -19
  23. package/dist/lib/init-agent-guide.js.map +1 -1
  24. package/dist/lib/init-green-template.js +1 -1
  25. package/dist/lib/project-urls.js +13 -0
  26. package/dist/lib/project-urls.js.map +1 -1
  27. package/npm-shrinkwrap.json +2 -17
  28. package/package.json +4 -6
  29. package/runtime/VENDOR.json +0 -2
  30. package/dist/commands/exec.js +0 -145
  31. package/dist/commands/exec.js.map +0 -1
  32. package/dist/local/browser-run.js.map +0 -1
  33. package/dist/local/chrome.js.map +0 -1
  34. package/dist/local/compiler-core.js +0 -51
  35. package/dist/local/compiler-core.js.map +0 -1
  36. package/dist/local/compiler.js +0 -683
  37. package/dist/local/compiler.js.map +0 -1
  38. package/dist/local/dev-server.js +0 -517
  39. package/dist/local/dev-server.js.map +0 -1
  40. package/dist/local/envfile.js +0 -40
  41. package/dist/local/envfile.js.map +0 -1
  42. package/dist/local/loader.js +0 -299
  43. package/dist/local/loader.js.map +0 -1
  44. package/dist/local/loopback.js +0 -129
  45. package/dist/local/loopback.js.map +0 -1
  46. package/dist/local/request-timing.js +0 -101
  47. package/dist/local/request-timing.js.map +0 -1
  48. package/dist/local/router.js +0 -114
  49. package/dist/local/router.js.map +0 -1
  50. package/dist/local/runtime.js +0 -345
  51. package/dist/local/runtime.js.map +0 -1
  52. package/dist/local/server.js +0 -309
  53. package/dist/local/server.js.map +0 -1
  54. package/dist/local/telemetry-filter.js +0 -120
  55. package/dist/local/telemetry-filter.js.map +0 -1
  56. package/node_modules/esbuild-wasm/LICENSE.md +0 -21
  57. package/node_modules/esbuild-wasm/README.md +0 -3
  58. package/node_modules/esbuild-wasm/bin/esbuild +0 -91
  59. package/node_modules/esbuild-wasm/esbuild.wasm +0 -0
  60. package/node_modules/esbuild-wasm/esm/browser.d.ts +0 -705
  61. package/node_modules/esbuild-wasm/esm/browser.js +0 -2393
  62. package/node_modules/esbuild-wasm/esm/browser.min.js +0 -20
  63. package/node_modules/esbuild-wasm/lib/browser.d.ts +0 -705
  64. package/node_modules/esbuild-wasm/lib/browser.js +0 -2438
  65. package/node_modules/esbuild-wasm/lib/browser.min.js +0 -22
  66. package/node_modules/esbuild-wasm/lib/main.d.ts +0 -705
  67. package/node_modules/esbuild-wasm/lib/main.js +0 -2051
  68. package/node_modules/esbuild-wasm/package.json +0 -19
  69. package/node_modules/esbuild-wasm/wasm_exec.js +0 -561
  70. package/node_modules/esbuild-wasm/wasm_exec_node.js +0 -39
  71. package/runtime/compiler/VENDOR.json +0 -64
  72. package/runtime/compiler/compile-core.cjs +0 -2007
  73. package/runtime/compiler/graph-contract.cjs +0 -124
  74. package/runtime/compiler/typed-functions.cjs +0 -575
  75. package/runtime/platform-context.mjs +0 -8193
  76. package/runtime/sw-init.mjs +0 -283
  77. /package/dist/{local → lib}/chrome.js +0 -0
@@ -1,2007 +0,0 @@
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
- /* ─── Lockfile-derived install pins (tsk_f79d71ce) ────────────────────────────
429
- * THE INSTALL SPEC IS OWNED HERE, by the core, so `somewhere dev` and
430
- * `somewhere deploy` derive it from the same code and cannot disagree.
431
- *
432
- * The bug this closes: deploy floor-pinned the declared range (`^7.9.5` → 7.9.5)
433
- * while the developer's own machine had 7.18.3 installed from their lockfile, so
434
- * the local and deployed entry chunks were built against different library code
435
- * — measured on the 0.30.0 journey scaffold, where react-router was the only
436
- * package left in the diff. "Same app, same data, same build" was not true.
437
- *
438
- * THE RULE, both directions:
439
- * - LOCKFILE PRESENT (npm-shrinkwrap.json, else package-lock.json — npm's own
440
- * precedence): a dependency installs at the version the lockfile pins, which
441
- * is exactly what the developer has locally. The pin is only honoured when it
442
- * PROVABLY SATISFIES the range declared in package.json, so a lockfile can
443
- * only ever move the build WITHIN what package.json already allows; an
444
- * out-of-sync lockfile falls back to the floor-pin rather than installing
445
- * something package.json contradicts.
446
- * - NO LOCKFILE: unchanged, and this is the documented rule — third-party deps
447
- * FLOOR-PIN the declared range (`^7.9.5` → 7.9.5), first-party
448
- * `@somewhere-tech/*` pass the range through verbatim so npm resolves the
449
- * latest satisfying version. A project with no lockfile resolves today
450
- * exactly as it did before this change (rule 9).
451
- *
452
- * WHY `@somewhere-tech/*` IGNORES THE LOCKFILE TOO: pfb_f607d6b27453. Our own
453
- * scaffold + `npm install` writes a lockfile within seconds of `somewhere init`,
454
- * so honouring it for first-party packages would re-freeze every scaffold on the
455
- * SDK version it was born with and published SDK fixes would never reach new
456
- * deploys — the exact harm the range passthrough exists to prevent. Right after
457
- * an install the locked version IS the latest in range, so the two agree; they
458
- * only part as the lockfile ages, and that is the case where the platform's own
459
- * package must move.
460
- *
461
- * BOUNDARY, stated so nobody reads more into this than it does: this pins the
462
- * dependencies the core decides to install — the ones declared in the project's
463
- * package.json. Their TRANSITIVE dependencies are still resolved by npm from the
464
- * parents' ranges. Pinning the whole locked closure was considered and rejected
465
- * for now: an explicit exact spec for every transitive can trip ERESOLVE peer
466
- * conflicts and platform-specific optional binaries, which would turn a build
467
- * that works today into a refused one (rule 9 — tighten incrementally). If a
468
- * transitive drift is ever measured, that is the next step, not a redesign.
469
- *
470
- * Prefer-baked resolution is deliberately NOT changed: which packages come from
471
- * the baked image, and the React-19 symlink hot path, still key off the DECLARED
472
- * RANGE. Making the baked decision exact-match would cold-install React on every
473
- * lockfile project (tsk_2553662f's 600s timeout) and would re-open the very
474
- * divergence tsk_0312cf17 closed by pinning the local loop to the image's own
475
- * React set. Aligning the local loop with the rest of the baked set is the CLI's
476
- * half of this, not the compiler's.
477
- */
478
-
479
- /** npm's own lockfile precedence: a shrinkwrap wins over a package-lock. */
480
- const LOCKFILE_NAMES = ['npm-shrinkwrap.json', 'package-lock.json'];
481
-
482
- /**
483
- * Versions an npm lockfile pins for the project's TOP-LEVEL packages.
484
- *
485
- * Only `node_modules/<name>` entries count: a nested `node_modules/a/node_modules/b`
486
- * path is a transitive's private copy, not the version the project itself
487
- * resolves. Link entries (workspaces, `file:` deps) carry no registry version and
488
- * are skipped. Handles lockfileVersion 2/3 (`packages`) and 1 (`dependencies`).
489
- * Malformed input yields an empty map — never a throw; a lockfile we cannot read
490
- * degrades to today's floor-pin.
491
- */
492
- function lockfileVersions(text) {
493
- const out = new Map();
494
- if (typeof text !== 'string' || !text.trim()) return out;
495
- let lock;
496
- try { lock = JSON.parse(text); } catch { return out; }
497
- if (!lock || typeof lock !== 'object') return out;
498
- const packages = lock.packages && typeof lock.packages === 'object' ? lock.packages : null;
499
- if (packages) {
500
- for (const [entryPath, entry] of Object.entries(packages)) {
501
- if (!entryPath.startsWith('node_modules/')) continue;
502
- const name = entryPath.slice('node_modules/'.length);
503
- if (name.includes('/node_modules/')) continue;
504
- if (!entry || typeof entry !== 'object' || entry.link) continue;
505
- if (typeof entry.version !== 'string' || !entry.version) continue;
506
- out.set(name, entry.version);
507
- }
508
- }
509
- const v1 = lock.dependencies && typeof lock.dependencies === 'object' ? lock.dependencies : null;
510
- if (v1) {
511
- for (const [name, entry] of Object.entries(v1)) {
512
- if (out.has(name)) continue;
513
- if (!entry || typeof entry !== 'object') continue;
514
- if (typeof entry.version !== 'string' || !entry.version) continue;
515
- if (/^(?:file|link|git\+|https?):/.test(entry.version)) continue;
516
- out.set(name, entry.version);
517
- }
518
- }
519
- return out;
520
- }
521
-
522
- /**
523
- * The one derivation of what to hand `npm install` for a single dependency.
524
- * PURE — `satisfies(version, range)` is passed in (semver is host-loaded) and
525
- * must return TRUE only when it can prove the version is inside the range;
526
- * anything it cannot prove falls back to the floor-pin, i.e. today's behavior.
527
- *
528
- * Exported so the fixture can pin the rule in BOTH directions without a build.
529
- */
530
- function resolveInstallSpec({ name, range, lockedVersion, satisfies }) {
531
- // First-party platform packages: declared range verbatim, lockfile ignored.
532
- // See the block comment above (pfb_f607d6b27453).
533
- if (name.startsWith('@somewhere-tech/')) {
534
- return `${name}@${String(range || 'latest').trim() || 'latest'}`;
535
- }
536
- if (lockedVersion && typeof satisfies === 'function') {
537
- let inRange = false;
538
- try { inRange = satisfies(lockedVersion, range) === true; } catch { inRange = false; }
539
- if (inRange) return `${name}@${lockedVersion}`;
540
- }
541
- return `${name}@${cleanRange(range)}`;
542
- }
543
-
544
- /**
545
- * The project's locked versions, read from the materialized build root. Both
546
- * hosts land here: the container materializes every uploaded file (the lockfile
547
- * included) into the build root, and the CLI's build root is the project itself.
548
- * A missing or unreadable lockfile is not an error — it is the documented
549
- * no-lockfile path.
550
- */
551
- function readLockedVersions(root) {
552
- for (const name of LOCKFILE_NAMES) {
553
- let text;
554
- try { text = fs.readFileSync(path.join(root, name), 'utf8'); } catch { continue; }
555
- const versions = lockfileVersions(text);
556
- if (versions.size) return versions;
557
- }
558
- return new Map();
559
- }
560
-
561
- /**
562
- * Is `name` resolvable the way ESBUILD will resolve it — i.e. as a direct child
563
- * package of one of the exact search dirs (the per-build install + the baked
564
- * image set, mirroring esbuild's nodePaths)? We deliberately do NOT use Node's
565
- * require.resolve here: its `paths` option walks UP ancestor directories, so in
566
- * the monorepo it finds deps in a parent node_modules that esbuild's strict
567
- * nodePaths can't see — over-reporting "resolvable" and skipping an install
568
- * esbuild then needs. An isolated container has no such ancestor; this fs check
569
- * is correct in both environments.
570
- */
571
- function isResolvable(name, searchDirs) {
572
- return searchDirs.some((dir) => fs.existsSync(path.join(dir, name, 'package.json')));
573
- }
574
-
575
- // Build-toolchain packages that legacy scaffolds declare under `dependencies`
576
- // (CRA puts react-scripts there by default). The browser bundle never imports
577
- // them, and installing react-scripts alone drags in a ~100MB tree that can
578
- // blow the per-build install timeout — the platform IS the build toolchain.
579
- // (tsk_4d9c865a)
580
- const TOOLCHAIN_DEPS = new Set(['react-scripts', 'react-app-rewired', 'craco', '@craco/craco', 'vite', 'webpack', 'parcel']);
581
-
582
- /* ─── Phantom-import detection (tsk_8e460b69) ─────────────────────────────────
583
- * esbuild 0.24 tree-shakes a bare import whose named bindings are ALL unused
584
- * before it attempts to resolve the module. An `import { nothing } from
585
- * 'missing-pkg'` where `nothing` is never referenced silently succeeds — no
586
- * "Could not resolve" error and no warning. The customer's deploy
587
- * goes live; if that code path ever executes in the browser it fails at runtime.
588
- *
589
- * This scanner runs AFTER the esbuild build succeeds and catches that class:
590
- * every bare specifier found in the frontend source that is NOT resolvable
591
- * through the same set of sources esbuild would use (per-build install, baked
592
- * image, React-19 baked set, NODE_POLYFILLS aliases) is surfaced as a warning.
593
- *
594
- * RULE 9 — WARN-FIRST: never a build failure. A phantom import the customer
595
- * hasn't reached yet doesn't break today's page. The warning names the package
596
- * and the file so the developer or their agent can add it to package.json.
597
- *
598
- * GRANDFATHER: the full resolvability chain (build root → IMAGE_NODE_MODULES →
599
- * REACT19_NODE_MODULES → NODE_POLYFILLS → node: prefix → relative/URL paths)
600
- * mirrors exactly what esbuild can resolve, so a working import NEVER warns.
601
- * Platform-provided specifiers (PLATFORM_MODULE_ROOTS) are exempt on top of
602
- * that: they are not npm packages, so no resolution chain would ever find them.
603
- */
604
-
605
- /**
606
- * Extract bare import/export specifiers from JS-family source.
607
- * "Bare" = not relative (`./`, `../`), not root-anchored (`/`), not a URL.
608
- * Handles static import, re-export, bare side-effect import, and dynamic import().
609
- */
610
- function extractBareSpecifiers(content) {
611
- if (typeof content !== 'string') return [];
612
- const out = new Set();
613
- const re = /(?:\bimport\b[^'";]*?\bfrom\s*|\bexport\b[^'";]*?\bfrom\s*|\bimport\s*\(\s*|\bimport\s*)(['"])([^'"]+)\1/g;
614
- let m;
615
- while ((m = re.exec(content)) !== null) {
616
- const spec = m[2];
617
- if (!spec) continue;
618
- if (spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/')) continue;
619
- if (/^[a-z][a-z0-9+.-]*:/i.test(spec)) continue; // http:, data:, blob:, node:, virtual:, …
620
- out.add(spec);
621
- }
622
- return [...out];
623
- }
624
-
625
- /**
626
- * Return the root package name for a specifier: `@scope/pkg/subpath` → `@scope/pkg`,
627
- * `pkg/subpath` → `pkg`, `pkg` → `pkg`.
628
- */
629
- function specRootPkg(spec) {
630
- return spec.startsWith('@')
631
- ? spec.split('/').slice(0, 2).join('/')
632
- : spec.split('/')[0];
633
- }
634
-
635
- /**
636
- * Root packages the PLATFORM provides — derived from typed-functions'
637
- * PLATFORM_MODULES, which is also what builds the virtual-module resolver, so
638
- * this can never drift into a stale hand list.
639
- *
640
- * These are not npm packages. Reporting one as "not in your package.json" told
641
- * a developer that the `somewhere/db` import our own managed-schema docs handed
642
- * them would fail to load in production, and the remediation it offered —
643
- * `npm install somewhere` — installs an unrelated package (tsk_53badecfb7).
644
- */
645
- const PLATFORM_MODULE_ROOTS = new Set(typedFunctions.PLATFORM_MODULES.map(specRootPkg));
646
-
647
- /* ─── Dependency review (PR1, tsk_35befef9 / npm supply-chain) ────────────────
648
- * After deps are installed (with --ignore-scripts), walk the customer's RESOLVED
649
- * dependency tree and report what actually shipped: package count, lifecycle
650
- * scripts that were SKIPPED, native modules, and any resolved version that
651
- * matches a known-compromised release. Surfaced at deploy as the "dependency
652
- * review" block + (for a known-bad hit) a non-blocking warning.
653
- *
654
- * RULE 9 — this is WARN-FIRST: we report, we never refuse the build. Promotion
655
- * of any signal to an actual block is a separate, gated step (PR4).
656
- *
657
- * No network: reads only the installed package.json files on the build fs. The
658
- * AUTHORITATIVE live advisory scan (OSV/npm-advisory) ships with the R2
659
- * dependency cache (PR2), running on cache-entry; this is the certain, feed-free
660
- * signal + a small hand-verified known-bad backstop so a famous worm can't slip
661
- * through before that feed lands.
662
- *
663
- * Scope = the customer's installed tree (build-root node_modules) — the long
664
- * tail npm pulled in. The pre-baked image deps (our toolchain + react) are our
665
- * own trusted pins, not the customer's supply-chain surface, so they're excluded.
666
- */
667
-
668
- // Hand-verified backstop of CONFIRMED-compromised exact versions. NOT the
669
- // authoritative source (PR2's live OSV scan is) — every entry is an exact
670
- // name@version confirmed across independent advisories. Rule 7: no guessed or
671
- // approximate entry, because a wrong known-bad becomes a false BLOCK the moment
672
- // these promote past warn-first.
673
- //
674
- // Sept 8 2025 "crypto-clipper" cluster — chalk/debug/ansi maintainer phished
675
- // (npmjs.help 2FA-reset lure); injected a browser wallet-drainer. Sources:
676
- // Sonatype, Wiz, Semgrep, Vercel, Palo Alto Unit 42.
677
- const KNOWN_BAD_VERSIONS = {
678
- debug: ['4.4.2'], chalk: ['5.6.1'], 'ansi-styles': ['6.2.2'], 'ansi-regex': ['6.2.1'],
679
- 'strip-ansi': ['7.1.1'], 'wrap-ansi': ['9.0.1'], 'supports-color': ['10.2.1'],
680
- 'supports-hyperlinks': ['4.1.1'], color: ['5.0.1'], 'color-name': ['2.0.1'],
681
- 'color-convert': ['3.1.1'], 'color-string': ['2.1.1'], 'has-ansi': ['6.0.1'],
682
- 'slice-ansi': ['7.1.1'], 'chalk-template': ['1.1.1'], backslash: ['0.2.1'],
683
- 'is-arrayish': ['0.3.3'], 'error-ex': ['1.3.3'], 'simple-swizzle': ['0.2.3'],
684
- 'proto-tinker-wc': ['0.1.87'], duckdb: ['1.3.3'], '@duckdb/node-api': ['1.3.3'],
685
- '@duckdb/node-bindings': ['1.3.3'], '@duckdb/duckdb-wasm': ['1.29.2'],
686
- };
687
-
688
- const MAX_REVIEW_PACKAGES = 5000; // pathological-tree guard
689
- const NATIVE_BUILD_TOOLS = /node-gyp|node-pre-gyp|prebuild|prebuildify|cmake-js/;
690
-
691
- // Walk a node_modules dir → {name, version, dir, pkg} per installed package.
692
- // Handles @scope packages and nested node_modules (npm dedupe conflicts).
693
- function* walkInstalled(nmDir, depth) {
694
- if (depth > 6 || !fs.existsSync(nmDir)) return;
695
- let entries;
696
- try { entries = fs.readdirSync(nmDir, { withFileTypes: true }); } catch { return; }
697
- for (const e of entries) {
698
- if (e.name.startsWith('.')) continue;
699
- const full = path.join(nmDir, e.name);
700
- if (e.name.startsWith('@')) {
701
- let scoped;
702
- try { scoped = fs.readdirSync(full, { withFileTypes: true }); } catch { continue; }
703
- for (const s of scoped) {
704
- if (s.name.startsWith('.')) continue;
705
- yield* emitInstalledPkg(path.join(full, s.name), depth);
706
- }
707
- } else {
708
- yield* emitInstalledPkg(full, depth);
709
- }
710
- }
711
- }
712
- function* emitInstalledPkg(dir, depth) {
713
- let pkg = null;
714
- try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { /* not a package dir */ }
715
- if (pkg && typeof pkg.name === 'string') yield { name: pkg.name, version: pkg.version || '0.0.0', dir, pkg };
716
- yield* walkInstalled(path.join(dir, 'node_modules'), depth + 1);
717
- }
718
-
719
- function reviewDependencies(root, pkg) {
720
- const directDeps = (pkg && pkg.dependencies) || {};
721
- const review = {
722
- directDependencies: Object.keys(directDeps).length,
723
- scanned: 0,
724
- installScriptsExecuted: 0, // always 0 — --ignore-scripts
725
- installScriptsSkipped: [], // [{name, version, hooks}]
726
- nativeModules: [], // [{name, version}]
727
- knownBad: [], // [{name, version, severity, incident}]
728
- packages: [], // [{name, version}] — resolved set, for the
729
- // worker's live OSV advisory scan (PR4). Capped.
730
- };
731
- const seen = new Set();
732
- for (const { name, version, dir, pkg: p } of walkInstalled(path.join(root, 'node_modules'), 0)) {
733
- const key = `${name}@${version}`;
734
- if (seen.has(key)) continue;
735
- seen.add(key);
736
- if (seen.size > MAX_REVIEW_PACKAGES) break;
737
- review.scanned++;
738
- review.packages.push({ name, version });
739
-
740
- const scripts = (p && p.scripts) || {};
741
- const hooks = ['preinstall', 'install', 'postinstall'].filter((h) => typeof scripts[h] === 'string' && scripts[h].trim());
742
- if (hooks.length) review.installScriptsSkipped.push({ name, version, hooks });
743
-
744
- const isNative = (p && p.gypfile === true)
745
- || fs.existsSync(path.join(dir, 'binding.gyp'))
746
- || hooks.some((h) => NATIVE_BUILD_TOOLS.test(scripts[h] || ''));
747
- if (isNative) review.nativeModules.push({ name, version });
748
-
749
- const bad = KNOWN_BAD_VERSIONS[name];
750
- if (bad && bad.includes(version)) {
751
- review.knownBad.push({ name, version, severity: 'critical', incident: 'confirmed-compromised npm release' });
752
- }
753
- }
754
- return review;
755
- }
756
-
757
- /* ─── Manifest path (tsk_118c1ecc) — direct-to-storage artifact upload ──────
758
- * A heavy compiled result (~36MB for project `tools`) cannot safely ride back
759
- * inline. When the request carries `artifact_upload` {url, token}, the
760
- * container PUTs every chunk to the worker's /v1/compile-artifacts route
761
- * (gated by the scoped single-build token — same pattern as the npm-proxy
762
- * per-build token) and the response carries only a MANIFEST: chunk names,
763
- * sizes, sha256 hashes. No chunk bodies. */
764
-
765
- /** Throw an Error carrying a machine-readable `code` the worker branches on. */
766
- function codedError(code, message) {
767
- const err = new Error(message);
768
- err.code = code;
769
- return err;
770
- }
771
-
772
- function artifactDescriptor(pathname, kind, text) {
773
- const bytes = Buffer.from(text, 'utf8');
774
- const mime = pathname.endsWith('.css')
775
- ? 'text/css; charset=utf-8'
776
- : 'application/javascript; charset=utf-8';
777
- return {
778
- path: pathname,
779
- kind,
780
- bytes: bytes.length,
781
- sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
782
- mime,
783
- };
784
- }
785
-
786
- function inlineResultBodyBytes(result) {
787
- let bytes = 0;
788
- for (const text of Object.values(result.chunks || {})) bytes += Buffer.byteLength(text, 'utf8');
789
- for (const artifact of Object.values(result.functions || {})) bytes += Buffer.byteLength(artifact.js, 'utf8');
790
- for (const artifact of Object.values(result.transforms || {})) bytes += Buffer.byteLength(artifact.js, 'utf8');
791
- return bytes;
792
- }
793
-
794
- function enforceInlineResultCap(body, result) {
795
- const cap = body.inline_result_cap_bytes;
796
- if (!Number.isFinite(cap) || cap <= 0) return;
797
- const inlineBytes = inlineResultBodyBytes(result);
798
- const serializedBytes = Buffer.byteLength(JSON.stringify(result), 'utf8');
799
- if (serializedBytes <= cap) return;
800
- const mb = (serializedBytes / (1024 * 1024)).toFixed(1);
801
- const inlineMb = (inlineBytes / (1024 * 1024)).toFixed(1);
802
- const capMb = (cap / (1024 * 1024)).toFixed(0);
803
- throw codedError(
804
- 'RESULT_TOO_LARGE',
805
- `compiled result is ${mb}MB (${inlineMb}MB inline bodies) across ${result.artifact_manifest.artifacts.length} artifacts, above the ${capMb}MB limit`,
806
- );
807
- }
808
-
809
- function resolvedDependencyDigest(review) {
810
- const packages = [...(review && review.packages || [])]
811
- .map((item) => `${item.name}@${item.version}`)
812
- .sort();
813
- return crypto.createHash('sha256').update(packages.join('\n')).digest('hex');
814
- }
815
-
816
- function sourceLoader(filename) {
817
- if (/\.tsx$/i.test(filename)) return 'tsx';
818
- if (/\.(?:ts|mts|cts)$/i.test(filename)) return 'ts';
819
- if (/\.jsx$/i.test(filename)) return 'jsx';
820
- if (/\.(?:js|mjs|cjs)$/i.test(filename)) return 'js';
821
- if (/\.json$/i.test(filename)) return 'json';
822
- return null;
823
- }
824
-
825
- function resolveLocalFunctionImport(root, spec) {
826
- if (typeof spec !== 'string' || /^(?:node|cloudflare|workerd):/.test(spec)) return null;
827
- if (spec.startsWith('.') || /^[a-z][a-z0-9+.-]*:/i.test(spec)) return null;
828
- const base = path.resolve(root, spec.replace(/^\/+/, ''));
829
- if (base !== root && !base.startsWith(root + path.sep)) return null;
830
- const candidates = [
831
- base,
832
- ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'].map((ext) => base + ext),
833
- ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'].map((ext) => path.join(base, 'index' + ext)),
834
- ];
835
- return candidates.find((candidate) => {
836
- try { return fs.statSync(candidate).isFile(); } catch { return false; }
837
- }) || null;
838
- }
839
-
840
- function functionLocalImportPlugin(root) {
841
- return {
842
- name: 'somewhere-function-local-root',
843
- setup(build) {
844
- build.onResolve({ filter: /.*/ }, (args) => {
845
- if (args.kind === 'entry-point') return null;
846
- if (/^(?:node|cloudflare|workerd):/.test(args.path)) return { path: args.path, external: true };
847
- const local = resolveLocalFunctionImport(root, args.path);
848
- return local ? { path: local } : null;
849
- });
850
- },
851
- };
852
- }
853
- /* ══════════════════════════════════════════════════════════════════════════
854
- * ENVIRONMENT-BOUND — everything that needs to know where the baked toolchain
855
- * and dependency trees live, how an install runs, and what stamps this build.
856
- * ══════════════════════════════════════════════════════════════════════════ */
857
-
858
- /**
859
- * Build a compiler instance bound to one host environment.
860
- * @param {object} host — see the file header for the complete contract.
861
- */
862
- /**
863
- * Build-CONFIG files, which are never in the browser graph.
864
- *
865
- * The platform IS the bundler, so a project's vite/rollup/webpack/postcss/
866
- * tailwind config is never executed by anything the compiler emits. Scanning
867
- * them for phantom imports produced a warning whose own premise was false —
868
- * "if that code path runs, your app will fail to load it", for a code path
869
- * that cannot run — on our OWN init scaffold, whose vite.config.ts imports
870
- * vite and @vitejs/plugin-react (tsk_424174be).
871
- */
872
- function isBuildConfigFile(filePath) {
873
- const base = filePath.split('/').pop() || '';
874
- 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);
875
- }
876
-
877
- /* ─── devDependencies the app's own source actually imports (tsk_2a7c6b33) ────
878
- * The install is `dependencies`-only BY DESIGN — the platform is the build
879
- * toolchain, so vite/typescript/@vitejs/* have no business in a per-build
880
- * install. That was always the stated rule, and `missing` always honoured it,
881
- * but `npm install <specs>` runs in a build root holding the customer's whole
882
- * package.json and npm reifies the FULL declared tree from it, devDependencies
883
- * included. Measured on our own `somewhere init` scaffold: 74 packages / 72 MB
884
- * with the dev closure (vite, rollup, @babel/*, esbuild + its platform binary,
885
- * typescript, @types/*), 8 packages / 13 MB without it. Every scaffold deploy
886
- * has been paying for a toolchain the bundle never imports, and on a slow or
887
- * churning instance that is what puts an install over the 300 s build budget.
888
- * `--omit=dev` on the npm side closes it (server.js).
889
- *
890
- * RULE 9 — THE CARVE-OUT THAT KEEPS WORKING CODE WORKING: because the dev
891
- * closure landed incidentally, a project that IMPORTS a dev-declared package
892
- * from its own source has always had that import resolve. Dropping the dev
893
- * tree wholesale would turn those builds into "Could not resolve" failures —
894
- * the exact class rule 9 forbids. So a devDependency the source imports is
895
- * installed EXPLICITLY, by name, and nothing that builds today stops building.
896
- * Only the unimported remainder — the toolchain — goes away.
897
- *
898
- * BUILD-CONFIG FILES ARE EXCLUDED, and that exclusion is the whole point: the
899
- * scaffold's own vite.config.ts imports `vite` and `@vitejs/plugin-react`, and
900
- * the platform never executes it (isBuildConfigFile, tsk_424174be). Counting it
901
- * as an import would reinstall the toolchain and buy nothing.
902
- */
903
- function importedDevDeps(pkg, files) {
904
- const declared = (pkg && pkg.devDependencies) || {};
905
- const names = Object.keys(declared);
906
- if (!names.length || !files || typeof files !== 'object') return [];
907
- const declaredSet = new Set(names);
908
- const hit = new Set();
909
- for (const [filePath, content] of Object.entries(files)) {
910
- if (typeof content !== 'string') continue;
911
- if (!/\.(tsx?|jsx?|mjs|cjs)$/i.test(filePath)) continue;
912
- if (isBuildConfigFile(filePath)) continue;
913
- for (const spec of extractBareSpecifiers(content)) {
914
- const root = specRootPkg(spec);
915
- if (declaredSet.has(root)) hit.add(root);
916
- }
917
- }
918
- return [...hit];
919
- }
920
-
921
- function createCompileCore(host) {
922
- if (!host || typeof host !== 'object') throw new Error('createCompileCore(host) requires a host');
923
- for (const required of ['imageNodeModules', 'requireImage', 'installPackages']) {
924
- if (host[required] === undefined || host[required] === null) {
925
- throw new Error(`createCompileCore(host): host.${required} is required`);
926
- }
927
- }
928
- // One dir or several, in search order. The container has exactly one (its
929
- // baked image tree); the CLI's local dev loop has up to two (the project's
930
- // own node_modules first, then its managed cache), which is why this is a
931
- // list rather than the single path the container needed.
932
- // esbuild IS the compiler: same version in, same bytes out. A host may hand
933
- // us a different BUILD of that exact version (the CLI uses esbuild-wasm
934
- // 0.24.0, which the parity fixture proves emits identical output to the
935
- // container's native 0.24.0) but never a different version — the compiler
936
- // stamp carries esbuild.version and the vendor step refuses a pin mismatch.
937
- const esbuild = host.esbuild || require('esbuild');
938
- const IMAGE_NODE_MODULES = Array.isArray(host.imageNodeModules)
939
- ? host.imageNodeModules.filter(Boolean)
940
- : [host.imageNodeModules];
941
- if (!IMAGE_NODE_MODULES.length) throw new Error('createCompileCore(host): host.imageNodeModules is empty');
942
- const REACT19_NODE_MODULES = host.react19NodeModules || null;
943
- const imageRequire = host.requireImage;
944
- const tw4Require = host.requireTw4 || (() => { throw new Error('this host has no Tailwind v4 engine'); });
945
- const stamp = host.stamp || {};
946
-
947
- function compilerStamp(sourceDigest) {
948
- return {
949
- contract: COMPILER_CONTRACT,
950
- esbuild: esbuild.version,
951
- source: stamp.source,
952
- toolchain: stamp.toolchain,
953
- ...(sourceDigest ? { source_digest: sourceDigest } : {}),
954
- };
955
- }
956
-
957
- // semver is lazily required from the baked image and backs ensureDeps'
958
- // version-aware resolution. If it somehow can't load, getSemver returns null and
959
- // bakedSatisfies degrades to "does not satisfy" → per-build install (slower but
960
- // correct) — never a crash, and never a false baked match.
961
- let _semver;
962
- function getSemver() {
963
- if (_semver !== undefined) return _semver;
964
- try { _semver = imageRequire('semver'); } catch { _semver = null; }
965
- return _semver;
966
- }
967
-
968
- /**
969
- * The prefer-baked-but-respect-the-pin rule for ONE baked dependency: return
970
- * true only when the image's pre-baked copy of `name` satisfies the project's
971
- * requested `range`. When it returns false the caller installs the project's
972
- * declared version per-build into the build root, and esbuild resolves the build
973
- * root's node_modules BEFORE the baked nodePaths — so the project's version wins
974
- * and a baked dep is never forced onto an app that pinned another.
975
- *
976
- * Bias is deliberate and one-directional: a FALSE POSITIVE (claiming baked
977
- * satisfies when it doesn't) would ship the wrong version to the customer, so we
978
- * never risk it. Anything we cannot positively prove satisfies — a missing baked
979
- * version, a range semver can't parse (`latest`, git/alias specs), or semver
980
- * being unavailable — returns false and falls back to a per-build install, which
981
- * is correct, just slower. A false NEGATIVE only costs an unnecessary install.
982
- */
983
- function bakedSatisfies(name, range, baseDir) {
984
- // No explicit dir = ask the whole search path, in order. Any dir whose copy
985
- // satisfies the range is a hit, because that is the copy esbuild resolves.
986
- if (baseDir === undefined) return IMAGE_NODE_MODULES.some((dir) => bakedSatisfies(name, range, dir));
987
- let bakedVersion;
988
- try {
989
- bakedVersion = JSON.parse(fs.readFileSync(path.join(baseDir, name, 'package.json'), 'utf8')).version;
990
- } catch { return false; }
991
- if (!bakedVersion) return false;
992
- const semver = getSemver();
993
- if (!semver) return false;
994
- // Empty / missing range means "any version" — the baked copy is fine.
995
- const wanted = (typeof range === 'string' && range.trim()) ? range.trim() : '*';
996
- if (semver.validRange(wanted) === null) return false; // 'latest', git/alias specs, etc.
997
- try { return semver.satisfies(bakedVersion, wanted, { includePrerelease: false }); }
998
- catch { return false; }
999
- }
1000
-
1001
- // Packages the React 19 isolated set provides. ensureDeps consults the baked 19
1002
- // for these BEFORE deciding to install — but ONLY when the baked 18 in
1003
- // IMAGE_NODE_MODULES does NOT satisfy the pin (i.e. the app actually wants 19).
1004
- const REACT19_PACKAGES = new Set(['react', 'react-dom']);
1005
-
1006
- /**
1007
- * For a react/react-dom dependency that the default baked 18 does NOT satisfy:
1008
- * can the baked React-19 set satisfy it? Returns the absolute dir of the baked
1009
- * 19 package to symlink, or null (→ fall through to a per-build install of the
1010
- * project's exact pin — the incompatible-pin path, RULE-6). The same prefer-
1011
- * baked-but-respect-the-pin discipline as bakedSatisfies, just against the
1012
- * isolated react19/ node_modules.
1013
- */
1014
- function react19BakedDir(name, range) {
1015
- if (!REACT19_PACKAGES.has(name)) return null;
1016
- // A host may have no isolated React-19 set (the CLI's dep cache is one flat
1017
- // tree). Then there is nothing to prefer and the pin installs normally.
1018
- if (!REACT19_NODE_MODULES) return null;
1019
- if (!bakedSatisfies(name, range, REACT19_NODE_MODULES)) return null;
1020
- return path.join(REACT19_NODE_MODULES, name);
1021
- }
1022
-
1023
- /**
1024
- * Resolve the customer's `dependencies` (directive flag #1 + invariant #2:
1025
- * resolved versions match local), with version-aware prefer-baked resolution.
1026
- * Per dependency, in order:
1027
- * 1. Already in THIS build's node_modules → nothing to do.
1028
- * 2. Baked in the image AND the baked version satisfies the requested range
1029
- * (bakedSatisfies) → use the baked copy, no install. This is the hot path:
1030
- * react + the top long-tail libs ship in the image, so most builds do zero
1031
- * `npm install` (perf) and the container barely reaches the registry (good
1032
- * for the egress-locked compile container).
1033
- * 3. Otherwise → `npm install` the declared version into the build root. This
1034
- * covers both un-baked long-tail deps AND the case where the project pinned
1035
- * a version the baked copy does NOT satisfy (step 2 returned false): the
1036
- * per-build install lands in the build root, which esbuild resolves before
1037
- * the baked nodePaths, so the project's pin wins.
1038
- * devDependencies (tailwind, vite, etc.) are NOT installed — the build toolchain
1039
- * is the container's job, not the app's. The ONE exception is a devDependency
1040
- * the project's own source imports: see importedDevDeps (tsk_2a7c6b33) for why
1041
- * that carve-out exists and why it is not optional.
1042
- */
1043
-
1044
-
1045
- async function ensureDeps(root, pkg, warnings, ctx, scopedProxyReady = true, sourceFiles = null) {
1046
- // `dependencies`, plus any devDependency the source actually imports. npm
1047
- // is told `--omit=dev` (server.js), so anything the app really needs must
1048
- // be named here explicitly — which is what keeps a build that resolves a
1049
- // dev-declared import today resolving it tomorrow (rule 9).
1050
- const declaredDev = (pkg && pkg.devDependencies) || {};
1051
- const deps = { ...((pkg && pkg.dependencies) || {}) };
1052
- for (const name of importedDevDeps(pkg, sourceFiles)) {
1053
- if (!(name in deps)) deps[name] = declaredDev[name];
1054
- }
1055
- const buildRoot = path.join(root, 'node_modules');
1056
- // The project's own lockfile, materialized alongside package.json. It IS
1057
- // uploaded on every deploy and was, until now, ignored — which is what made
1058
- // the deployed build resolve a different version of an app dependency than
1059
- // the developer's machine (tsk_f79d71ce). Missing/unreadable → empty map →
1060
- // the floor-pin path, unchanged.
1061
- const locked = readLockedVersions(root);
1062
- const semverForLock = getSemver();
1063
- const satisfiesLocked = semverForLock
1064
- ? (version, range) => {
1065
- const wanted = (typeof range === 'string' && range.trim()) ? range.trim() : '*';
1066
- if (semverForLock.validRange(wanted) === null) return false;
1067
- return semverForLock.satisfies(version, wanted, { includePrerelease: true });
1068
- }
1069
- : null;
1070
- const missing = [];
1071
- // react/react-dom whose pin the baked 18 misses but the baked 19 satisfies:
1072
- // collect them to symlink from the isolated react19 set instead of installing.
1073
- // This is the tsk_2553662f hot-path fix — a React-19 app no longer triggers a
1074
- // cold full install of react + react-dom (which dragged the whole heavy dep
1075
- // tree into one over-budget job).
1076
- const react19Links = [];
1077
- for (const [name, range] of Object.entries(deps)) {
1078
- if (TOOLCHAIN_DEPS.has(name)) continue;
1079
- if (isResolvable(name, [buildRoot])) continue;
1080
- if (isResolvable(name, IMAGE_NODE_MODULES) && bakedSatisfies(name, range)) continue;
1081
- const r19 = react19BakedDir(name, range);
1082
- if (r19) { react19Links.push({ name, dir: r19 }); continue; }
1083
- // WHAT version to install is resolveInstallSpec's single decision — the
1084
- // project's lockfile when it pins one inside the declared range, the
1085
- // floor-pin otherwise, the declared range verbatim for first-party
1086
- // @somewhere-tech/* packages. See the block comment on resolveInstallSpec.
1087
- missing.push(resolveInstallSpec({
1088
- name, range, lockedVersion: locked.get(name), satisfies: satisfiesLocked,
1089
- }));
1090
- }
1091
- // Symlink the baked React-19 packages into the build root so esbuild resolves
1092
- // them from root/node_modules (which it consults before the baked nodePaths) —
1093
- // a single React instance per build, the baked 19, with zero install. A
1094
- // symlink failure is non-fatal: drop it back into `missing` so the dep still
1095
- // installs per-build (slower but correct), never a broken build.
1096
- if (react19Links.length) {
1097
- try { fs.mkdirSync(buildRoot, { recursive: true }); } catch { /* exists */ }
1098
- for (const { name, dir } of react19Links) {
1099
- const link = path.join(buildRoot, name);
1100
- try {
1101
- if (!fs.existsSync(link)) fs.symlinkSync(dir, link, 'dir');
1102
- } catch (e) {
1103
- warnings.push(`react19 link skipped for ${name}; installing per-build: ${e && e.message ? e.message : String(e)}`);
1104
- missing.push(resolveInstallSpec({
1105
- name, range: deps[name], lockedVersion: locked.get(name), satisfies: satisfiesLocked,
1106
- }));
1107
- }
1108
- }
1109
- }
1110
- if (!missing.length) return;
1111
- if (!scopedProxyReady) {
1112
- throw codedError('NPM_PROXY_REQUIRED', `the scoped package proxy is required to resolve ${missing.length} dependency pin(s)`);
1113
- }
1114
- // Mark the build STAGE so the BUILD_TIMEOUT handler can report where it
1115
- // stalled (tsk_2553662f: a timeout used to be a bare "build exceeded Nms"
1116
- // with no hint whether npm install or esbuild was the culprit).
1117
- if (ctx && ctx.setStage) ctx.setStage('installing');
1118
- // WHICH packages to install and WHY is decided above and is the same
1119
- // everywhere. HOW the install runs is the one host-specific part: the
1120
- // container shells out to npm through its per-build scoped proxy with the
1121
- // BUILD_TIMEOUT controller wired to the child, the CLI installs into its
1122
- // own cache with no proxy. host.installPackages owns that and owns the
1123
- // NPM_INSTALL_TIMEOUT / real-failure error classification with it.
1124
- await host.installPackages({ root, specs: missing, ctx });
1125
- }
1126
-
1127
- /**
1128
- * Parse the tsconfig `paths` aliases into a list of matchers. esbuild resolves
1129
- * these at build time (buildOpts.tsconfig is set), so a specifier that matches
1130
- * an alias is NOT a phantom import — without this the ubiquitous `@/*` → `src/*`
1131
- * alias would false-warn on every deploy that uses it (the jsxApp fixture, and
1132
- * most real Vite apps). Malformed/absent tsconfig → no aliases (never a crash).
1133
- * Each matcher is the key with a trailing `/*` stripped; a spec matches when it
1134
- * equals the matcher OR starts with `matcher + '/'`.
1135
- */
1136
- function tsconfigAliasMatchers(tsconfigText) {
1137
- if (typeof tsconfigText !== 'string') return [];
1138
- try {
1139
- // ts.parseConfigFileTextToJson tolerates JSONC (comments/trailing commas)
1140
- // that real tsconfigs carry; it's the same parser used by runReferenceCheck.
1141
- const ts = imageRequire('typescript');
1142
- const cfg = ts.parseConfigFileTextToJson('tsconfig.json', tsconfigText).config || {};
1143
- const paths = (cfg.compilerOptions && cfg.compilerOptions.paths) || {};
1144
- return Object.keys(paths).map((k) => k.replace(/\/\*$/, ''));
1145
- } catch {
1146
- return [];
1147
- }
1148
- }
1149
-
1150
- /** Make the implicit project root explicit for path aliases. */
1151
- function normalizeTsconfigText(tsconfigText) {
1152
- if (typeof tsconfigText !== 'string') return tsconfigText;
1153
- try {
1154
- const parsed = imageRequire('typescript').parseConfigFileTextToJson('tsconfig.json', tsconfigText).config;
1155
- const options = parsed && typeof parsed === 'object' ? parsed.compilerOptions : null;
1156
- if (options && typeof options === 'object' && options.paths && options.baseUrl === undefined) {
1157
- return JSON.stringify({ ...parsed, compilerOptions: { ...options, baseUrl: '.' } });
1158
- }
1159
- } catch { /* preserve malformed input so the compiler reports it */ }
1160
- return tsconfigText;
1161
- }
1162
-
1163
- /**
1164
- * Scan `frontendFiles` for bare imports that are NOT resolvable through any of
1165
- * the mechanisms esbuild uses. Returns a Map<rootPkg, filePaths[]>.
1166
- *
1167
- * Called after ensureDeps so build-root node_modules reflects installed deps.
1168
- * `aliasMatchers` are the tsconfig `paths` prefixes esbuild also resolves.
1169
- * Wrapped in compile() so any unexpected error degrades to 0 findings, never
1170
- * a crash (same discipline as reviewDependencies).
1171
- */
1172
- function detectPhantomImports(frontendFiles, root, aliasMatchers = [], declaredDevDeps = []) {
1173
- const buildRootNm = path.join(root, 'node_modules');
1174
- const polyfillKeys = new Set(Object.keys(NODE_POLYFILLS));
1175
- const devDeclared = new Set(declaredDevDeps);
1176
- const isAliased = (spec) =>
1177
- aliasMatchers.some((a) => spec === a || spec.startsWith(a + '/'));
1178
- // Map<rootPkg, string[]> — files that import a phantom package
1179
- const phantom = new Map();
1180
- for (const [filePath, content] of Object.entries(frontendFiles)) {
1181
- if (typeof content !== 'string') continue;
1182
- // Only scan JS-family source files — CSS @import is a different resolver
1183
- if (!/\.(tsx?|jsx?|mjs|cjs|js)$/i.test(filePath)) continue;
1184
- // A build config is not in the browser graph at all (tsk_424174be).
1185
- if (isBuildConfigFile(filePath)) continue;
1186
- for (const spec of extractBareSpecifiers(content)) {
1187
- // node: builtins are handled by NODE_POLYFILLS aliases; skip independently
1188
- // (node:path is not a polyfillKey but is caught by the URL-shaped :// test
1189
- // in extractBareSpecifiers already — this is belt-and-suspenders).
1190
- if (spec.startsWith('node:')) continue;
1191
- // Resolvable through a tsconfig `paths` alias (e.g. `@/App`) — esbuild
1192
- // resolves these, so they are never phantom. Check the FULL specifier
1193
- // (aliases like `@/App` have no npm-style root package).
1194
- if (isAliased(spec)) continue;
1195
- const rootPkg = specRootPkg(spec);
1196
- // PROVIDED BY THE PLATFORM (`somewhere/db`, `somewhere:api`, …). Not npm
1197
- // packages, so "add it to your package.json" is wrong advice on code our
1198
- // own docs hand the developer (tsk_53badecfb7).
1199
- if (PLATFORM_MODULE_ROOTS.has(rootPkg)) continue;
1200
- // DECLARED in devDependencies. It IS declared, so "add it to your
1201
- // package.json" is already done and warning about it is a false
1202
- // positive with wrong remediation (tsk_424174be) — whether or not it
1203
- // resolved. (Since tsk_2a7c6b33 a dev-declared package the source
1204
- // imports IS installed by name, so this branch is now mostly reached
1205
- // by build-config imports, which are skipped above anyway.)
1206
- if (devDeclared.has(rootPkg)) continue;
1207
- // Resolvable through NODE_POLYFILLS alias (e.g. 'buffer', 'process')
1208
- if (polyfillKeys.has(rootPkg)) continue;
1209
- // Resolvable from per-build install (declared in package.json + installed)
1210
- if (isResolvable(rootPkg, [buildRootNm])) continue;
1211
- // Resolvable from the pre-baked image (react@18, zustand, lucide-react, …)
1212
- if (isResolvable(rootPkg, IMAGE_NODE_MODULES)) continue;
1213
- // Resolvable from the isolated React-19 baked set
1214
- if (REACT19_NODE_MODULES && isResolvable(rootPkg, [REACT19_NODE_MODULES])) continue;
1215
- // Not resolvable through any means — phantom import
1216
- if (!phantom.has(rootPkg)) phantom.set(rootPkg, []);
1217
- phantom.get(rootPkg).push(filePath);
1218
- }
1219
- }
1220
- return phantom;
1221
- }
1222
-
1223
- /**
1224
- * Real Tailwind + PostCSS over the customer's CSS — the headline of the
1225
- * migration. v3 (`@tailwind`/config) and v4 (`@import "tailwindcss"`) run their
1226
- * actual engines against the materialized filesystem, so JIT scanning sees the
1227
- * source and emits the utilities the app uses. No runtime CDN, no `sw-css-noop`.
1228
- * Returns an esbuild onLoad plugin scoped to `.css`.
1229
- *
1230
- * cwd is the build root for the duration of the build (set by caller), so
1231
- * Tailwind v3 auto-loads the customer's tailwind.config.js (via jiti, ESM/TS
1232
- * configs included) and resolves its `content` globs relative to the project,
1233
- * exactly like `vite build` locally.
1234
- */
1235
- function tailwindCssPlugin(twVersion, root) {
1236
- const postcss = imageRequire('postcss');
1237
- const autoprefixer = imageRequire('autoprefixer');
1238
-
1239
- function buildProcessor() {
1240
- if (twVersion === 4) {
1241
- const tw4 = tw4Require('@tailwindcss/postcss');
1242
- return postcss([tw4({ base: root }), autoprefixer]);
1243
- }
1244
- const tailwind3 = imageRequire('tailwindcss');
1245
- const hasConfig = ['tailwind.config.js', 'tailwind.config.cjs', 'tailwind.config.mjs', 'tailwind.config.ts']
1246
- .some((f) => fs.existsSync(path.join(root, f)));
1247
- // With a config present, tailwind v3 auto-loads it from cwd (=root). Without
1248
- // one (utility classes but no config file), give it explicit content globs
1249
- // so JIT still scans the source instead of erroring/emitting nothing.
1250
- const tw = hasConfig
1251
- ? tailwind3()
1252
- : tailwind3({ content: [path.join(root, '**/*.{html,js,ts,jsx,tsx,mjs,cjs}')], theme: { extend: {} }, plugins: [] });
1253
- return postcss([tw, autoprefixer]);
1254
- }
1255
-
1256
- return {
1257
- name: 'somewhere-tailwind-css',
1258
- setup(build) {
1259
- const processor = buildProcessor();
1260
- build.onLoad({ filter: /\.css$/ }, async (args) => {
1261
- // CSS Modules (*.module.css) are class-name maps, not stylesheets to run
1262
- // Tailwind over — let esbuild's native local-css loader handle them.
1263
- // (Go RE2 has no lookbehind, so we filter all .css then bail here.)
1264
- if (/\.module\.css$/i.test(args.path)) return undefined;
1265
- let raw;
1266
- try { raw = await fsp.readFile(args.path, 'utf8'); }
1267
- catch (e) { return { errors: [{ text: `Cannot read CSS ${args.path}: ${e.message}` }] }; }
1268
- try {
1269
- const result = await processor.process(raw, { from: args.path, to: args.path });
1270
- return { contents: result.css, loader: 'css' };
1271
- } catch (e) {
1272
- // Invariant #6: a Tailwind/PostCSS failure is a loud, actionable
1273
- // compile error — never empty CSS served as "success".
1274
- return { errors: [{ text: `Tailwind/PostCSS failed on ${path.relative(root, args.path)}: ${e.message}` }] };
1275
- }
1276
- });
1277
- },
1278
- };
1279
- }
1280
-
1281
- /**
1282
- * PR5 — tsc --noEmit type checking (directive Tier 1, invariant #6). Opt-in per
1283
- * request (`typecheck:true`) so the deploy hot-path stays fast; the /v1/compile
1284
- * inspect-fix loop turns it on. Reports type errors in the customer's OWN typed
1285
- * code. Module-resolution noise (TS2307) and implicit-any-from-untyped-deps
1286
- * (TS7016) are filtered: esbuild already proved every import resolves, so a
1287
- * TS2307 here only means "this dep ships no .d.ts" — not an app error. Untyped
1288
- * externals collapse to `any`, so real errors in the customer's annotations
1289
- * (wrong shapes, bad generics) still surface. NON-blocking: type errors are
1290
- * reported as metadata, never fail the build (rule 9 — surface before gating).
1291
- */
1292
- const TSC_IGNORE_CODES = new Set([2307, 7016, 2792, 6053]);
1293
- function runTypecheck(root, tsconfigText) {
1294
- const ts = imageRequire('typescript');
1295
- const tsFiles = [];
1296
- (function walk(dir) {
1297
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1298
- if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
1299
- const p = path.join(dir, e.name);
1300
- if (e.isDirectory()) walk(p);
1301
- else if (/\.tsx?$/.test(e.name)) tsFiles.push(p);
1302
- }
1303
- })(root);
1304
- if (!tsFiles.length) return { errors: [], checkedFiles: 0 };
1305
-
1306
- let userOpts = {};
1307
- if (typeof tsconfigText === 'string') {
1308
- try { userOpts = (ts.parseConfigFileTextToJson('tsconfig.json', tsconfigText).config || {}).compilerOptions || {}; }
1309
- catch { /* malformed tsconfig — use the lenient default below */ }
1310
- }
1311
- const { options } = ts.convertCompilerOptionsFromJson(
1312
- {
1313
- jsx: 'react-jsx', target: 'es2022', module: 'esnext', moduleResolution: 'bundler',
1314
- esModuleInterop: true, skipLibCheck: true, strict: false, noImplicitAny: false,
1315
- allowJs: false, ...userOpts, noEmit: true,
1316
- },
1317
- root,
1318
- );
1319
- const program = ts.createProgram(tsFiles, options);
1320
- const errors = [];
1321
- for (const d of ts.getPreEmitDiagnostics(program)) {
1322
- if (d.category !== ts.DiagnosticCategory.Error) continue;
1323
- if (d.code && TSC_IGNORE_CODES.has(d.code)) continue;
1324
- const message = ts.flattenDiagnosticMessageText(d.messageText, '\n');
1325
- let loc = {};
1326
- if (d.file && d.start != null) {
1327
- const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
1328
- loc = { file: path.relative(root, d.file.fileName), line: line + 1, col: character + 1 };
1329
- }
1330
- errors.push({ code: `TS${d.code}`, message, ...loc });
1331
- }
1332
- return { errors, checkedFiles: tsFiles.length };
1333
- }
1334
-
1335
- /**
1336
- * Deploy-validation reference check (tsk §1 deploy-validation-gate). The
1337
- * narrow, ALWAYS-SAFE cousin of runTypecheck, purpose-built for the deploy
1338
- * gate that flags an UNDEFINED REFERENCE — the Bolo `sanitizeForSpeech is
1339
- * not defined` class: code esbuild bundles past (valid syntax) but that
1340
- * throws at runtime because nothing in the graph defines the name.
1341
- *
1342
- * Differences from runTypecheck that make it catch that class without
1343
- * false-positiving on normal customer code:
1344
- * - Walks .js/.jsx TOO (allowJs:true + checkJs:true). The Bolo bug was in
1345
- * a .jsx; runTypecheck's allowJs:false skips those files entirely.
1346
- * - Includes the DOM lib so window/fetch/localStorage/navigator/etc.
1347
- * resolve — without it every browser global reads as an undefined name
1348
- * (a wall of false positives on real frontend code).
1349
- * - Filters module-resolution noise (TS2307 etc., same set as
1350
- * runTypecheck) AND scopes the SURFACED set to reference codes only on
1351
- * the worker side — here we return EVERY non-ignored error and let the
1352
- * worker pick the reference subset, so the container stays the single
1353
- * source of "what tsc said" and the gate's policy lives in one place.
1354
- *
1355
- * Returns the same {code, message, file, line, col} shape as runTypecheck.
1356
- * Never throws into compile() — the caller wraps it.
1357
- */
1358
- function runReferenceCheck(root, tsconfigText) {
1359
- const ts = imageRequire('typescript');
1360
- const checkFiles = [];
1361
- (function walk(dir) {
1362
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1363
- if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
1364
- const p = path.join(dir, e.name);
1365
- if (e.isDirectory()) walk(p);
1366
- else if (/\.(?:tsx?|jsx?|mjs|cjs)$/.test(e.name)) checkFiles.push(p);
1367
- }
1368
- })(root);
1369
- if (!checkFiles.length) return { errors: [], checkedFiles: 0 };
1370
-
1371
- let userOpts = {};
1372
- if (typeof tsconfigText === 'string') {
1373
- try { userOpts = (ts.parseConfigFileTextToJson('tsconfig.json', tsconfigText).config || {}).compilerOptions || {}; }
1374
- catch { /* malformed tsconfig — use the lenient default below */ }
1375
- }
1376
- const { options } = ts.convertCompilerOptionsFromJson(
1377
- {
1378
- jsx: 'react-jsx', target: 'es2022', module: 'esnext', moduleResolution: 'bundler',
1379
- esModuleInterop: true, skipLibCheck: true, strict: false, noImplicitAny: false,
1380
- // DOM + es2022 so browser globals (window/fetch/localStorage/…) resolve
1381
- // and don't read as undefined names — the whole reason this differs from
1382
- // runTypecheck. NOTE: convertCompilerOptionsFromJson expects the SHORT
1383
- // lib names ('es2022','dom'), not the lib.*.d.ts filenames (those are
1384
- // rejected → empty lib → every browser global false-positives).
1385
- lib: ['es2022', 'dom', 'dom.iterable'],
1386
- ...userOpts,
1387
- // These are forced ON regardless of the customer's tsconfig — the gate
1388
- // exists to check .js/.jsx for undefined refs, and a customer
1389
- // allowJs:false would silently disable it.
1390
- allowJs: true, checkJs: true, noEmit: true,
1391
- },
1392
- root,
1393
- );
1394
- const program = ts.createProgram(checkFiles, options);
1395
- const errors = [];
1396
- for (const d of ts.getPreEmitDiagnostics(program)) {
1397
- if (d.category !== ts.DiagnosticCategory.Error) continue;
1398
- if (d.code && TSC_IGNORE_CODES.has(d.code)) continue;
1399
- const message = ts.flattenDiagnosticMessageText(d.messageText, '\n');
1400
- let loc = {};
1401
- if (d.file && d.start != null) {
1402
- const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
1403
- loc = { file: path.relative(root, d.file.fileName), line: line + 1, col: character + 1 };
1404
- }
1405
- errors.push({ code: `TS${d.code}`, message, ...loc });
1406
- }
1407
- return { errors, checkedFiles: checkFiles.length };
1408
- }
1409
-
1410
- async function compileTransforms(body) {
1411
- const entries = Array.isArray(body.entries) ? body.entries : [];
1412
- if (!entries.length) throw new Error('`entries` is required');
1413
- const transforms = {};
1414
- for (const entry of entries) {
1415
- assertSourcePath(entry);
1416
- const source = body.files[entry];
1417
- const loader = sourceLoader(entry);
1418
- if (typeof source !== 'string' || (loader !== 'jsx' && loader !== 'tsx')) {
1419
- throw new Error(`transform entry ${JSON.stringify(entry)} must be a .jsx or .tsx source file`);
1420
- }
1421
- const out = await esbuild.transform(source, {
1422
- loader,
1423
- format: 'esm',
1424
- target: 'es2022',
1425
- jsx: 'automatic',
1426
- sourcemap: 'inline',
1427
- sourcefile: entry,
1428
- logLevel: 'silent',
1429
- });
1430
- transforms[entry] = { js: out.code, warnings: out.warnings.map((warning) => warning.text) };
1431
- }
1432
- return { ok: true, transforms, warnings: [], compiler: compilerStamp() };
1433
- }
1434
-
1435
- async function parseSources(body) {
1436
- const errors = [];
1437
- for (const [file, source] of Object.entries(body.files)) {
1438
- if (typeof source !== 'string') continue;
1439
- const loader = sourceLoader(file);
1440
- if (!loader || loader === 'json') continue;
1441
- try {
1442
- await esbuild.transform(source, { loader, target: 'esnext', format: 'esm', logLevel: 'silent', sourcefile: file });
1443
- } catch (err) {
1444
- const messages = Array.isArray(err && err.errors) ? err.errors : [];
1445
- if (messages.length) {
1446
- for (const message of messages.slice(0, 5)) {
1447
- errors.push({
1448
- file,
1449
- line: message.location && message.location.line,
1450
- column: message.location ? message.location.column + 1 : undefined,
1451
- message: message.text,
1452
- line_text: message.location && message.location.lineText,
1453
- });
1454
- }
1455
- } else {
1456
- errors.push({ file, message: err && err.message ? err.message : String(err) });
1457
- }
1458
- if (errors.length >= 20) break;
1459
- }
1460
- }
1461
- return { ok: true, errors, warnings: [], compiler: compilerStamp() };
1462
- }
1463
-
1464
- async function compileFunctionEntries(root, entries, tsconfigText) {
1465
- if (!entries.length) return { functions: {}, warnings: [], metafile: null, graph: { edges: [] } };
1466
- const entryPoints = {};
1467
- const outputNames = new Map();
1468
- entries.forEach((entry, index) => {
1469
- const name = `__sw_function_${String(index).padStart(4, '0')}`;
1470
- entryPoints[name] = entry;
1471
- outputNames.set(`${name}.js`, entry);
1472
- });
1473
- const buildOptions = {
1474
- absWorkingDir: root,
1475
- entryPoints,
1476
- bundle: true,
1477
- splitting: false,
1478
- format: 'esm',
1479
- platform: 'neutral',
1480
- target: 'es2022',
1481
- jsx: 'automatic',
1482
- sourcemap: 'inline',
1483
- write: false,
1484
- metafile: true,
1485
- outdir: 'function-out',
1486
- entryNames: '[name]',
1487
- nodePaths: IMAGE_NODE_MODULES,
1488
- external: ['node:*', 'cloudflare:*', 'workerd:*'],
1489
- define: {
1490
- 'import.meta.env.DEV': 'false',
1491
- 'import.meta.env.PROD': 'true',
1492
- 'import.meta.env.MODE': '"production"',
1493
- 'import.meta.env.SSR': 'true',
1494
- 'import.meta.env.BASE_URL': '"/"',
1495
- 'process.env.NODE_ENV': '"production"',
1496
- },
1497
- plugins: [functionLocalImportPlugin(root)],
1498
- };
1499
- const tsconfigPath = path.join(root, 'tsconfig.json');
1500
- if (fs.existsSync(tsconfigPath)) buildOptions.tsconfig = tsconfigPath;
1501
- const result = await esbuild.build(buildOptions);
1502
- const completeGraph = graphFromMetafile(result.metafile, root);
1503
- const functions = {};
1504
- for (const output of result.outputFiles || []) {
1505
- const entry = outputNames.get(path.basename(output.path));
1506
- if (!entry) continue;
1507
- functions[entry] = {
1508
- js: output.text,
1509
- inlined: [entry],
1510
- warnings: [],
1511
- };
1512
- }
1513
- for (const entry of entries) {
1514
- if (!functions[entry]) throw new Error(`bundler emitted no output for ${entry}`);
1515
- }
1516
- return { functions, warnings: result.warnings.map((warning) => warning.text), metafile: result.metafile, graph: completeGraph };
1517
- }
1518
-
1519
- async function compile(body, ctx) {
1520
- validateCompileInput(body);
1521
- const operation = body.operation || 'project';
1522
- if (operation === 'parse') return parseSources(body);
1523
- if (operation !== 'project') throw new Error(`unsupported compile operation: ${operation}`);
1524
- const result = await compileProject(body, ctx);
1525
- const stamped = { ...result, compiler: compilerStamp(result.source_digest) };
1526
- enforceInlineResultCap(body, stamped);
1527
- return stamped;
1528
- }
1529
-
1530
- // ctx (tsk_2553662f) carries the BUILD_TIMEOUT plumbing: `signal` (the
1531
- // AbortController the handler aborts on timeout), `setStage(name)` so the
1532
- // timeout 504 can report which stage stalled, and `npmChild` (the in-flight npm
1533
- // process, so the abort kills it). Optional — the in-process harness/test path
1534
- // calls compile(body) with no ctx and just runs to completion.
1535
- async function compileProject(body, ctx) {
1536
- const files = { ...body.files };
1537
- const requestedEntry = typeof body.entry === 'string' && body.entry ? body.entry : null;
1538
- const entry = requestedEntry || '__somewhere_empty_entry.mjs';
1539
- if (!files || typeof files !== 'object') throw new Error('`files` map is required');
1540
- if (requestedEntry && files[entry] === undefined) throw new Error(`entry "${entry}" not in files map`);
1541
- if (!requestedEntry) files[entry] = 'export default null;\n';
1542
- const functionEntries = [...new Set(body.function_entries || [])].sort();
1543
- const functionPaths = new Set(body.function_paths || functionEntries);
1544
- const transformEntries = [...new Set(body.transform_entries || [])]
1545
- .filter((item) => item !== requestedEntry)
1546
- .sort();
1547
- for (const functionEntry of functionEntries) {
1548
- if (typeof files[functionEntry] !== 'string') throw new Error(`function entry ${JSON.stringify(functionEntry)} not in files map`);
1549
- }
1550
- for (const transformEntry of transformEntries) {
1551
- if (typeof files[transformEntry] !== 'string') throw new Error(`transform entry ${JSON.stringify(transformEntry)} not in files map`);
1552
- }
1553
- const digest = sourceDigest(body);
1554
- const parsed = await parseSources({ files: body.files });
1555
- if (parsed.errors.length) {
1556
- const err = codedError('SOURCE_PARSE_ERROR', parsed.errors.map((error) => `${error.file}: ${error.message}`).join('; '));
1557
- // The joined message keeps its exact existing text (the worker's error
1558
- // copy reads it), but the STRUCTURED errors ride along: {file, line,
1559
- // column, message, line_text} per bad file. A caller that wants to point
1560
- // at the offending line — the local dev loop's terminal and its in-page
1561
- // overlay — should not have to regex a paragraph back apart.
1562
- err.source_errors = parsed.errors;
1563
- throw err;
1564
- }
1565
-
1566
- // Classify functions out of the frontend graph. Every routed entry is still
1567
- // compiled below in THIS transaction, from the same materialized root and
1568
- // resolved dependency tree.
1569
- const functionRoutes = [];
1570
- const frontendFiles = {};
1571
- for (const p of Object.keys(files)) {
1572
- if (functionPaths.has(p) || isFunctionPath(p)) functionRoutes.push(p);
1573
- else frontendFiles[p] = files[p];
1574
- }
1575
-
1576
- const packageJsonText = typeof body.package_json === 'string'
1577
- ? body.package_json
1578
- : files['package.json'];
1579
- const tsconfigText = normalizeTsconfigText(
1580
- typeof body.tsconfig === 'string' ? body.tsconfig : files['tsconfig.json'],
1581
- );
1582
- let pkg = {};
1583
- if (typeof packageJsonText === 'string') {
1584
- try { pkg = JSON.parse(packageJsonText); } catch { /* malformed → no extra deps */ }
1585
- }
1586
- const viteEnv = body.vite_env || {};
1587
- const twVersion = detectTailwind(files);
1588
- const warnings = [];
1589
-
1590
- // Per-phase wall-clock instrumentation (tsk_241c7c9f). Date.now() is fine
1591
- // here — real Node in a container. Each value is ms for that phase; null =
1592
- // phase did not run on this path. Echoed under metadata.timing_ms, purely
1593
- // additive (no existing metadata field touched).
1594
- const _t0 = Date.now();
1595
- const timing = { materialize: null, install: null, typed_functions: null, bundle: null, refcheck: null, upload: null, total: null };
1596
- const _ms = (start) => Date.now() - start;
1597
-
1598
- const _tMat = Date.now();
1599
- const root = await materialize(files, body.binary_files);
1600
- if (typeof packageJsonText === 'string') {
1601
- await fsp.writeFile(path.join(root, 'package.json'), packageJsonText, 'utf8').catch(() => {});
1602
- }
1603
- if (typeof tsconfigText === 'string') {
1604
- await fsp.writeFile(path.join(root, 'tsconfig.json'), tsconfigText, 'utf8').catch(() => {});
1605
- }
1606
- timing.materialize = _ms(_tMat);
1607
-
1608
- // ── validate_only fast path (tsk §1 deploy-validation-gate) ────────────
1609
- // The deploy validation gate asks ONLY "does the module graph have an
1610
- // undefined reference?" — it does NOT want a bundle, Tailwind, or npm
1611
- // install (the slow parts). Run just the reference check over the
1612
- // materialized graph and return metadata.validation_errors. No esbuild,
1613
- // no chdir, no chunk emit. Wrapped so a tsc crash degrades to "no
1614
- // validation errors" (ran-but-empty is indistinguishable from a clean
1615
- // graph here, which is the safe direction — the worker treats an absent
1616
- // validation_errors array as "not reference-checked", never as "clean").
1617
- if (body.validate_only) {
1618
- let validationErrors = null;
1619
- const _tRef = Date.now();
1620
- try {
1621
- validationErrors = runReferenceCheck(root, tsconfigText).errors;
1622
- } catch (e) {
1623
- warnings.push(`reference check skipped: ${e && e.message ? e.message : String(e)}`);
1624
- }
1625
- timing.refcheck = _ms(_tRef);
1626
- await fsp.rm(root, { recursive: true, force: true }).catch(() => {});
1627
- timing.total = _ms(_t0);
1628
- return {
1629
- ok: true,
1630
- validate_only: true,
1631
- warnings,
1632
- metadata: { validation_errors: validationErrors, timing_ms: timing },
1633
- };
1634
- }
1635
-
1636
- const prevCwd = process.cwd();
1637
- try {
1638
- // PR2 npm proxy — when the deploy path supplies a registry+token, route this
1639
- // build's `npm install` through the worker /v1/npm cache. Written as a
1640
- // build-root .npmrc; ensureDeps runs npm with cwd=root so it's picked up.
1641
- // always-auth so the bearer rides tarball GETs too. The token is a
1642
- // short-lived proxy capability, NOT a registry credential (credential desert
1643
- // preserved). Without it, a build that needs an install fails closed.
1644
- const hasScopedProxy = typeof body.npm_registry === 'string' && !!body.npm_registry
1645
- && typeof body.npm_token === 'string' && !!body.npm_token;
1646
- if (hasScopedProxy) {
1647
- try {
1648
- const reg = body.npm_registry.endsWith('/') ? body.npm_registry : body.npm_registry + '/';
1649
- const authKey = reg.replace(/^https?:/, '');
1650
- await fsp.writeFile(
1651
- path.join(root, '.npmrc'),
1652
- `registry=${reg}\n${authKey}:_authToken=${body.npm_token}\nalways-auth=true\n`,
1653
- 'utf8',
1654
- );
1655
- } catch (e) {
1656
- warnings.push(`npm proxy config skipped: ${e && e.message ? e.message : String(e)}`);
1657
- }
1658
- }
1659
-
1660
- // Resolve the long-tail deps before building (invariant #2). Awaited +
1661
- // abortable now (tsk_2553662f): a heavy install can be killed by the
1662
- // BUILD_TIMEOUT instead of blocking the event loop past the budget.
1663
- const _tInstall = Date.now();
1664
- // Two different questions. `hasScopedProxy` is "did this REQUEST carry
1665
- // proxy credentials", and it decides the .npmrc above. Whether an
1666
- // install may run AT ALL without them is a property of the HOST: the
1667
- // container is egress-locked and must fail closed, the CLI reaches npm
1668
- // directly through the developer's own config. Default is the container's
1669
- // refusal, so opening it is always deliberate.
1670
- // `files` (frontend AND function sources) is what decides the one
1671
- // devDependency carve-out — a dev-declared package the app imports is
1672
- // installed by name so the install can otherwise skip the dev closure.
1673
- await ensureDeps(root, pkg, warnings, ctx, hasScopedProxy || host.requiresPackageProxy === false, files);
1674
- timing.install = _ms(_tInstall);
1675
-
1676
- // Dependency review (PR1) — WARN-FIRST, non-blocking. A scanner bug must
1677
- // never break a build (that's the exact rule-9 anti-pattern), so it's
1678
- // wrapped: on error we say so in warnings and continue without the block.
1679
- let dependencyReview = null;
1680
- try {
1681
- dependencyReview = reviewDependencies(root, pkg);
1682
- if (dependencyReview.knownBad.length) {
1683
- const list = dependencyReview.knownBad.map((b) => `${b.name}@${b.version}`).join(', ');
1684
- const n = dependencyReview.knownBad.length;
1685
- warnings.push(
1686
- `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.`,
1687
- );
1688
- }
1689
- } catch (e) {
1690
- warnings.push(`dependency review skipped: ${e && e.message ? e.message : String(e)}`);
1691
- }
1692
-
1693
- // Tailwind v3 config auto-load + content scanning resolve relative to cwd.
1694
- process.chdir(root);
1695
-
1696
- // v4's `@import "tailwindcss"` is resolved (by @tailwindcss/postcss's
1697
- // resolver) relative to the importing CSS file — it expects the project to
1698
- // have `tailwindcss` installed. We substitute the isolated v4 engine, so
1699
- // link it into the build root's node_modules where the resolver walks up
1700
- // to find it. (No-op if the customer somehow shipped their own.)
1701
- if (twVersion === 4) {
1702
- const nm = path.join(root, 'node_modules');
1703
- await fsp.mkdir(nm, { recursive: true });
1704
- const link = path.join(nm, 'tailwindcss');
1705
- if (!fs.existsSync(link)) {
1706
- if (host.tw4TailwindDir) await fsp.symlink(host.tw4TailwindDir, link, 'dir').catch(() => {});
1707
- }
1708
- }
1709
-
1710
- // Typed server functions are sentinel-gated. analyzeTypedFunctions returns
1711
- // after one string scan when no exact `somewhere:v1` candidate exists, so
1712
- // ordinary deploys never construct a TypeScript Program. Stage 1 is
1713
- // warning-only: an analyzer defect must not reject otherwise-working code.
1714
- let typedAnalysis = {
1715
- manifest: null,
1716
- summary: { procedures: 0, contract_digest: null, warnings: 0 },
1717
- warnings: [],
1718
- declaration: null,
1719
- runtime: typedFunctions.RUNTIME_SOURCE,
1720
- timing_ms: { scan: 0, typecheck: 0, total: 0 },
1721
- };
1722
- try {
1723
- typedAnalysis = typedFunctions.analyzeTypedFunctions({
1724
- loadTypescript: () => imageRequire('typescript'),
1725
- root,
1726
- files,
1727
- functionEntries,
1728
- tsconfigText,
1729
- });
1730
- warnings.push(...typedAnalysis.warnings);
1731
- } catch (e) {
1732
- 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)})`);
1733
- }
1734
- timing.typed_functions = typedAnalysis.timing_ms;
1735
-
1736
- const tsconfigPath = path.join(root, 'tsconfig.json');
1737
- const buildOpts = {
1738
- absWorkingDir: root,
1739
- entryPoints: [entry],
1740
- bundle: true,
1741
- splitting: true,
1742
- format: 'esm',
1743
- target: 'es2022',
1744
- jsx: 'automatic',
1745
- sourcemap: 'inline',
1746
- write: false,
1747
- metafile: true,
1748
- outdir: 'out',
1749
- entryNames: '[name]-[hash]',
1750
- chunkNames: 'chunk-[hash]',
1751
- assetNames: 'asset-[hash]',
1752
- // A .js entry only ever comes from the CRA adapter (module-script
1753
- // detection is .jsx/.tsx-only, so no pre-existing project can reach
1754
- // here with one) — CRA allows JSX in .js files, so parse all .js as
1755
- // JSX for those builds. The jsx loader is a superset of js.
1756
- loader: { ...ASSET_LOADERS, ...(/\.js$/i.test(entry) ? { '.js': 'jsx' } : {}) },
1757
- // esbuild resolves bare deps from root/node_modules first, then the
1758
- // pre-baked image set (flag #1). Single node_modules tree per build =
1759
- // single React instance, without the retired CDN dependency workaround.
1760
- nodePaths: IMAGE_NODE_MODULES,
1761
- // Node builtins → browser polyfills (so packages importing buffer/stream/
1762
- // etc. resolve instead of hard-erroring). esbuild resolves these targets
1763
- // from the pre-baked node_modules via nodePaths.
1764
- alias: { ...NODE_POLYFILLS },
1765
- define: {
1766
- 'import.meta.env.DEV': 'false',
1767
- 'import.meta.env.PROD': 'true',
1768
- 'import.meta.env.MODE': '"production"',
1769
- 'import.meta.env.SSR': 'false',
1770
- 'import.meta.env.BASE_URL': '"/"',
1771
- 'process.env.NODE_ENV': '"production"',
1772
- // Node polyfills (the buffer/process packages) reference the `global`
1773
- // identifier, which the browser doesn't define. Map it to globalThis.
1774
- global: 'globalThis',
1775
- ...buildViteEnvDefines(viteEnv, files),
1776
- ...buildReactEnvDefines(viteEnv, files),
1777
- },
1778
- plugins: [
1779
- typedFunctions.virtualApiPlugin(esbuild),
1780
- urlAssetPlugin(root, body.binary_paths),
1781
- svgPlugin(root),
1782
- ...(twVersion > 0 ? [tailwindCssPlugin(twVersion, root)] : []),
1783
- ],
1784
- };
1785
- if (fs.existsSync(tsconfigPath)) buildOpts.tsconfig = tsconfigPath;
1786
-
1787
- // Stage marker (tsk_2553662f): we're past install, now bundling. If the
1788
- // BUILD_TIMEOUT fires during esbuild, its 504 reports stage='bundling'.
1789
- if (ctx && ctx.setStage) ctx.setStage('bundling');
1790
- // Use an esbuild CONTEXT so a BUILD_TIMEOUT abort can CANCEL the in-flight
1791
- // build (and dispose the worker) instead of leaking it. Without a signal
1792
- // (the local harness) this is a plain build()+dispose, behaviourally
1793
- // identical to the old esbuild.build() call.
1794
- let result;
1795
- const _tBundle = Date.now();
1796
- const bctx = await esbuild.context(buildOpts);
1797
- try {
1798
- const onAbort = () => { bctx.cancel().catch(() => {}); };
1799
- if (ctx && ctx.signal) {
1800
- if (ctx.signal.aborted) onAbort();
1801
- else ctx.signal.addEventListener('abort', onAbort, { once: true });
1802
- }
1803
- result = await bctx.rebuild();
1804
- if (ctx && ctx.signal) ctx.signal.removeEventListener('abort', onAbort);
1805
- } finally {
1806
- await bctx.dispose();
1807
- timing.bundle = _ms(_tBundle);
1808
- }
1809
- for (const w of result.warnings) warnings.push(w.text);
1810
-
1811
- // Phantom-import detection (tsk_8e460b69): esbuild tree-shakes unused named
1812
- // imports before resolution, so `import { x } from 'missing-pkg'` where `x`
1813
- // is never referenced silently succeeds. Scan the source for bare specifiers
1814
- // that are NOT resolvable through any of esbuild's search paths and surface
1815
- // them as warnings. RULE 9 — always non-blocking; scanner errors degrade to
1816
- // 0 findings, never a crash.
1817
- try {
1818
- const aliasMatchers = tsconfigAliasMatchers(tsconfigText);
1819
- const phantom = detectPhantomImports(
1820
- frontendFiles, root, aliasMatchers, Object.keys((pkg && pkg.devDependencies) || {}),
1821
- );
1822
- for (const [pkg, files] of phantom) {
1823
- const uniq = [...new Set(files)];
1824
- const where = uniq.slice(0, 2).join(', ') + (uniq.length > 2 ? ` and ${uniq.length - 2} other file(s)` : '');
1825
- warnings.push(
1826
- `\`${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.`,
1827
- );
1828
- }
1829
- } catch (e) {
1830
- warnings.push(`phantom-import scan skipped: ${e && e.message ? e.message : String(e)}`);
1831
- }
1832
-
1833
- const entryBase = entry.split('/').pop().replace(/\.[^.]+$/, '');
1834
- const chunks = {};
1835
- let entryChunk = null;
1836
- for (const f of result.outputFiles || []) {
1837
- const name = f.path.split('/').pop();
1838
- if (requestedEntry) {
1839
- chunks[name] = f.text;
1840
- if (!entryChunk && name.startsWith(`${entryBase}-`) && name.endsWith('.js')) entryChunk = name;
1841
- }
1842
- }
1843
- if (requestedEntry && !entryChunk) throw new Error('bundler emitted no entry chunk');
1844
-
1845
- // Every routed function is compiled in one batched esbuild invocation from
1846
- // this same root/dependency tree. No per-entry container request exists.
1847
- if (ctx && ctx.setStage) ctx.setStage('bundling-functions');
1848
- const functionBuild = await compileFunctionEntries(root, functionEntries, tsconfigText);
1849
- warnings.push(...functionBuild.warnings);
1850
-
1851
- // Independently referenced JSX/TSX module entries are transformation
1852
- // artifacts of this same complete project transaction, not another
1853
- // publication request.
1854
- const transformBuild = transformEntries.length
1855
- ? await compileTransforms({ entries: transformEntries, files })
1856
- : { transforms: {} };
1857
- const transforms = transformBuild.transforms || {};
1858
-
1859
- // PR5 — opt-in type checking. Non-blocking: a type error is reported in
1860
- // metadata, never a build failure (rule 9). A tsc crash degrades to a
1861
- // warning, not a lost build.
1862
- let typeErrors = null;
1863
- if (body.typecheck) {
1864
- try { typeErrors = runTypecheck(root, tsconfigText).errors; }
1865
- catch (e) { warnings.push(`type check skipped: ${e && e.message ? e.message : String(e)}`); }
1866
- }
1867
-
1868
- // Manifest path (tsk_118c1ecc): when the worker handed us an upload
1869
- // capability, write the chunks to storage OURSELVES and answer with a
1870
- // manifest only — the result stays small no matter how heavy the build.
1871
- // Otherwise enforce the worker's inline cap: a result the deploy Worker
1872
- // provably cannot parse must be a LOUD deterministic error here, never a
1873
- // 36MB body that hangs the request.
1874
- let uploadedChunks = null;
1875
- const functionArtifacts = Object.entries(functionBuild.functions).map(([pathname, artifact]) =>
1876
- artifactDescriptor(pathname, 'function', artifact.js));
1877
- const transformArtifacts = Object.entries(transforms).map(([pathname, artifact]) =>
1878
- artifactDescriptor(pathname, 'transform', artifact.js));
1879
- const staticArtifacts = Object.entries(chunks).map(([name, text]) =>
1880
- artifactDescriptor(`_compiled/${name}`, 'static', text));
1881
- const artifacts = [...staticArtifacts, ...functionArtifacts, ...transformArtifacts]
1882
- .sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
1883
- if (artifacts.length > MAX_ARTIFACT_COUNT) {
1884
- throw codedError('RESULT_TOO_LARGE', `compiled artifact count ${artifacts.length} > ${MAX_ARTIFACT_COUNT}`);
1885
- }
1886
- if (body.artifact_upload && typeof body.artifact_upload.url === 'string' && typeof body.artifact_upload.token === 'string') {
1887
- const _tUpload = Date.now();
1888
- if (typeof host.uploadArtifacts !== 'function') {
1889
- throw codedError('ARTIFACT_UPLOAD_FAILED', 'this compiler host cannot upload artifacts');
1890
- }
1891
- uploadedChunks = await host.uploadArtifacts(body.artifact_upload, chunks);
1892
- timing.upload = _ms(_tUpload);
1893
- }
1894
-
1895
- timing.total = _ms(_t0);
1896
- const dependencyDigest = resolvedDependencyDigest(dependencyReview);
1897
- return {
1898
- ok: true,
1899
- entry_chunk: entryChunk,
1900
- chunks: uploadedChunks ? {} : chunks,
1901
- ...(uploadedChunks ? { uploaded_chunks: uploadedChunks } : {}),
1902
- functions: functionBuild.functions,
1903
- function_graph: functionBuild.graph,
1904
- transforms,
1905
- errors: [],
1906
- source_digest: digest,
1907
- artifact_manifest: {
1908
- project_id: body.project_id,
1909
- build_id: body.build_id,
1910
- source_digest: digest,
1911
- dependency_digest: dependencyDigest,
1912
- compiler: compilerStamp(digest),
1913
- artifacts,
1914
- },
1915
- // The compiler emits Tailwind in the CSS chunk — the worker must not inject
1916
- // the runtime CDN. Always false; the compiled stylesheet rides in chunks.
1917
- needs_tailwind: false,
1918
- // Mirrored at top level (in addition to metadata.warnings) so the worker's
1919
- // existing bundleProject(), which reads `body.warnings`, is drop-in on the
1920
- // PR3 cutover without a response remap.
1921
- warnings,
1922
- graph: graphFromMetafile(result.metafile, root),
1923
- metadata: {
1924
- timing_ms: timing,
1925
- importGraph: importGraphFromMetafile(result.metafile, root),
1926
- routes: { static: Object.keys(frontendFiles).filter((item) => item !== '__somewhere_empty_entry.mjs'), functions: functionRoutes },
1927
- tailwind: twVersion > 0 ? { version: twVersion, compiled: true } : null,
1928
- typeErrors,
1929
- typed_functions: {
1930
- manifest: typedAnalysis.manifest,
1931
- summary: typedAnalysis.summary,
1932
- timing_ms: typedAnalysis.timing_ms,
1933
- },
1934
- // Dependency review (PR1) — what the customer's resolved tree contains.
1935
- // Null only if the scan itself threw (a `dependency review skipped`
1936
- // warning is then present). knownBad already echoed into warnings above.
1937
- dependency_review: dependencyReview,
1938
- dependency_digest: dependencyDigest,
1939
- warnings,
1940
- // TODO PR4: prerenderedHtml via renderToString in headless Chromium.
1941
- // TODO: securityFindings, bundleAnalysis.
1942
- },
1943
- };
1944
- } finally {
1945
- process.chdir(prevCwd);
1946
- await fsp.rm(root, { recursive: true, force: true }).catch(() => {});
1947
- }
1948
- }
1949
- return {
1950
- // The compile contract — identical signatures to the pre-extraction
1951
- // server.js exports, so the container's consumers and contract tests are
1952
- // untouched by the move.
1953
- compile,
1954
- compileProject,
1955
- compileTransforms,
1956
- parseSources,
1957
- compilerStamp,
1958
- // Exported for the container's regression fixtures (and reused by the CLI's
1959
- // parity fixture). See server.js's module.exports for what each one guards.
1960
- detectTailwind,
1961
- reviewDependencies,
1962
- runTypecheck,
1963
- runReferenceCheck,
1964
- ensureDeps,
1965
- bakedSatisfies,
1966
- react19BakedDir,
1967
- detectPhantomImports,
1968
- extractBareSpecifiers,
1969
- tsconfigAliasMatchers,
1970
- normalizeTsconfigText,
1971
- isFunctionPath,
1972
- sourceDigest,
1973
- REACT19_NODE_MODULES,
1974
- IMAGE_NODE_MODULES,
1975
- };
1976
- }
1977
-
1978
- module.exports = {
1979
- createCompileCore,
1980
- COMPILER_CONTRACT,
1981
- // Install-spec derivation (tsk_f79d71ce) — pure, exported for the fixture
1982
- // that pins the rule in both directions (lockfile present / absent).
1983
- LOCKFILE_NAMES,
1984
- lockfileVersions,
1985
- resolveInstallSpec,
1986
- cleanRange,
1987
- // The devDependency carve-out (tsk_2a7c6b33) — pure, exported so the fixture
1988
- // can pin BOTH directions: the toolchain is dropped, an imported dev-declared
1989
- // package is not.
1990
- importedDevDeps,
1991
- NODE_POLYFILLS,
1992
- ASSET_LOADERS,
1993
- KNOWN_BAD_VERSIONS,
1994
- MAX_TOTAL_SOURCE_BYTES,
1995
- MAX_FILE_COUNT,
1996
- MAX_BINARY_BYTES,
1997
- MAX_ARTIFACT_COUNT,
1998
- GRAPH_MAX_EDGES,
1999
- GRAPH_MAX_BYTES,
2000
- importGraphFromMetafile,
2001
- graphFromMetafile,
2002
- isFunctionPath,
2003
- detectTailwind,
2004
- reviewDependencies,
2005
- extractBareSpecifiers,
2006
- sourceDigest,
2007
- };