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