Skip to content

Commit 4665002

Browse files
committed
fix(transpile): always run esbuild's child on the editor's own runtime
esbuild-wasm spawns `node <bin/esbuild>` via a bare PATH lookup, so an absent or present-but-broken `node` (a stale nvm/mise shim - the reported case) kills its initialize-once service for the session. Point `node` at process.execPath before esbuild spawns, unconditionally (a broken shim defeats an "only when missing" guard), mirroring child_process.fork. Verified: with a broken `node` on PATH esbuild dies before and works after; transpiler suite green.
1 parent fe5ce2e commit 4665002

4 files changed

Lines changed: 48 additions & 134 deletions

File tree

docs/changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
### Transpilers
2929

30-
- Fixed: transpiling a multi-file TSSL/TBAF/TD source (one that uses `import`) needed a separate Node.js install on the system `PATH` and failed on editors that ship their own runtime instead of a `node` binary. The bundler now falls back to the editor's own runtime, the same way the built-in SSL compiler already does.
30+
- Fixed: transpiling a multi-file TSSL/TBAF/TD source (one that uses `import`) relied on the system `PATH` `node`, so it broke on editors that ship their own runtime instead of a `node` binary AND in environments with a broken `node` shim (stale nvm/mise/asdf). The bundler now always uses the editor's own runtime, the same way the built-in SSL compiler does, so a missing or broken `PATH` `node` no longer affects it.
3131

3232
### Performance
3333

transpilers/common/esbuild-utils.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ let esbuildInitialized = false;
2323
* Native esbuild (used by CLI via alias) doesn't need initialize().
2424
* esbuild-wasm (used by LSP server) requires it.
2525
*
26-
* esbuild-wasm's Node build spawns `node <bin/esbuild>` via a PATH lookup, which fails on a
27-
* host with no `node` on PATH (VS Code/VSCodium ship an Electron runtime, not `node`). Point
28-
* `node` at the extension host's own runtime first, the way the sslc compiler's fork does -
29-
* see node-runtime.ts. A no-op when a real `node` is already resolvable.
26+
* esbuild-wasm spawns `node <bin/esbuild>` via a bare PATH lookup; ensureNodeOnPath points `node`
27+
* at the extension host's own runtime first (see node-runtime.ts), so an absent or broken PATH
28+
* `node` can't break bundling.
3029
*/
3130
async function ensureEsbuild(): Promise<void> {
3231
if (esbuildInitialized) return;

transpilers/common/node-runtime.ts

Lines changed: 24 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,59 @@
11
/**
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.
33
*
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).
1911
*/
2012

2113
import * as fs from "fs";
2214
import * as os from "os";
2315
import * as path from "path";
2416

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+
}
3023

3124
/**
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.
3927
*/
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 {
5629
if (input.platform === "win32") {
5730
return {
58-
kind: "shim",
5931
filename: "node.cmd",
6032
contents: `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${input.execPath}" %*\r\n`,
6133
mode: 0o755,
6234
};
6335
}
6436
return {
65-
kind: "shim",
6637
filename: "node",
6738
contents: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec "${input.execPath}" "$@"\n`,
6839
mode: 0o755,
6940
};
7041
}
7142

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-
8943
let nodePathEnsured = false;
9044

9145
/**
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.
9448
*/
9549
export function ensureNodeOnPath(): void {
9650
if (nodePathEnsured) return;
9751
nodePathEnsured = true;
9852

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);
11658
process.env.PATH = dir + path.delimiter + (process.env.PATH ?? "");
11759
}
Lines changed: 20 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,30 @@
11
import { describe, expect, test } from "vitest";
22
import { planNodeShim } from "../common/node-runtime";
33

4-
// esbuild-wasm's Node build spawns `node <bin/esbuild>` via a bare PATH lookup. planNodeShim
5-
// decides how to make "node" resolve to the host's actual runtime (process.execPath) when the
6-
// host has no `node` on PATH - mirroring what child_process.fork does for the sslc compiler.
4+
// esbuild-wasm's Node build spawns `node <bin/esbuild>` via a bare PATH lookup. planNodeShim builds
5+
// a `node` shim that re-execs process.execPath, so esbuild uses the extension host's own runtime
6+
// unconditionally - immune to a PATH `node` that is absent OR present-but-broken (a stale shim).
77
describe("planNodeShim", () => {
8-
test("no-op when node already resolves on PATH", () => {
9-
const plan = planNodeShim({ hasNode: true, execPath: "/usr/bin/node", platform: "linux" });
10-
expect(plan).toEqual({ kind: "none" });
8+
test("Unix: a `node` shim that re-execs execPath in Node mode", () => {
9+
const shim = planNodeShim({ execPath: "/opt/vscodium/codium", platform: "linux" });
10+
expect(shim.filename).toBe("node");
11+
expect(shim.mode).toBe(0o755);
12+
expect(shim.contents).toContain("#!/bin/sh");
13+
// ELECTRON_RUN_AS_NODE makes an Electron execPath behave as node; a real node ignores it.
14+
expect(shim.contents).toContain("ELECTRON_RUN_AS_NODE=1");
15+
expect(shim.contents).toContain('exec "/opt/vscodium/codium" "$@"');
1116
});
1217

13-
test("prepends the runtime dir when the runtime binary is itself named node (code-server, plain node)", () => {
14-
const plan = planNodeShim({
15-
hasNode: false,
16-
execPath: "/opt/code-server/lib/node",
17-
platform: "linux",
18-
});
19-
expect(plan).toEqual({ kind: "prepend-dir", dir: "/opt/code-server/lib" });
18+
test("Unix: the same shim shape serves a plain-node execPath (code-server)", () => {
19+
const shim = planNodeShim({ execPath: "/opt/code-server/lib/node", platform: "linux" });
20+
expect(shim.filename).toBe("node");
21+
expect(shim.contents).toContain('exec "/opt/code-server/lib/node" "$@"');
2022
});
2123

22-
test("treats node.exe basename as a node runtime on Windows", () => {
23-
const plan = planNodeShim({
24-
hasNode: false,
25-
execPath: "C:\\Program Files\\nodejs\\node.exe",
26-
platform: "win32",
27-
});
28-
expect(plan).toEqual({ kind: "prepend-dir", dir: "C:\\Program Files\\nodejs" });
29-
});
30-
31-
test("writes a node shim that re-execs an Electron runtime in node mode (Linux desktop)", () => {
32-
const plan = planNodeShim({
33-
hasNode: false,
34-
execPath: "/opt/vscodium/codium",
35-
platform: "linux",
36-
});
37-
expect(plan.kind).toBe("shim");
38-
if (plan.kind !== "shim") return;
39-
expect(plan.filename).toBe("node");
40-
expect(plan.contents).toContain("ELECTRON_RUN_AS_NODE=1");
41-
expect(plan.contents).toContain('exec "/opt/vscodium/codium"');
42-
expect(plan.mode).toBe(0o755);
43-
});
44-
45-
test("writes a node.cmd shim for an Electron runtime on Windows", () => {
46-
const plan = planNodeShim({
47-
hasNode: false,
48-
execPath: "C:\\Program Files\\VSCodium\\VSCodium.exe",
49-
platform: "win32",
50-
});
51-
expect(plan.kind).toBe("shim");
52-
if (plan.kind !== "shim") return;
53-
expect(plan.filename).toBe("node.cmd");
54-
expect(plan.contents).toContain("ELECTRON_RUN_AS_NODE=1");
55-
expect(plan.contents).toContain("VSCodium.exe");
24+
test("Windows: a node.cmd shim that re-execs execPath in Node mode", () => {
25+
const shim = planNodeShim({ execPath: "C:\\Program Files\\VSCodium\\VSCodium.exe", platform: "win32" });
26+
expect(shim.filename).toBe("node.cmd");
27+
expect(shim.contents).toContain("set ELECTRON_RUN_AS_NODE=1");
28+
expect(shim.contents).toContain('"C:\\Program Files\\VSCodium\\VSCodium.exe" %*');
5629
});
5730
});

0 commit comments

Comments
 (0)