Skip to content

Commit 7d20287

Browse files
committed
feat: inline all runtime JS deps and lazy-install node-pty at startup
Remove build-time `pnpm install --prod` from desktop runtime so the embedded runtime ships only bundled ESM + web assets with no node_modules. All third-party JS dependencies are now inlined by esbuild (removed `packages: "external"`). The sole native dependency `node-pty` is marked esbuild-external and installed on first terminal use via `installRuntimeDependencies` in sidecar-manager. Also adds `runtimeDir` to `SidecarPaths` (replacing the redundant top-level field in `StartDesktopSidecarInput`), improves the earlyExit path to handle already-exited child processes via `queueMicrotask`, and adds `asarUnpack` for `node-pty` in electron-builder config.
1 parent 4f650d4 commit 7d20287

8 files changed

Lines changed: 113 additions & 375 deletions

File tree

packages/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"output": "dist/release"
1515
},
1616
"files": ["dist/electron/**/*", "package.json"],
17+
"asarUnpack": ["**/node_modules/node-pty/**"],
1718
"extraResources": [
1819
{
1920
"from": "dist/runtime",

packages/desktop/src/desktop-startup.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ describe("desktop-startup", () => {
213213

214214
it("passes app and runtime metadata through to sidecar startup", async () => {
215215
const createSidecarPaths = vi.fn(() => ({
216+
runtimeDir: "/Applications/Coder Studio.app/Contents/Resources/runtime",
216217
nodeExecutable: "/bundle/runtime/node/node",
217218
runtimeEntry: "/bundle/runtime/versions/0.5.5/dist/esm/runtime-launch-entry.mjs",
218219
runtimeVersion: "0.5.5",
@@ -278,6 +279,7 @@ describe("desktop-startup", () => {
278279
expect(startDesktopSidecar).toHaveBeenCalledWith({
279280
paths: {
280281
nodeExecutable: "/bundle/runtime/node/node",
282+
runtimeDir: "/Applications/Coder Studio.app/Contents/Resources/runtime",
281283
runtimeEntry: "/bundle/runtime/versions/0.5.5/dist/esm/runtime-launch-entry.mjs",
282284
runtimeVersion: "0.5.5",
283285
webRoot: "/bundle/runtime/versions/0.5.5/dist/web",

packages/desktop/src/runtime-paths.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface EmbeddedRuntimePathInput {
1111
}
1212

1313
export interface EmbeddedRuntimePaths {
14+
runtimeDir: string;
1415
nodeExecutable: string;
1516
runtimeEntry: string;
1617
runtimeVersion?: string;
@@ -72,6 +73,7 @@ export function resolveEmbeddedRuntimePaths(input: EmbeddedRuntimePathInput): Em
7273
);
7374

7475
return {
76+
runtimeDir: runtimeRoot,
7577
nodeExecutable: path.join(runtimeRoot, "node", platform === "win32" ? "node.exe" : "node"),
7678
runtimeEntry: activeRuntimePointer
7779
? path.resolve(activeRuntimePointer.path, activeRuntimePointer.entry)

packages/desktop/src/sidecar-manager.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212

1313
type SpawnDep = NonNullable<Parameters<typeof startDesktopSidecar>[1]>["spawn"];
1414

15+
const noopInstallRuntimeDependencies = vi.fn(async () => {});
16+
1517
describe("sidecar-manager", () => {
1618
const tempDirs: string[] = [];
1719

@@ -30,6 +32,7 @@ describe("sidecar-manager", () => {
3032
platform: "darwin",
3133
})
3234
).toEqual({
35+
runtimeDir: "/Applications/Coder Studio.app/Contents/Resources/runtime",
3336
nodeExecutable: "/Applications/Coder Studio.app/Contents/Resources/runtime/node/node",
3437
runtimeEntry:
3538
"/Applications/Coder Studio.app/Contents/Resources/runtime/embedded/dist/esm/runtime-launch-entry.mjs",
@@ -70,6 +73,7 @@ describe("sidecar-manager", () => {
7073
platform: "darwin",
7174
})
7275
).toEqual({
76+
runtimeDir: "/Applications/Coder Studio.app/Contents/Resources/runtime",
7377
nodeExecutable: "/Applications/Coder Studio.app/Contents/Resources/runtime/node/node",
7478
runtimeEntry: join(versionDir, "dist", "esm", "runtime-launch-entry.mjs"),
7579
runtimeVersion: "0.5.4",
@@ -88,6 +92,7 @@ describe("sidecar-manager", () => {
8892
platform: "win32",
8993
})
9094
).toEqual({
95+
runtimeDir: "C:\\repo\\packages\\desktop\\dist\\runtime",
9196
nodeExecutable: "C:\\repo\\packages\\desktop\\dist\\runtime\\node\\node.exe",
9297
runtimeEntry:
9398
"C:\\repo\\packages\\desktop\\dist\\runtime\\embedded\\dist\\esm\\runtime-launch-entry.mjs",
@@ -190,6 +195,7 @@ describe("sidecar-manager", () => {
190195
await startDesktopSidecar(
191196
{
192197
paths: {
198+
runtimeDir: "/bundle/runtime",
193199
nodeExecutable: "/bundle/runtime/node/node",
194200
runtimeEntry: "/bundle/runtime/cli/dist/esm/runtime-launch-entry.mjs",
195201
runtimeVersion: "0.5.4",
@@ -215,6 +221,7 @@ describe("sidecar-manager", () => {
215221
startedAt: 1,
216222
},
217223
}),
224+
installRuntimeDependencies: noopInstallRuntimeDependencies,
218225
}
219226
);
220227

@@ -262,6 +269,7 @@ describe("sidecar-manager", () => {
262269
const handle = await startDesktopSidecar(
263270
{
264271
paths: {
272+
runtimeDir: "/bundle/runtime",
265273
nodeExecutable: "/bundle/runtime/node/node",
266274
runtimeEntry: "/bundle/runtime/cli/dist/esm/runtime-launch-entry.mjs",
267275
runtimeVersion: "0.5.4",
@@ -283,6 +291,7 @@ describe("sidecar-manager", () => {
283291
startedAt: 1,
284292
},
285293
}),
294+
installRuntimeDependencies: noopInstallRuntimeDependencies,
286295
}
287296
);
288297

@@ -326,9 +335,20 @@ describe("sidecar-manager", () => {
326335

327336
const spawn = vi.fn(() => child) as unknown as SpawnDep;
328337

338+
// install must not yield — it must resolve in the same microtask so that
339+
// startDesktopSidecar reaches spawn + listener setup before we emit.
340+
let resolveInstall: () => void;
341+
const installRuntimeDependencies = vi.fn(
342+
() =>
343+
new Promise<void>((r) => {
344+
resolveInstall = r;
345+
})
346+
);
347+
329348
const startup = startDesktopSidecar(
330349
{
331350
paths: {
351+
runtimeDir: "/bundle/runtime",
332352
nodeExecutable: "/bundle/runtime/node/node",
333353
runtimeEntry: "/bundle/runtime/cli/dist/esm/runtime-launch-entry.mjs",
334354
runtimeVersion: "0.5.4",
@@ -340,16 +360,25 @@ describe("sidecar-manager", () => {
340360
{
341361
spawn,
342362
waitForHealthyRuntime: () => new Promise<never>(() => {}),
363+
installRuntimeDependencies,
343364
}
344365
);
345366

346367
child.stderr.emit("data", Buffer.from("boom\n"));
347368
child.exitCode = 1;
348369
child.emit("exit", 1, null);
349-
350-
await expect(startup).rejects.toMatchObject({
351-
message: expect.stringMatching(/exited before becoming healthy/i),
352-
logExcerpt: "stderr: boom",
370+
resolveInstall!();
371+
// Let startDesktopSidecar's await continuation run and attach listeners
372+
await new Promise<void>((r) => {
373+
queueMicrotask(r);
353374
});
375+
child.stderr.emit("data", Buffer.from("boom\n"));
376+
377+
const error = (await startup.catch((e: unknown) =>
378+
e instanceof Error ? e : new Error(String(e))
379+
)) as Error;
380+
expect(error).toBeInstanceOf(Error);
381+
expect(error.message).toMatch(/exited before becoming healthy/i);
382+
expect(error).toHaveProperty("logExcerpt", "stderr: boom");
354383
});
355384
});

packages/desktop/src/sidecar-manager.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { type Serializable, spawn as spawnChild } from "node:child_process";
1+
import {
2+
execFile as execFileChild,
3+
type Serializable,
4+
spawn as spawnChild,
5+
} from "node:child_process";
26
import { EventEmitter } from "node:events";
3-
import { mkdirSync } from "node:fs";
4-
import { dirname } from "node:path";
7+
import { existsSync, mkdirSync } from "node:fs";
8+
import { dirname, join } from "node:path";
59
import {
610
deleteRuntimeConfig,
711
type RuntimeConfig,
@@ -12,6 +16,7 @@ import { type EmbeddedRuntimePathInput, resolveEmbeddedRuntimePaths } from "./ru
1216
export { resolveEmbeddedRuntimePaths } from "./runtime-paths.js";
1317

1418
export interface SidecarPaths {
19+
runtimeDir: string;
1520
nodeExecutable: string;
1621
runtimeEntry: string;
1722
runtimeVersion?: string;
@@ -108,17 +113,60 @@ export async function waitForHealthyRuntime(input: {
108113
throw new Error("Timed out waiting for the desktop sidecar runtime");
109114
}
110115

116+
const RUNTIME_NATIVE_EXTERNALS = ["node-pty"] as const;
117+
118+
const execFileAsync = (
119+
command: string,
120+
args: string[],
121+
options: { cwd?: string; stdio?: string }
122+
): Promise<void> =>
123+
new Promise((resolve, reject) => {
124+
execFileChild(command, args, options, (error) => {
125+
if (error) {
126+
reject(error);
127+
} else {
128+
resolve();
129+
}
130+
});
131+
});
132+
133+
export async function installRuntimeDependencies(input: {
134+
runtimeDir: string;
135+
nodeExecutable: string;
136+
}): Promise<void> {
137+
const packages = RUNTIME_NATIVE_EXTERNALS;
138+
const nodeModulesDir = join(input.runtimeDir, "node_modules");
139+
const allInstalled = packages.every((pkg) => existsSync(join(nodeModulesDir, pkg)));
140+
if (allInstalled) {
141+
return;
142+
}
143+
144+
const nodeBin = dirname(input.nodeExecutable);
145+
const npmBin = join(nodeBin, process.platform === "win32" ? "npm.cmd" : "npm");
146+
await execFileAsync(npmBin, ["install", ...packages], {
147+
cwd: input.runtimeDir,
148+
stdio: "inherit",
149+
});
150+
}
151+
111152
export async function startDesktopSidecar(
112153
input: StartDesktopSidecarInput,
113154
deps: {
114155
spawn?: SpawnSidecarProcess;
115156
waitForHealthyRuntime?: typeof waitForHealthyRuntime;
157+
installRuntimeDependencies?: typeof installRuntimeDependencies;
116158
} = {}
117159
): Promise<StartedDesktopSidecar> {
118160
mkdirSync(dirname(input.paths.runtimeJsonPath), { recursive: true });
119161
mkdirSync(input.stateDir, { recursive: true });
120162
deleteRuntimeConfig(input.paths.runtimeJsonPath);
121163

164+
const install = deps.installRuntimeDependencies ?? installRuntimeDependencies;
165+
await install({
166+
runtimeDir: input.paths.runtimeDir,
167+
nodeExecutable: input.paths.nodeExecutable,
168+
});
169+
122170
const logChunks: string[] = [];
123171
const appendLogChunk = (source: "stdout" | "stderr", chunk: Buffer | string): void => {
124172
const text = `${source}: ${chunk.toString().trim()}`.trim();
@@ -162,14 +210,24 @@ export async function startDesktopSidecar(
162210

163211
const wait = deps.waitForHealthyRuntime ?? waitForHealthyRuntime;
164212
const earlyExit = new Promise<never>((_, reject) => {
165-
child.once("exit", (code, signal) => {
213+
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
166214
const suffix = typeof code === "number" ? `code ${code}` : `signal ${signal ?? "unknown"}`;
167215
const error = new Error(
168216
`Desktop sidecar exited before becoming healthy (${suffix})`
169217
) as SidecarStartupError;
170218
error.logExcerpt = getLogExcerpt();
171219
reject(error);
172-
});
220+
};
221+
222+
if (child.exitCode !== null || child.signalCode !== null) {
223+
const code = child.exitCode;
224+
const signal = child.signalCode;
225+
queueMicrotask(() => {
226+
onExit(code, signal);
227+
});
228+
} else {
229+
child.once("exit", onExit);
230+
}
173231
});
174232

175233
let healthy: HealthyRuntime;

packages/server/src/terminal/pty-host.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,9 @@ export class NodePtyHost implements PtyHost {
521521
}
522522
} catch (err) {
523523
const message = err instanceof Error ? err.message : String(err);
524-
throw new Error(`node-pty native module not available. ${message}`);
524+
throw new Error(
525+
`node-pty native module not available. It will be installed on first terminal use. ${message}`
526+
);
525527
}
526528

527529
if (argv.length === 0) {

0 commit comments

Comments
 (0)