Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/nameof-transformer/src/models/IApiClientInternals.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

/**
* Close the connection, rejecting any requests still pending.
* @returns Resolves once the connection is closed.
*/
close(): Promise<void>;
};
}
88 changes: 66 additions & 22 deletions packages/nameof-transformer/src/tsTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -64,34 +65,77 @@ export async function tsTransform(args: string[], cwd: string): Promise<string>
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<never>((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<string> {
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) : "";
}
29 changes: 29 additions & 0 deletions packages/nameof-transformer/tests/tsTransform.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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");

Expand Down
Loading