|
1 | 1 | /** |
2 | | - * Make esbuild-wasm's child-process spawn find a Node runtime on hosts that have none. |
| 2 | + * Force esbuild-wasm's child spawn to use the extension host's own Node runtime. |
3 | 3 | * |
4 | | - * esbuild-wasm's Node build never runs the WASM in-process - `initialize` rejects the |
5 | | - * `wasmModule` option outside the browser - so every bundle spawns `node <bin/esbuild>` |
6 | | - * via a bare PATH lookup (esbuild-wasm lib/main.js). VS Code and VSCodium ship an Electron |
7 | | - * runtime, not a `node` binary on PATH, so a user without a system Node install hits |
8 | | - * `spawn node ENOENT` and the bundler dies. The built-in sslc compiler avoids this by using |
9 | | - * child_process.fork, which launches process.execPath (the extension host's own runtime) |
10 | | - * rather than resolving "node". We cannot change esbuild's spawn, but we can make "node" |
11 | | - * resolve to process.execPath by pointing PATH at it - applied ONLY when "node" is not |
12 | | - * already resolvable, so a host that already has Node is left untouched (zero regression). |
13 | | - * |
14 | | - * Extension-wide invariant (this module is its documented home): every child Node process |
15 | | - * launches via process.execPath - the runtime already executing our code - never a bare |
16 | | - * "node" PATH lookup the host may not satisfy. sslc gets this from child_process.fork's |
17 | | - * default (server/src/sslc/ssl_compiler.ts); esbuild's dep-internal spawn gets it from |
18 | | - * ensureNodeOnPath below. |
| 4 | + * esbuild-wasm's Node build can't run the WASM in-process, so it spawns `node <bin/esbuild>` via a |
| 5 | + * bare PATH lookup. That trusts whatever "node" PATH resolves to - which may be absent (an editor |
| 6 | + * ships its own runtime) or a broken shim (stale nvm/mise), and either way the child dies and |
| 7 | + * permanently kills esbuild's initialize-once service. We point "node" at process.execPath before |
| 8 | + * esbuild spawns - unconditionally, since a present-but-broken shim defeats an "only when missing" |
| 9 | + * guard. Mirrors child_process.fork's default and is the home of that invariant; sslc relies on |
| 10 | + * fork directly (server/src/sslc/ssl_compiler.ts). |
19 | 11 | */ |
20 | 12 |
|
21 | 13 | import * as fs from "fs"; |
22 | 14 | import * as os from "os"; |
23 | 15 | import * as path from "path"; |
24 | 16 |
|
25 | | -/** A decision about how to make `node` resolve; pure, so it is unit-testable. */ |
26 | | -export type NodeShimPlan = |
27 | | - | { readonly kind: "none" } |
28 | | - | { readonly kind: "prepend-dir"; readonly dir: string } |
29 | | - | { readonly kind: "shim"; readonly filename: string; readonly contents: string; readonly mode: number }; |
| 17 | +/** A `node` shim to write into an isolated dir that is then prepended to PATH. Pure, so testable. */ |
| 18 | +export interface NodeShim { |
| 19 | + readonly filename: string; |
| 20 | + readonly contents: string; |
| 21 | + readonly mode: number; |
| 22 | +} |
30 | 23 |
|
31 | 24 | /** |
32 | | - * Decide how to expose `node` for esbuild's child spawn given the host runtime. |
33 | | - * |
34 | | - * - Node already on PATH -> nothing to do. |
35 | | - * - The runtime binary is itself named `node`/`node.exe` (code-server, plain Node hosts) |
36 | | - * -> prepend its directory to PATH; no file needs writing. |
37 | | - * - Otherwise it is an Electron runtime (VS Code/VSCodium desktop) -> write a tiny `node` |
38 | | - * shim that re-execs it with ELECTRON_RUN_AS_NODE=1 so it behaves as plain Node. |
| 25 | + * Build a `node` shim that re-execs process.execPath. `ELECTRON_RUN_AS_NODE=1` makes an Electron |
| 26 | + * editor binary behave as Node; a real `node` execPath ignores it, so one shape serves both. |
39 | 27 | */ |
40 | | -export function planNodeShim(input: { |
41 | | - readonly hasNode: boolean; |
42 | | - readonly execPath: string; |
43 | | - readonly platform: NodeJS.Platform; |
44 | | -}): NodeShimPlan { |
45 | | - if (input.hasNode) return { kind: "none" }; |
46 | | - |
47 | | - // Parse execPath with the target platform's path rules, not the host's - this function |
48 | | - // is pure and unit-tested cross-platform, so a Windows path must split on backslashes |
49 | | - // even when the test runs on Linux. |
50 | | - const p = input.platform === "win32" ? path.win32 : path.posix; |
51 | | - const base = p.basename(input.execPath).toLowerCase(); |
52 | | - if (base === "node" || base === "node.exe") { |
53 | | - return { kind: "prepend-dir", dir: p.dirname(input.execPath) }; |
54 | | - } |
55 | | - |
| 28 | +export function planNodeShim(input: { readonly execPath: string; readonly platform: NodeJS.Platform }): NodeShim { |
56 | 29 | if (input.platform === "win32") { |
57 | 30 | return { |
58 | | - kind: "shim", |
59 | 31 | filename: "node.cmd", |
60 | 32 | contents: `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${input.execPath}" %*\r\n`, |
61 | 33 | mode: 0o755, |
62 | 34 | }; |
63 | 35 | } |
64 | 36 | return { |
65 | | - kind: "shim", |
66 | 37 | filename: "node", |
67 | 38 | contents: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec "${input.execPath}" "$@"\n`, |
68 | 39 | mode: 0o755, |
69 | 40 | }; |
70 | 41 | } |
71 | 42 |
|
72 | | -/** True when a `node` executable is resolvable on the current PATH. */ |
73 | | -function nodeResolvesOnPath(): boolean { |
74 | | - const names = process.platform === "win32" ? ["node.exe", "node.cmd", "node.bat"] : ["node"]; |
75 | | - for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { |
76 | | - if (!dir) continue; |
77 | | - for (const name of names) { |
78 | | - try { |
79 | | - fs.accessSync(path.join(dir, name), fs.constants.X_OK); |
80 | | - return true; |
81 | | - } catch { |
82 | | - // not here; keep looking |
83 | | - } |
84 | | - } |
85 | | - } |
86 | | - return false; |
87 | | -} |
88 | | - |
89 | 43 | let nodePathEnsured = false; |
90 | 44 |
|
91 | 45 | /** |
92 | | - * Idempotently make `node` resolvable for esbuild's child spawn (see module header). |
93 | | - * Safe to call repeatedly; a no-op once done or when the host already has Node. |
| 46 | + * Idempotently prepend an isolated `node` -> process.execPath shim to PATH so esbuild's child uses |
| 47 | + * our runtime, never PATH's `node`. Isolated dir so it shadows only `node`. No-op after the first call. |
94 | 48 | */ |
95 | 49 | export function ensureNodeOnPath(): void { |
96 | 50 | if (nodePathEnsured) return; |
97 | 51 | nodePathEnsured = true; |
98 | 52 |
|
99 | | - const plan = planNodeShim({ |
100 | | - hasNode: nodeResolvesOnPath(), |
101 | | - execPath: process.execPath, |
102 | | - platform: process.platform, |
103 | | - }); |
104 | | - |
105 | | - if (plan.kind === "none") return; |
106 | | - |
107 | | - let dir: string; |
108 | | - if (plan.kind === "prepend-dir") { |
109 | | - dir = plan.dir; |
110 | | - } else { |
111 | | - dir = fs.mkdtempSync(path.join(os.tmpdir(), "bgforge-node-")); |
112 | | - const shim = path.join(dir, plan.filename); |
113 | | - fs.writeFileSync(shim, plan.contents); |
114 | | - fs.chmodSync(shim, plan.mode); |
115 | | - } |
| 53 | + const shim = planNodeShim({ execPath: process.execPath, platform: process.platform }); |
| 54 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bgforge-node-")); |
| 55 | + const shimPath = path.join(dir, shim.filename); |
| 56 | + fs.writeFileSync(shimPath, shim.contents); |
| 57 | + fs.chmodSync(shimPath, shim.mode); |
116 | 58 | process.env.PATH = dir + path.delimiter + (process.env.PATH ?? ""); |
117 | 59 | } |
0 commit comments