diff --git a/packages/nameof-transformer/src/models/IApiClientInternals.ts b/packages/nameof-transformer/src/models/IApiClientInternals.ts new file mode 100644 index 00000000..063711f1 --- /dev/null +++ b/packages/nameof-transformer/src/models/IApiClientInternals.ts @@ -0,0 +1,42 @@ +// Copyright 2026 3Sixty Trade Ltd. +// SPDX-License-Identifier: Apache-2.0. + +/** + * The internal client of the API, which owns the compiler process. + */ +export interface IApiClientInternals { + /** + * The client connecting to the compiler process. + */ + client: { + /** + * The compiler process, set once connected. + */ + process?: { + /** + * The exit code, null while the process is running. + */ + exitCode: number | null; + + /** + * Listen for the process exiting. + * @param event The exit event. + * @param listener Called with the exit code and signal. + * @returns The process. + */ + once(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown; + }; + + /** + * Connect to the compiler, spawning its process. + * @returns Resolves once the process has been spawned. + */ + connect(): Promise; + + /** + * Close the connection, rejecting any requests still pending. + * @returns Resolves once the connection is closed. + */ + close(): Promise; + }; +} diff --git a/packages/nameof-transformer/src/tsTransform.ts b/packages/nameof-transformer/src/tsTransform.ts index ebb6e486..fd2b9a05 100644 --- a/packages/nameof-transformer/src/tsTransform.ts +++ b/packages/nameof-transformer/src/tsTransform.ts @@ -9,6 +9,7 @@ import { } from "typescript-native/async"; import { type FileSystemCallbacks, serverFS } from "typescript-native/fs"; import { manual } from "./manual.js"; +import type { IApiClientInternals } from "./models/IApiClientInternals.js"; import type { IBuildOrchestratorApi } from "./models/IBuildOrchestratorApi.js"; /** @@ -64,34 +65,77 @@ export async function tsTransform(args: string[], cwd: string): Promise readFile }; const api = new API({ cwd, fs }); + const client = (api as unknown as IApiClientInternals).client; + let closing = false; + let crashed = false; try { - const commandLine = await api.parseCommandLine(args); - const diagnostics: Diagnostic[] = [...commandLine.errors]; - if (diagnostics.length === 0) { - const { project, ...compilerOptions } = commandLine.options; - // The build orchestrator is internal to the API but is the only route which - // supports incremental builds, the server expects the options to be nested. - // Current clients spread the options so only the nested compilerOptions key - // reaches the server, clients with https://github.com/microsoft/TypeScript/pull/64759 - // only send overrideCompilerOptions. Remove compilerOptions once that is released. - const orchestrator = await (api as unknown as IBuildOrchestratorApi).createBuildOrchestrator( - [project ?? path.join(cwd, "tsconfig.json")], - { - cwd, - compilerOptions, - overrideCompilerOptions: compilerOptions + // The API leaves its requests pending if the compiler process dies, so the + // compile is raced against the process exiting to report the failure instead. + await client.connect(); + const exited = new Promise((resolve, reject) => { + const onExit = (code: number | null, signal: string | null): void => { + if (!closing) { + crashed = true; + reject( + // This package sits below @3sixty/core, so has no BaseError to use. + // eslint-disable-next-line no-restricted-syntax + new Error( + `The TypeScript compiler exited unexpectedly (code ${code}, signal ${signal})` + ) + ); } - ); - try { - diagnostics.push(...((await orchestrator.build()).diagnostics ?? [])); - } finally { - await orchestrator.dispose(); + }; + if (client.process?.exitCode !== null && client.process?.exitCode !== undefined) { + onExit(client.process.exitCode, null); + } else { + client.process?.once("exit", onExit); } - } + }); - return diagnostics.length > 0 ? formatDiagnosticsWithColorAndContext(diagnostics, api) : ""; + return await Promise.race([compile(api, args, cwd), exited]); } finally { + closing = true; + // After a crash the API's close waits on requests the compiler will never answer, + // closing the client first disposes the connection, which rejects them. + if (crashed) { + await client.close(); + } await api.close(); } } + +/** + * Compile the project through a connected API. + * @param api The connected API. + * @param args The tsc command line arguments. + * @param cwd The working directory containing the project. + * @returns The diagnostics output, empty if the compilation succeeded. + */ +async function compile(api: API, args: string[], cwd: string): Promise { + const commandLine = await api.parseCommandLine(args); + const diagnostics: Diagnostic[] = [...commandLine.errors]; + if (diagnostics.length === 0) { + const { project, ...compilerOptions } = commandLine.options; + // The build orchestrator is internal to the API but is the only route which + // supports incremental builds, the server expects the options to be nested. + // Current clients spread the options so only the nested compilerOptions key + // reaches the server, clients with https://github.com/microsoft/TypeScript/pull/64759 + // only send overrideCompilerOptions. Remove compilerOptions once that is released. + const orchestrator = await (api as unknown as IBuildOrchestratorApi).createBuildOrchestrator( + [project ?? path.join(cwd, "tsconfig.json")], + { + cwd, + compilerOptions, + overrideCompilerOptions: compilerOptions + } + ); + try { + diagnostics.push(...((await orchestrator.build()).diagnostics ?? [])); + } finally { + await orchestrator.dispose(); + } + } + + return diagnostics.length > 0 ? formatDiagnosticsWithColorAndContext(diagnostics, api) : ""; +} diff --git a/packages/nameof-transformer/tests/tsTransform.spec.ts b/packages/nameof-transformer/tests/tsTransform.spec.ts index 9cdd734c..60a1b61c 100644 --- a/packages/nameof-transformer/tests/tsTransform.spec.ts +++ b/packages/nameof-transformer/tests/tsTransform.spec.ts @@ -5,6 +5,21 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { tsTransform } from "../src/tsTransform.js"; +const mocks = vi.hoisted(() => ({ compilerPath: undefined as string | undefined })); + +// Lets a test replace the compiler executable, to simulate the compiler process failing. +vi.mock("typescript-native/async", async importOriginal => { + const original = (await importOriginal()) as { + API: new (options: { tsserverPath?: string }) => object; + }; + class API extends original.API { + constructor(options: { tsserverPath?: string }) { + super({ ...options, tsserverPath: mocks.compilerPath ?? options.tsserverPath }); + } + } + return { ...original, API }; +}); + let projectDir: string; /** @@ -85,6 +100,20 @@ describe("tsTransform", () => { expect(output).toContain("TS2322"); }); + test("rejects when the compiler process exits unexpectedly", async () => { + writeProject("export const value = 1;\n"); + // Node rejects the compiler's --api argument and exits straight away. + mocks.compilerPath = "node"; + + try { + await expect(tsTransform([], projectDir)).rejects.toThrow( + "The TypeScript compiler exited unexpectedly" + ); + } finally { + mocks.compilerPath = undefined; + } + }); + test("can return the diagnostics for invalid command line arguments", async () => { writeProject("export const value = 1;\n");