Improve check error performance by off-loading to worker thread w/ incremental compilation (#575)

This commit is contained in:
Will Chen
2025-07-07 12:47:33 -07:00
committed by GitHub
parent 97b5c29f11
commit bc38f9b2d7
22 changed files with 695 additions and 396 deletions

307
workers/tsc/tsc_worker.ts Normal file
View File

@@ -0,0 +1,307 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { parentPort } from "node:worker_threads";
import {
Problem,
ProblemReport,
SyncVirtualFileSystem,
WorkerInput,
WorkerOutput,
} from "../../shared/tsc_types";
import { SyncVirtualFileSystemImpl } from "../../shared/VirtualFilesystem";
function loadLocalTypeScript(appPath: string): typeof import("typescript") {
try {
// Try to load TypeScript from the project's node_modules
const requirePath = require.resolve("typescript", { paths: [appPath] });
const ts = require(requirePath);
return ts;
} catch (error) {
throw new Error(
`Failed to load TypeScript from ${appPath} because of ${error}`,
);
}
}
function findTypeScriptConfig(appPath: string): string {
const possibleConfigs = [
// For vite applications, we want to check tsconfig.app.json, since it's the
// most important one (client-side app).
// The tsconfig.json in vite apps is a project reference and doesn't
// actually check anything unless you do "--build" which requires a complex
// programmatic approach
"tsconfig.app.json",
// For Next.js applications, it typically has a single tsconfig.json file
"tsconfig.json",
];
for (const config of possibleConfigs) {
const configPath = path.join(appPath, config);
if (fs.existsSync(configPath)) {
return configPath;
}
}
throw new Error(
`No TypeScript configuration file found in ${appPath}. Expected one of: ${possibleConfigs.join(", ")}`,
);
}
async function runTypeScriptCheck(
ts: typeof import("typescript"),
vfs: SyncVirtualFileSystem,
{
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
}: {
appPath: string;
tsconfigPath: string;
tsBuildInfoCacheDir: string;
},
): Promise<ProblemReport> {
return runSingleProject(ts, vfs, {
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
});
}
async function runSingleProject(
ts: typeof import("typescript"),
vfs: SyncVirtualFileSystem,
{
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
}: {
appPath: string;
tsconfigPath: string;
tsBuildInfoCacheDir: string;
},
): Promise<ProblemReport> {
// Use the idiomatic way to parse TypeScript config
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
tsconfigPath,
undefined, // No additional options
{
// Custom system object that can handle our virtual files
...ts.sys,
fileExists: (fileName: string) => vfs.fileExists(fileName),
readFile: (fileName: string) => vfs.readFile(fileName),
onUnRecoverableConfigFileDiagnostic: (
diagnostic: import("typescript").Diagnostic,
) => {
throw new Error(
`TypeScript config error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
);
},
},
);
if (!parsedCommandLine) {
throw new Error(`Failed to parse TypeScript config: ${tsconfigPath}`);
}
// Enable incremental compilation by setting tsBuildInfoFile if not already set
const options = { ...parsedCommandLine.options };
if (!options.tsBuildInfoFile && options.incremental !== false) {
// Place the buildinfo file in a temp directory to avoid polluting the project
const tmpDir = tsBuildInfoCacheDir;
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// Create a unique filename based on both the app path and tsconfig path to prevent collisions
const configName = path.basename(tsconfigPath, path.extname(tsconfigPath));
const appHash = Buffer.from(appPath)
.toString("base64")
.replace(/[/+=]/g, "_");
options.tsBuildInfoFile = path.join(
tmpDir,
`${appHash}-${configName}.tsbuildinfo`,
);
options.incremental = true;
}
let rootNames = parsedCommandLine.fileNames;
// Add any virtual files that aren't already included
const virtualTsFiles = vfs
.getVirtualFiles()
.map((file) => path.resolve(appPath, file.path))
.filter(isTypeScriptFile);
// Remove deleted files from rootNames
const deletedFiles = vfs
.getDeletedFiles()
.map((file) => path.resolve(appPath, file));
rootNames = rootNames.filter((fileName) => {
const resolvedPath = path.resolve(fileName);
return !deletedFiles.includes(resolvedPath);
});
for (const virtualFile of virtualTsFiles) {
if (!rootNames.includes(virtualFile)) {
rootNames.push(virtualFile);
}
}
// Create custom compiler host
const host = createVirtualCompilerHost(ts, appPath, vfs, options);
// Create incremental program - TypeScript will automatically use the tsBuildInfo file
const builderProgram = ts.createIncrementalProgram({
rootNames,
options,
host,
configFileParsingDiagnostics:
ts.getConfigFileParsingDiagnostics(parsedCommandLine),
});
// Get diagnostics - the incremental program optimizes this by only checking changed files
const diagnostics = [
...builderProgram.getSyntacticDiagnostics(),
...builderProgram.getSemanticDiagnostics(),
...builderProgram.getGlobalDiagnostics(),
];
// Emit the build info file to persist the incremental state
builderProgram.emit();
// Convert diagnostics to our format
const problems: Problem[] = [];
for (const diagnostic of diagnostics) {
if (!diagnostic.file) continue;
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
diagnostic.start!,
);
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n",
);
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
continue;
}
problems.push({
file: normalizePath(path.relative(appPath, diagnostic.file.fileName)),
line: line + 1, // Convert to 1-based
column: character + 1, // Convert to 1-based
message,
code: diagnostic.code,
});
}
return {
problems,
};
}
function createVirtualCompilerHost(
ts: typeof import("typescript"),
appPath: string,
vfs: SyncVirtualFileSystem,
compilerOptions: import("typescript").CompilerOptions,
): import("typescript").CompilerHost {
const host = ts.createIncrementalCompilerHost(compilerOptions);
// Override file reading to use virtual files
host.readFile = (fileName: string) => {
return vfs.readFile(fileName);
};
// Override file existence check
host.fileExists = (fileName: string) => {
return vfs.fileExists(fileName);
};
// Override getCurrentDirectory to ensure proper resolution
host.getCurrentDirectory = () => appPath;
// Override writeFile to handle virtual file system
// This is important for writing the tsBuildInfo file
const originalWriteFile = host.writeFile;
host.writeFile = (
fileName: string,
data: string,
writeByteOrderMark?: boolean,
onError?: (message: string) => void,
) => {
// Only write build info files to disk, not emit files
if (fileName.endsWith(".tsbuildinfo")) {
originalWriteFile?.call(
host,
fileName,
data,
!!writeByteOrderMark,
onError,
);
}
// Ignore other emit files since we're only doing type checking
};
return host;
}
function isTypeScriptFile(fileName: string): boolean {
const ext = path.extname(fileName).toLowerCase();
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
}
async function processTypeScriptCheck(
input: WorkerInput,
): Promise<WorkerOutput> {
try {
const { appPath, virtualChanges, tsBuildInfoCacheDir } = input;
// Load the local TypeScript version from the app's node_modules
const ts = loadLocalTypeScript(appPath);
const vfs = new SyncVirtualFileSystemImpl(appPath, {
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
readFile: (fileName: string) => ts.sys.readFile(fileName),
});
vfs.applyResponseChanges(virtualChanges);
// Find TypeScript config - throw error if not found
const tsconfigPath = findTypeScriptConfig(appPath);
// Create TypeScript program with virtual file system
const result = await runTypeScriptCheck(ts, vfs, {
appPath,
tsconfigPath,
tsBuildInfoCacheDir,
});
return {
success: true,
data: result,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
// Handle messages from main thread
parentPort?.on("message", async (input: WorkerInput) => {
const output = await processTypeScriptCheck(input);
parentPort?.postMessage(output);
});
/**
* Normalize the path to use forward slashes instead of backslashes.
* This is important to prevent weird Git issues, particularly on Windows.
* @param path Source path.
* @returns Normalized path.
*/
function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}