Skip to content

Commit 8bea7a5

Browse files
committed
fix(transpile): resolve esbuild's child node to the editor's own runtime
esbuild-wasm's Node build never runs the WASM in-process (initialize rejects wasmModule outside the browser), and our bundler relies on esbuild's JS plugin API, so bundling a multi-file source must spawn `node <bin/esbuild>` as a service child. That spawn is a bare PATH lookup for "node", which fails on hosts that ship an Electron runtime rather than a node binary (VS Code/VSCodium): spawn node ENOENT, surfaced as the transpiler's "service is no longer running". The built-in sslc compiler already avoids this by using child_process.fork, which launches process.execPath. Mirror that: before initializing esbuild, make "node" resolve to the extension host's own runtime - prepend its directory when the runtime binary is itself named node (code-server, plain node hosts), or write a small node shim that re-execs an Electron runtime with ELECTRON_RUN_AS_NODE=1. Applied only when no node is already resolvable, so a host that has node is left untouched (no regression). Verified: with node stripped from PATH, esbuild.build fails with spawn ENOENT before this change and succeeds after; the full transpiler suite (41 tests) stays green.
1 parent 3439b42 commit 8bea7a5

4 files changed

Lines changed: 179 additions & 0 deletions

File tree

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
- Fixed: dropped several phantom TP2 keywords the grammar never matched, and corrected highlighting of the real ones.
2626
- Fixed: the WeiDU D formatter was not idempotent when a `~string~` immediately followed a keyword.
2727

28+
### Transpilers
29+
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.
31+
2832
### Performance
2933

3034
- Fixed: on large mod workspaces the language server could use excessive memory while indexing at startup. It now skips `node_modules` and dotfile directories and limits how many files it reads at once.

transpilers/common/esbuild-utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,23 @@ import {
1414
expandEnumPropertyAccess,
1515
enumTransformPlugin,
1616
} from "./enum-transform";
17+
import { ensureNodeOnPath } from "./node-runtime";
1718

1819
let esbuildInitialized = false;
1920

2021
/**
2122
* Initialize esbuild (singleton, safe to call multiple times).
2223
* Native esbuild (used by CLI via alias) doesn't need initialize().
2324
* esbuild-wasm (used by LSP server) requires it.
25+
*
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.
2430
*/
2531
async function ensureEsbuild(): Promise<void> {
2632
if (esbuildInitialized) return;
33+
ensureNodeOnPath();
2734
if (typeof esbuild.initialize === "function") {
2835
await esbuild.initialize({});
2936
}

transpilers/common/node-runtime.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Make esbuild-wasm's child-process spawn find a Node runtime on hosts that have none.
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+
15+
import * as fs from "fs";
16+
import * as os from "os";
17+
import * as path from "path";
18+
19+
/** A decision about how to make `node` resolve; pure, so it is unit-testable. */
20+
export type NodeShimPlan =
21+
| { readonly kind: "none" }
22+
| { readonly kind: "prepend-dir"; readonly dir: string }
23+
| { readonly kind: "shim"; readonly filename: string; readonly contents: string; readonly mode: number };
24+
25+
/**
26+
* Decide how to expose `node` for esbuild's child spawn given the host runtime.
27+
*
28+
* - Node already on PATH -> nothing to do.
29+
* - The runtime binary is itself named `node`/`node.exe` (code-server, plain Node hosts)
30+
* -> prepend its directory to PATH; no file needs writing.
31+
* - Otherwise it is an Electron runtime (VS Code/VSCodium desktop) -> write a tiny `node`
32+
* shim that re-execs it with ELECTRON_RUN_AS_NODE=1 so it behaves as plain Node.
33+
*/
34+
export function planNodeShim(input: {
35+
readonly hasNode: boolean;
36+
readonly execPath: string;
37+
readonly platform: NodeJS.Platform;
38+
}): NodeShimPlan {
39+
if (input.hasNode) return { kind: "none" };
40+
41+
// Parse execPath with the target platform's path rules, not the host's - this function
42+
// is pure and unit-tested cross-platform, so a Windows path must split on backslashes
43+
// even when the test runs on Linux.
44+
const p = input.platform === "win32" ? path.win32 : path.posix;
45+
const base = p.basename(input.execPath).toLowerCase();
46+
if (base === "node" || base === "node.exe") {
47+
return { kind: "prepend-dir", dir: p.dirname(input.execPath) };
48+
}
49+
50+
if (input.platform === "win32") {
51+
return {
52+
kind: "shim",
53+
filename: "node.cmd",
54+
contents: `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${input.execPath}" %*\r\n`,
55+
mode: 0o755,
56+
};
57+
}
58+
return {
59+
kind: "shim",
60+
filename: "node",
61+
contents: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec "${input.execPath}" "$@"\n`,
62+
mode: 0o755,
63+
};
64+
}
65+
66+
/** True when a `node` executable is resolvable on the current PATH. */
67+
function nodeResolvesOnPath(): boolean {
68+
const names = process.platform === "win32" ? ["node.exe", "node.cmd", "node.bat"] : ["node"];
69+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
70+
if (!dir) continue;
71+
for (const name of names) {
72+
try {
73+
fs.accessSync(path.join(dir, name), fs.constants.X_OK);
74+
return true;
75+
} catch {
76+
// not here; keep looking
77+
}
78+
}
79+
}
80+
return false;
81+
}
82+
83+
let nodePathEnsured = false;
84+
85+
/**
86+
* Idempotently make `node` resolvable for esbuild's child spawn (see module header).
87+
* Safe to call repeatedly; a no-op once done or when the host already has Node.
88+
*/
89+
export function ensureNodeOnPath(): void {
90+
if (nodePathEnsured) return;
91+
nodePathEnsured = true;
92+
93+
const plan = planNodeShim({
94+
hasNode: nodeResolvesOnPath(),
95+
execPath: process.execPath,
96+
platform: process.platform,
97+
});
98+
99+
if (plan.kind === "none") return;
100+
101+
let dir: string;
102+
if (plan.kind === "prepend-dir") {
103+
dir = plan.dir;
104+
} else {
105+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "bgforge-node-"));
106+
const shim = path.join(dir, plan.filename);
107+
fs.writeFileSync(shim, plan.contents);
108+
fs.chmodSync(shim, plan.mode);
109+
}
110+
process.env.PATH = dir + path.delimiter + (process.env.PATH ?? "");
111+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, test } from "vitest";
2+
import { planNodeShim } from "../common/node-runtime";
3+
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.
7+
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" });
11+
});
12+
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" });
20+
});
21+
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");
56+
});
57+
});

0 commit comments

Comments
 (0)