ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
1,494 lines (1,421 loc) • 85.7 kB
text/typescript
import crypto from "node:crypto";
import fs from "node:fs";
import {
Module,
isBuiltin,
registerHooks,
stripTypeScriptTypes,
} from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig";
import { resolveEmittedJavaScript } from "../../compiler/internal/resolveEmittedJavaScript";
import { resolveTsgo } from "../../compiler/internal/resolveTsgo";
import { runBuild } from "../../compiler/internal/runBuild";
import { outputText, spawnNative } from "../../compiler/internal/spawnNative";
import {
type FilesystemPathIdentityContext,
createFilesystemPathIdentityContext,
} from "../../internal/projectInputPathIdentity";
import { inlineServedSourceMap } from "./servedSourceMap";
/**
* Synchronous Node module hooks installed (via `module.registerHooks`) in the
* child process `ttsx` spawns to run a TypeScript entry _from source_.
*
* They give the runner ts-node-style whole-graph reach without weakening the
* compile gate. The owning entry project is type-checked and built up front (by
* `prepareExecution`, with its transform plugins such as typia); these hooks
* serve that build under the source URLs so `__dirname`/`import.meta.url` keep
* pointing at the source tree. Three load paths:
*
* 1. A `.ts` belonging to the entry project → serve the pre-built emitted JS
* (transform plugins already applied), mapped by the project's `rootDir`.
* 2. Any other raw `.ts` dependency (a published or workspace package that ships
* source) → build its own owning `tsconfig.json` once via `runBuild` and
* serve the emit. A real build (not a type-strip) is required because Node's
* type-stripping cannot do cross-file type-only elision — e.g. a
* value-shaped import of a type+namespace merge survives stripping and
* dangles at runtime.
* 3. No owning tsconfig → transform the lone file by the format it resolves to: a
* CommonJS-classified file (`.cts`, or a `.ts` in a package without `type:
* "module"`) is lowered to CommonJS through a tsgo single-file emit so its
* `export` syntax becomes `module.exports`; any other (ESM) file keeps the
* fast in-process `mode: "transform"` type-strip.
*
* The hooks are synchronous and run on the main thread (not a loader worker):
* that is what lets a CommonJS `require("./x")` chain reach them and what makes
* `require.resolve(..., { paths })` inside `runBuild`'s plugin loader behave.
*/
/** Source/JS extensions probed when an extensionless relative import fails. */
const RESOLVABLE_EXTENSIONS = [
".ts",
".tsx",
".mts",
".cts",
".js",
".mjs",
".cjs",
] as const;
/** TypeScript source extensions these hooks compile. */
const TYPESCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
interface ResolveContext {
readonly parentURL?: string;
readonly conditions?: string[];
readonly importAttributes?: Record<string, string | undefined>;
}
interface ResolveResult {
url: string;
format?: string | null;
shortCircuit?: boolean;
}
interface LoadContext {
readonly format?: string | null;
readonly conditions?: string[];
readonly importAttributes?: Record<string, string | undefined>;
}
interface LoadResult {
format: string | null | undefined;
source?: string | ArrayBuffer | NodeJS.TypedArray;
shortCircuit?: boolean;
}
/**
* The compiler options that decide the emit format of a file, as declared by
* the project that emitted it. Both fields matter: tsgo derives the module kind
* from `target` whenever `module` is absent, so carrying only `module` cannot
* reproduce its decision.
*/
export interface OwningModuleOptions {
module?: string;
target?: string;
}
interface ServedSource {
source: string;
/** Options of the project that emitted this source; `null` when none did. */
moduleOptions: OwningModuleOptions | null;
emittedFile?: string;
sourceFile?: string;
}
type NextResolve = (
specifier: string,
context: ResolveContext,
) => ResolveResult;
type NextLoad = (url: string, context: LoadContext) => LoadResult;
/**
* Runtime manifest written by `runTtsx` (the parent) and read once here. It
* describes the already-built entry project so the hooks can serve its emit.
*/
interface RuntimeManifest {
/** Project root of the entry's owning tsconfig. */
projectRoot: string;
/** Source-tree root the emit mirrors (tsgo strips this prefix). */
rootDir: string;
/** Directory holding the entry project's emitted JavaScript. */
emitDir: string;
/** Emitted file list from the entry build, for source→output matching. */
emittedFiles?: readonly string[];
/**
* The entry tsconfig's `module` and `target`, deciding emit CJS/ESM per file.
* `target` is not decoration: an absent `module` makes tsgo derive the module
* kind from it.
*/
moduleOptions?: OwningModuleOptions;
/** Root directory for per-dependency build output. */
depCacheDir: string;
}
let manifestCache: RuntimeManifest | null | undefined;
function manifest(): RuntimeManifest | null {
if (manifestCache !== undefined) {
return manifestCache;
}
const file = process.env.TTSX_RUNTIME_MANIFEST;
if (file === undefined || file.length === 0) {
manifestCache = null;
return manifestCache;
}
try {
manifestCache = JSON.parse(
fs.readFileSync(file, "utf8"),
) as RuntimeManifest;
} catch {
manifestCache = null;
}
return manifestCache;
}
/**
* Lowest Node.js the ttsx source runtime supports. The synchronous
* `module.registerHooks` (Node 22.15.0) is the highest floor among the runtime
* APIs these hooks depend on — `stripTypeScriptTypes` (22.13.0) and the child's
* `--disable-warning` flag (20.11.0) are both lower — so it sets the effective
* minimum. Kept in sync with `packages/ttsc/package.json#engines.node` and the
* documented requirement in `website/src/content/docs/development/index.mdx`.
*/
export const TTSX_MINIMUM_NODE_VERSION = "22.15.0";
const TTSX_MINIMUM_NODE_PARTS: readonly [number, number, number] = [22, 15, 0];
/**
* Report why the running (or a candidate) Node.js version cannot execute the
* ttsx source runtime, or `null` when it can. Returning an actionable message —
* rather than letting the child die with an internal `TypeError` on the missing
* `registerHooks`, or Node 18 rejecting `--disable-warning` with exit 9 — is
* what turns an opaque internal failure into a clear version diagnostic.
*
* Exported for direct exercise by the ttsx e2e suite: the built launcher can
* only be spawned under the Node version running the tests, so the boundary
* around the floor cannot otherwise be pinned on CI.
*/
export function checkNodeRuntimeSupport(version: string): string | null {
const parts = parseNodeVersion(version);
if (parts === null) {
// An unrecognizable version string is not proof of an unsupported runtime;
// let execution proceed rather than block on a parsing quirk.
return null;
}
if (compareVersionParts(parts, TTSX_MINIMUM_NODE_PARTS) >= 0) {
return null;
}
return (
`ttsx requires Node.js ${TTSX_MINIMUM_NODE_VERSION} or later, but this ` +
`process is Node.js ${version}. The source runtime installs synchronous ` +
`module hooks (module.registerHooks, Node 22.15.0) and strips types with ` +
`module.stripTypeScriptTypes (Node 22.13.0), neither of which exists on ` +
`earlier releases. Upgrade Node.js to 22.15.0+ (or the current LTS), or ` +
`compile the project with \`ttsc\` and run the emitted JavaScript directly.`
);
}
function parseNodeVersion(version: string): [number, number, number] | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
if (match === null) {
return null;
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersionParts(
a: readonly [number, number, number],
b: readonly [number, number, number],
): number {
for (let index = 0; index < 3; index += 1) {
if (a[index]! !== b[index]!) {
return a[index]! < b[index]! ? -1 : 1;
}
}
return 0;
}
/**
* Throw an actionable version error when the current Node.js cannot run the
* ttsx source runtime. Guards the hook-installation boundary directly (a child
* or grandchild that inherits the runtime preload under an unsupported Node) so
* the failure is diagnosed here instead of surfacing as a bare `TypeError:
* registerHooks is not a function`.
*/
function assertNodeRuntimeSupport(): void {
const message = checkNodeRuntimeSupport(process.versions.node);
if (message !== null) {
throw new Error(message);
}
}
let installed = false;
/**
* Install the source-loading hooks on the current (main) thread. Idempotent:
* the bootstrap installs them for the entry process, and `NODE_OPTIONS`
* re-imports the installer in every child process the program spawns — both may
* run in the same process.
*
* Two hooks are needed, because `module.registerHooks` does not intercept a
* `require()` made from inside a CommonJS module that was itself reached
* through an ESM `import` (the interop translator loads it on the raw CJS
* path). The ESM graph goes through `registerHooks`; the CommonJS `require`
* graph goes through `Module._extensions` — the canonical loader extension
* point `ts-node`/`tsx` use for the same reason.
*/
export function installRuntimeHooks(): void {
if (installed) {
return;
}
assertNodeRuntimeSupport();
installed = true;
// Map error stacks through the source maps the serve path now inlines, so a
// thrown frame reports the true `.ts` line:col out of the box (no
// `--enable-source-maps` needed). Applied before the entry loads; user code
// that later toggles it wins, since this is a plain runtime switch.
if (typeof process.setSourceMapsEnabled === "function") {
process.setSourceMapsEnabled(true);
}
registerHooks({ load, resolve });
installCommonJsHook();
}
/**
* Register a CommonJS `require` handler for each TypeScript source extension so
* a `require("./x")` chain compiles `.ts` the same way the ESM `load` hook
* does.
*/
function installCommonJsHook(): void {
const extensions = (
Module as unknown as {
_extensions: Record<
string,
(
module: { _compile(source: string, filename: string): void },
filename: string,
) => void
>;
}
)._extensions;
const compile = (
module: { _compile(source: string, filename: string): void },
filename: string,
): void => {
module._compile(resolveServedSource(filename).source, filename);
};
for (const extension of [".ts", ".tsx", ".cts"]) {
extensions[extension] = compile;
}
}
/**
* The module format of the entry source file, derived from the entry project's
* compiler options (via the runtime manifest) the same way the served files are
* classified. The bootstrap uses it to load the entry through a CommonJS
* `require` or an ESM `import`.
*/
export function entryModuleFormat(entryFile: string): "module" | "commonjs" {
const m = manifest();
return moduleFormat(
entryFile,
m === null ? null : (m.moduleOptions ?? {}),
) === "module"
? "module"
: "commonjs";
}
/**
* Rescue an extensionless or directory relative specifier that Node's resolver
* rejected. Only runs after `nextResolve` throws, so a successful resolution is
* never perturbed; a genuinely missing module finds no candidate and the
* original error is rethrown, preserving `ERR_MODULE_NOT_FOUND`.
*/
function resolve(
specifier: string,
context: ResolveContext,
nextResolve: NextResolve,
): ResolveResult {
try {
return rememberCommonJsNamedInterop(
restoreStrippedNodeBuiltinScheme(
specifier,
nextResolve(specifier, context),
),
context,
);
} catch (error) {
const rescued = probeRescuableSpecifier(specifier, context.parentURL);
if (rescued === null) {
throw error;
}
return rememberCommonJsNamedInterop(
{ shortCircuit: true, url: rescued },
context,
);
}
}
/**
* Restore a `node:` builtin URL when affected Node releases return the exact
* prefix-stripped spelling from their synchronous CommonJS resolver.
*
* Every other result passes through unchanged. In particular, a user hook that
* intentionally remaps a `node:` specifier to another URL retains ownership of
* that mapping, while ordinary and ESM builtin results already carrying the
* scheme avoid an unnecessary copy.
*/
export function restoreStrippedNodeBuiltinScheme(
specifier: string,
result: ResolveResult,
): ResolveResult {
return isBuiltin(specifier) &&
specifier.startsWith("node:") &&
result.url === specifier.slice("node:".length)
? { ...result, url: specifier }
: result;
}
/** Cache of built projects keyed by owning tsconfig path. */
interface BuiltProject {
emitDir: string;
rootDir: string;
emittedFiles?: readonly string[];
moduleOptions: OwningModuleOptions;
}
const builtProjects = new Map<string, BuiltProject>();
/** File URLs whose CommonJS source was reached from an ESM parent import. */
const commonJsNamedInteropUrls = new Set<string>();
const commonJsNameScanSources = new Map<string, string | null>();
function load(
url: string,
context: LoadContext,
nextLoad: NextLoad,
): LoadResult {
if (!url.startsWith("file:")) {
return nextLoad(url, context);
}
const filename = fileURLToPath(url);
if (!isTypeScriptSource(filename)) {
return nextLoad(url, context);
}
const { format, source } = resolveRuntimeSource(filename, url);
return {
format,
shortCircuit: true,
source,
};
}
function resolveRuntimeSource(
filename: string,
url: string = pathToFileURL(filename).href,
): { format: string; source: string } {
const served = resolveServedSource(filename, url);
const format = moduleFormat(filename, served.moduleOptions);
return {
format,
source:
format === "commonjs" && commonJsNamedInteropUrls.has(url)
? exposeCommonJsStarExports(
served.source,
served.emittedFile,
served.sourceFile,
)
: served.source,
};
}
function rememberCommonJsNamedInterop(
result: ResolveResult,
context: ResolveContext,
): ResolveResult {
if (shouldExposeCommonJsNamedExports(result.url, context.parentURL)) {
commonJsNamedInteropUrls.add(result.url);
}
return result;
}
/**
* Whether a CommonJS-classified TypeScript source reached from an ESM parent
* needs its nested `export *` names exposed.
*
* Only the parent side is decided here. The child's own format is re-checked
* authoritatively in `resolveRuntimeSource` against the format its served
* source actually carries, so this predicate deliberately does not repeat that
* check: doing so would need the child's owning project, which is not known
* until the source is served, and an answer guessed from the nearest tsconfig
* silently under-exposes a file that tsconfig does not compile.
*/
function shouldExposeCommonJsNamedExports(
url: string,
parentURL: string | undefined,
): boolean {
if (
parentURL === undefined ||
!url.startsWith("file:") ||
!parentURL.startsWith("file:")
) {
return false;
}
const parentFile = fileURLToPath(parentURL);
if (moduleFormat(parentFile, owningModuleOptions(parentFile)) !== "module") {
return false;
}
return isTypeScriptSource(fileURLToPath(url));
}
/**
* The emit-deciding compiler options of the project that owns `filename`, or
* `null` when none does.
*
* The entry project owns a file only when it actually emitted it. Testing
* `isWithin(rootDir)` alone would claim every file under a wide `rootDir` —
* including the volume-root `rootDir` a config-loader project uses — and hand
* them the entry project's options even though the dependency or orphan lane is
* what serves them.
*/
function owningModuleOptions(filename: string): OwningModuleOptions | null {
if (!isTypeScriptSource(filename)) {
return null;
}
const real = realPath(filename);
const m = manifest();
if (m !== null && entryEmitPath(m, real) !== null) {
return m.moduleOptions ?? {};
}
const tsconfig = nearestTsconfig(real);
if (tsconfig === null) {
return null;
}
const cached = moduleOptionsCache.get(tsconfig);
if (cached !== undefined) {
return cached;
}
let options: OwningModuleOptions = {};
try {
const project = readProjectConfig({
cwd: path.dirname(tsconfig),
tsconfig,
});
options = projectModuleOptions(project.compilerOptions);
} catch {
// The owning project cannot be read, so nothing is known about the format
// it would have emitted. That is the same state as having no project at
// all, and it is what the dependency lane will conclude too when its build
// fails and the file falls through to the orphan type-strip.
moduleOptionsCache.set(tsconfig, null);
return null;
}
moduleOptionsCache.set(tsconfig, options);
return options;
}
/** Narrow a resolved project's compiler options to the emit-format pair. */
export function projectModuleOptions(
compilerOptions: Record<string, unknown>,
): OwningModuleOptions {
return {
...(typeof compilerOptions.module === "string"
? { module: compilerOptions.module }
: {}),
...(typeof compilerOptions.target === "string"
? { target: compilerOptions.target }
: {}),
};
}
/**
* Resolve the JavaScript to run for a TypeScript source file, in priority
* order: the entry project's pre-built emit (transform plugins applied), a
* built raw `.ts` dependency, or — when no tsconfig owns it — a `mode:
* "transform"` type-strip. Shared by the ESM `load` hook and the CommonJS
* `require` handler.
*/
function resolveServedSource(
filename: string,
url: string = pathToFileURL(filename).href,
): ServedSource {
const real = realPath(filename);
const served = serveEntryEmit(real);
if (served !== null) {
return withInlineSourceMap(served);
}
const built = serveDependencyEmit(real);
if (built !== null) {
return withInlineSourceMap(built);
}
return {
moduleOptions: null,
sourceFile: filename,
source: transformOrphanSource(filename, url),
};
}
/**
* Inline a served emit's external source map into its text and absolutize the
* map's `sources`, so the JavaScript executed under the `.ts` source URL stays
* self-describing after the per-run emit directory is deleted. Applied to both
* the entry lane (`serveEntryEmit`) and the dependency lane
* (`serveBuiltDependency`); the orphan type-strip lane carries no emitted map.
*/
function withInlineSourceMap(served: ServedSource): ServedSource {
const source = inlineServedSourceMap(
served.source,
served.emittedFile,
served.sourceFile,
);
return source === served.source ? served : { ...served, source };
}
/**
* Transform a TypeScript source file that no tsconfig owns (a published or
* vendored package that ships raw `.ts`/`.cts`/`.mts` straight under
* `node_modules`), choosing the lowering by the format the file resolves to.
*
* Node's in-process `stripTypeScriptTypes` only erases type syntax; it never
* rewrites ECMAScript `import`/`export` into CommonJS. That is correct for a
* file Node will load as ESM, but wrong for one classified CommonJS — a `.cts`,
* or a `.ts` in a package without `type: "module"` — when the author wrote it
* with module syntax (`export const`, `export namespace`, `export function`).
* Stripping leaves the `export` in place and Node's CommonJS loader dies with
* `SyntaxError: Unexpected token 'export'`. So a CommonJS-format orphan is
* lowered through a real tsgo `--module commonjs` single-file emit (which also
* handles `export =`), exactly the format decision tsgo would have made for an
* owning project; an ESM-format orphan keeps the fast in-process strip.
*/
function transformOrphanSource(filename: string, url: string): string {
if (moduleFormat(filename, null) === "commonjs") {
const lowered = emitOrphanAsCommonJs(filename);
if (lowered !== null) {
return lowered;
}
}
return stripTypeScriptTypes(fs.readFileSync(filename, "utf8"), {
mode: "transform",
sourceUrl: url,
});
}
/**
* Lower a single CommonJS-format source file to CommonJS JavaScript by running
* tsgo on the lone file with `--module commonjs`. Emit-only, no diagnostic gate
* (the entry project's up-front check is the type gate), matching
* `buildDependency`. Returns `null` when tsgo is unavailable or produced no
* output, so the caller can fall back to the in-process strip.
*/
function emitOrphanAsCommonJs(filename: string): string | null {
let tsgo: string;
try {
tsgo = resolveTsgo({ cwd: path.dirname(filename) }).binary;
} catch {
return null;
}
// Content-hash cache: a CJS-format orphan ('s tsgo single-file emit) is lowered
// once and reused by every other process in the run, and across runs. Without
// it a program that fans out into many processes (the automated test corpus
// imports the same vendored `.ts` deps from thousands of generated files) would
// re-spawn tsgo per file per process and crawl.
const cacheFile = orphanCacheFile(filename, tsgo);
if (cacheFile !== null) {
const hit = readFileOrNull(cacheFile);
if (hit !== null) {
return hit;
}
}
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsx-orphan-"));
try {
const res = spawnNative(
tsgo,
[
filename,
// The file is named on the command line, so any tsconfig tsgo would
// discover by walking up (the consumer's own) must be ignored — both
// because it is not this file's project and because tsgo errors out
// ("tsconfig.json is present but will not be loaded") otherwise.
"--ignoreConfig",
"--module",
"commonjs",
"--target",
"es2022",
// This is an emit-only lowering: the entry project's up-front build is
// the type gate, so the single-file pass does not need to type-check.
// Skipping the check (and the lib check it implies) cuts the per-file
// cost several-fold, which matters when a program generates and imports
// thousands of raw `.ts` files at runtime (a fanned-out test corpus) and
// each one would otherwise pay a full single-file check.
"--noCheck",
"--skipLibCheck",
"--outDir",
outDir,
"--listEmittedFiles",
],
{ cwd: path.dirname(filename), encoding: "utf8" },
);
const emitted = parseFirstEmittedFile(outputText(res.stdout));
const lowered = emitted === null ? null : readFileOrNull(emitted);
if (lowered !== null && cacheFile !== null) {
writeOrphanCache(cacheFile, lowered);
}
return lowered;
} catch {
return null;
} finally {
fs.rmSync(outDir, { force: true, recursive: true });
}
}
/**
* Emit a source file only for CommonJS export-name discovery.
*
* This intentionally does not read or write the runtime orphan cache. Name
* discovery may inspect a source dependency without executing it, so sharing
* that output with the runtime fallback would let a speculative scan affect a
* later load path.
*/
function emitCommonJsForNameScan(filename: string): string | null {
const real = realPath(filename);
const cached = commonJsNameScanSources.get(real);
if (cached !== undefined) {
return cached;
}
let tsgo: string;
try {
tsgo = resolveTsgo({ cwd: path.dirname(real) }).binary;
} catch {
commonJsNameScanSources.set(real, null);
return null;
}
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsx-export-scan-"));
try {
const res = spawnNative(
tsgo,
[
real,
"--ignoreConfig",
"--module",
"commonjs",
"--target",
"es2022",
"--noCheck",
"--skipLibCheck",
"--outDir",
outDir,
"--listEmittedFiles",
],
{ cwd: path.dirname(real), encoding: "utf8" },
);
const emitted = pickEmittedJavaScript(
real,
parseEmittedFiles(outputText(res.stdout)),
);
const lowered = emitted === null ? null : readFileOrNull(emitted);
commonJsNameScanSources.set(real, lowered);
return lowered;
} catch {
commonJsNameScanSources.set(real, null);
return null;
} finally {
fs.rmSync(outDir, { force: true, recursive: true });
}
}
/**
* Cache root for lowered orphan sources, shared per run (and across runs when
* `TTSC_CACHE_DIR` points at a persisted directory).
*/
function orphanCacheRoot(): string {
const base =
process.env.TTSC_CACHE_DIR && process.env.TTSC_CACHE_DIR.length !== 0
? process.env.TTSC_CACHE_DIR
: path.join(os.tmpdir(), "ttsc-orphan");
return path.join(base, "ttsx-orphan-cjs");
}
/**
* Content-addressed cache path for one orphan file's CommonJS lowering, keyed
* by source bytes and the tsgo binary so a tsgo bump invalidates it. `null`
* when the source cannot be read.
*/
function orphanCacheFile(filename: string, tsgo: string): string | null {
let source: Buffer;
try {
source = fs.readFileSync(filename);
} catch {
return null;
}
const key = crypto
.createHash("sha256")
.update(tsgo)
.update("\0")
.update(source)
.digest("hex")
.slice(0, 32);
return path.join(orphanCacheRoot(), `${key}.js`);
}
/**
* Write the lowered source to its cache path atomically (temp + rename), so a
* concurrent reader never sees a half-written file. Best-effort: a failure just
* means the next process re-lowers.
*/
function writeOrphanCache(cacheFile: string, lowered: string): void {
try {
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
const tmp = `${cacheFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, lowered);
fs.renameSync(tmp, cacheFile);
} catch {
// ignore — caching is an optimization, correctness does not depend on it
}
}
/** First `TSFILE:` path tsgo printed under `--listEmittedFiles`, or `null`. */
function parseFirstEmittedFile(stdout: string): string | null {
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^TSFILE:\s*(.+)$/);
if (match?.[1]) {
return match[1].trim();
}
}
return null;
}
/** `TSFILE:` paths tsgo printed under `--listEmittedFiles`. */
function parseEmittedFiles(stdout: string): string[] {
const files: string[] = [];
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^TSFILE:\s*(.+)$/);
if (match?.[1]) {
files.push(match[1].trim());
}
}
return files;
}
/** Pick the emitted JavaScript corresponding to the source file requested. */
function pickEmittedJavaScript(
filename: string,
emittedFiles: readonly string[],
): string | null {
const stem = path
.basename(filename)
.replace(/\.[cm]?tsx?$/i, "")
.toLowerCase();
const candidates = emittedFiles.filter((file) => {
const parsed = path.parse(file);
return (
parsed.name.toLowerCase() === stem && /\.(?:[cm]?js)$/i.test(parsed.base)
);
});
if (candidates.length === 1) {
return candidates[0]!;
}
return emittedFiles.find((file) => /\.(?:[cm]?js)$/i.test(file)) ?? null;
}
/**
* Make TypeScript-Go's CommonJS `export *` output visible to Node's
* ESM-from-CJS named export scanner.
*
* Tsgo lowers star re-exports to `__exportStar(require("./x"), exports)`.
* Runtime CommonJS consumers see the getters that helper installs, but Node's
* ESM linker only exposes named imports it can statically identify from
* `exports.name = ...` assignments. For relative star re-exports whose emitted
* target is available, replace the helper call with explicit configurable
* export placeholders followed by the same `__createBinding` getter install.
*/
function exposeCommonJsStarExports(
source: string,
emittedFile: string | undefined,
sourceFile: string | undefined,
): string {
if (!source.includes("__exportStar(")) {
return source;
}
const reserved = collectStaticCommonJsExportNames(source);
let index = 0;
return source.replace(
/^(\s*)__exportStar\(\s*require\((["'])([^"']+)\2\)\s*,\s*exports\s*\);/gm,
(statement: string, indent: string, _quote: string, specifier: string) => {
const names = [
...collectStarExportNames(emittedFile, sourceFile, specifier),
].filter(
(name) =>
name !== "default" &&
name !== "__esModule" &&
isIdentifierName(name) &&
!reserved.has(name),
);
if (names.length === 0) {
return statement;
}
for (const name of names) {
reserved.add(name);
}
const receiver = `__ttsx_export_star_${index++}`;
return [
...names.map((name) => `${indent}exports.${name} = void 0;`),
`${indent}var ${receiver} = require(${JSON.stringify(specifier)});`,
...names.map(
(name) =>
`${indent}__createBinding(exports, ${receiver}, ${JSON.stringify(name)});`,
),
].join("\n");
},
);
}
function collectStarExportNames(
emittedFile: string | undefined,
sourceFile: string | undefined,
specifier: string,
): Set<string> {
if (emittedFile !== undefined) {
const emittedTarget = resolveEmittedRequire(emittedFile, specifier);
if (emittedTarget !== null) {
return collectCommonJsExportNames(emittedTarget, new Set());
}
}
if (sourceFile !== undefined) {
const sourceTarget = resolveSourceSpecifier(sourceFile, specifier);
if (sourceTarget !== null) {
return collectSourceCommonJsExportNames(sourceTarget, new Set());
}
}
return new Set();
}
function collectCommonJsExportNames(
emittedFile: string,
seen: Set<string>,
): Set<string> {
const real = realPath(emittedFile);
if (seen.has(real)) {
return new Set();
}
seen.add(real);
const source = readFileOrNull(real);
if (source === null) {
return new Set();
}
const names = collectStaticCommonJsExportNames(source);
for (const specifier of collectExportStarSpecifiers(source)) {
const target = resolveEmittedRequire(real, specifier);
if (target === null) {
continue;
}
for (const name of collectCommonJsExportNames(target, seen)) {
if (name !== "default" && name !== "__esModule" && !names.has(name)) {
names.add(name);
}
}
}
return names;
}
function collectStaticCommonJsExportNames(source: string): Set<string> {
// Scan executable syntax only. Text that merely resembles an assignment —
// `exports.x =` inside a comment, string, or template-literal text — must not
// become an ESM-visible export name, or a named import of it would link to
// `undefined` for a property the CommonJS module never defines. Masking the
// inert lexical spans before matching keeps genuine top-level assignments
// (and executable `${ ... }` template substitutions) while dropping the
// decoys.
const scannable = maskCommentsAndStrings(source);
const names = new Set<string>();
const pattern =
/(?:^|[^\w$])(?:exports|module\.exports)\.([A-Za-z_$][\w$]*)\s*=/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(scannable)) !== null) {
names.add(match[1]!);
}
return names;
}
/**
* Blank out the interior of line comments, block comments, string literals, and
* template-literal text in emitted JavaScript, replacing each masked character
* with a space while preserving newlines, code, and template `${ ... }`
* substitutions verbatim. Positions and length are preserved so an offset in
* the masked text maps back to the same offset in the source.
*
* The input is tsgo's CommonJS emit (well-formed JavaScript), so a character
* scanner that tracks the standard comment/string/template states is sufficient
* to separate executable tokens from inert text. Regular-expression literals
* are intentionally not masked: distinguishing `/`-division from a regex
* literal needs full tokenization, and tsgo's CommonJS emit never wraps an
* `exports.<name> =` assignment inside a regex literal.
*/
function maskCommentsAndStrings(source: string): string {
const out = source.split("");
const n = out.length;
const blank = (index: number): void => {
const ch = out[index];
if (ch !== "\n" && ch !== "\r") {
out[index] = " ";
}
};
// A stack of lexical contexts. The base is code; each backtick pushes a
// template context, and each `${` inside a template pushes a nested code
// context whose `braceDepth` tracks `{}` nesting so an object literal inside
// the substitution does not end it early.
interface Context {
kind: "code" | "template";
braceDepth: number;
}
const stack: Context[] = [{ kind: "code", braceDepth: 0 }];
let i = 0;
while (i < n) {
const top = stack[stack.length - 1]!;
const ch = out[i]!;
if (top.kind === "template") {
if (ch === "\\") {
blank(i);
blank(i + 1);
i += 2;
continue;
}
if (ch === "`") {
blank(i);
stack.pop();
i += 1;
continue;
}
if (ch === "$" && out[i + 1] === "{") {
// Enter a code substitution: `${` and its contents stay executable.
stack.push({ kind: "code", braceDepth: 0 });
i += 2;
continue;
}
blank(i);
i += 1;
continue;
}
// Code context.
if (ch === "/" && out[i + 1] === "/") {
blank(i);
blank(i + 1);
i += 2;
while (i < n && out[i] !== "\n") {
blank(i);
i += 1;
}
continue;
}
if (ch === "/" && out[i + 1] === "*") {
blank(i);
blank(i + 1);
i += 2;
while (i < n && !(out[i] === "*" && out[i + 1] === "/")) {
blank(i);
i += 1;
}
if (i < n) {
blank(i);
blank(i + 1);
i += 2;
}
continue;
}
if (ch === '"' || ch === "'") {
blank(i);
i += 1;
while (i < n && out[i] !== ch) {
if (out[i] === "\\") {
blank(i);
blank(i + 1);
i += 2;
continue;
}
// A bare newline ends an unterminated string; stop masking so the rest
// of the line is still scanned as code (defensive — tsgo never emits
// one).
if (out[i] === "\n") {
break;
}
blank(i);
i += 1;
}
if (i < n && out[i] === ch) {
blank(i);
i += 1;
}
continue;
}
if (ch === "`") {
blank(i);
stack.push({ kind: "template", braceDepth: 0 });
i += 1;
continue;
}
if (ch === "{") {
top.braceDepth += 1;
i += 1;
continue;
}
if (ch === "}") {
if (top.braceDepth === 0 && stack.length > 1) {
// Close the enclosing template `${ ... }` and resume template text.
stack.pop();
i += 1;
continue;
}
if (top.braceDepth > 0) {
top.braceDepth -= 1;
}
i += 1;
continue;
}
i += 1;
}
return out.join("");
}
function collectSourceCommonJsExportNames(
sourceFile: string,
seen: Set<string>,
): Set<string> {
const real = realPath(sourceFile);
if (seen.has(real)) {
return new Set();
}
seen.add(real);
const source = emitCommonJsForNameScan(real);
if (source === null) {
return new Set();
}
const names = collectStaticCommonJsExportNames(source);
for (const specifier of collectExportStarSpecifiers(source)) {
const target = resolveSourceSpecifier(real, specifier);
if (target === null) {
continue;
}
for (const name of collectSourceCommonJsExportNames(target, seen)) {
if (name !== "default" && name !== "__esModule" && !names.has(name)) {
names.add(name);
}
}
}
return names;
}
function collectExportStarSpecifiers(source: string): string[] {
const specifiers: string[] = [];
const pattern =
/^(\s*)__exportStar\(\s*require\((["'])([^"']+)\2\)\s*,\s*exports\s*\);/gm;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
specifiers.push(match[3]!);
}
return specifiers;
}
function resolveEmittedRequire(
emittedFile: string,
specifier: string,
): string | null {
if (!isRelativeSpecifier(specifier)) {
return null;
}
const base = path.resolve(path.dirname(emittedFile), specifier);
if (path.extname(base).length !== 0) {
return isFile(base) ? base : null;
}
for (const extension of [".js", ".cjs", ".mjs"] as const) {
const candidate = base + extension;
if (isFile(candidate)) {
return candidate;
}
}
for (const extension of [".js", ".cjs", ".mjs"] as const) {
const candidate = path.join(base, `index${extension}`);
if (isFile(candidate)) {
return candidate;
}
}
return null;
}
function resolveSourceSpecifier(
sourceFile: string,
specifier: string,
): string | null {
if (!isRelativeSpecifier(specifier)) {
return null;
}
const base = path.resolve(path.dirname(sourceFile), specifier);
if (path.extname(base).length !== 0) {
return isFile(base) ? base : null;
}
for (const extension of TYPESCRIPT_EXTENSIONS) {
const candidate = base + extension;
if (isFile(candidate)) {
return candidate;
}
}
for (const extension of TYPESCRIPT_EXTENSIONS) {
const candidate = path.join(base, `index${extension}`);
if (isFile(candidate)) {
return candidate;
}
}
return null;
}
function isIdentifierName(name: string): boolean {
return /^[A-Za-z_$][\w$]*$/.test(name);
}
/**
* Serve the entry project's pre-built JavaScript for a source file the build
* emitted, or `null` when the file is outside the build or its emit is
* missing.
*
* The bound is the project's `rootDir` (the source root the emit mirrors), not
* its tsconfig directory: a project can pull in a file from elsewhere via
* `files` with a wider `rootDir` (e.g. the lint config loader compiles a
* `*.config.ts` from any directory under `rootDir: "/"`). Anything outside
* `rootDir` cannot have a mirrored emit, so it falls through to the dependency
* paths.
*/
function serveEntryEmit(real: string): ServedSource | null {
const m = manifest();
if (m === null) {
return null;
}
const emitted = entryEmitPath(m, real);
if (emitted === null) {
return null;
}
const source = readFileOrNull(emitted);
return source === null
? null
: {
emittedFile: emitted,
moduleOptions: m.moduleOptions ?? {},
source,
sourceFile: real,
};
}
/**
* The entry project's emitted JavaScript for `real`, or `null` when that
* project did not emit it. Shared with `owningModuleOptions` so "the entry
* project owns this file" means exactly one thing in both places.
*/
function entryEmitPath(m: RuntimeManifest, real: string): string | null {
const cached = entryEmitPathCache.get(real);
if (cached !== undefined) {
return cached;
}
const resolved = isWithin(real, m.rootDir)
? resolveEmittedJavaScript({
emittedFiles: m.emittedFiles,
outDir: m.emitDir,
projectRoot: m.rootDir,
sourceFile: real,
})
: null;
entryEmitPathCache.set(real, resolved);
return resolved;
}
/**
* Memo for `entryEmitPath`, because it is now on the `resolve` hook's path
* through `owningModuleOptions` — once per import specifier — and a miss inside
* `resolveEmittedJavaScript` walks the whole emit tree. The entry emit is
* written once before the run starts and never changes under it, so one answer
* per path is the only one there is.
*/
const entryEmitPathCache = new Map<string, string | null>();
/**
* True when `real` is `directory` itself or sits beneath it. Handles a root
* `directory` (`/`, `C:\`): naively appending a separator would yield `//`,
* which no path starts with, so a root `rootDir` project would serve nothing.
* Both sides are normalized to native separators first: a manifest `rootDir`
* arrives slash-normalized from the synthesized tsconfig (`C:/` on Windows)
* while `real` paths are native, and a raw prefix comparison across the two
* forms silently never matches. Filesystem identity preserves ordinary Windows
* aliases while keeping case-distinct paths under an opted-in directory
* separate. Exported for direct exercise by the ttsx e2e suite — spawned runs
* cannot pin both Windows case-semantics branches on CI.
*/
export function isWithin(
real: string,
directory: string,
identities: FilesystemPathIdentityContext = createFilesystemPathIdentityContext(
{ throwOnRealpathError: false },
),
): boolean {
return identities.isWithin(directory, real);
}
/**
* Build the project that owns `real` (nearest `tsconfig.json` above its real
* path) and return its emitted JavaScript, or `null` when no tsconfig owns it
* or the project does not emit it. The build honours the dependency's own
* tsconfig (transform plugins included), so a source-shipping package that
* needs a transform behaves correctly at runtime.
*/
function serveDependencyEmit(real: string): ServedSource | null {
const tsconfig = nearestTsconfig(real);
if (tsconfig === null) {
return null;
}
let built: BuiltProject;
try {
built = ensureProjectBuilt(tsconfig);
} catch {
// The owning project produced no emit at all; fall back to type-stripping
// this single file rather than failing the whole run.
return null;
}
const served = serveBuiltDependency(built, real);
if (served !== null) {
return served;
}
return null;
}
function serveBuiltDependency(
built: BuiltProject,
real: string,
): ServedSource | null {
const emitted = resolveEmittedJavaScript({
emittedFiles: built.emittedFiles,
outDir: built.emitDir,
projectRoot: built.rootDir,
sourceFile: real,
});
if (emitted === null) {
return null;
}
const source = readFileOrNull(emitted);
return source === null
? null
: {
emittedFile: emitted,
moduleOptions: built.moduleOptions,
source,
sourceFile: real,
};
}
/**
* On-disk completion marker for a built dependency, shared across processes.
*
* `generation` names the exact immutable emit directory this marker describes
* (`<cacheDir>/gen-<generation>`). Binding metadata to one generation is what
* makes publication atomic: a reader that parses this marker reads the emit of
* the SAME generation, never old metadata combined with a different, still
* partially-written directory. The marker is the last thing a build writes, and
* it is written by an atomic temp-and-rename, so a reader observes either one
* complete old generation or one complete new generation.
*/
interface DependencyCacheMeta {
generation: string;
rootDir: string;
moduleOptions?: OwningModuleOptions;
}
/**
* Build the project that owns a dependency once per run and share the result
* across every process the program spawns.
*
* A program (a benchmark, a worker pool) can fan out into many child processes,
* each of which inherits the runtime manifest and would otherwise rebuild every
* dependency from scratch — and worse, several at once into the same directory,
* corrupting each other. So the build output is content-keyed under the shared
* per-run cache: a finished build leaves a meta marker that any later process
* (or a second import in this one) reuses, and concurrent first-builders are
* serialised by an atomic lock directory.
*/
function ensureProjectBuilt(tsconfig: string): BuiltProject {
const cached = builtProjects.get(tsconfig);
if (cached !== undefined) {
return cached;
}
const { cacheDir, lockDir, metaPath, root } = dependencyCachePaths(tsconfig);
const reuse = readDependencyCache(cacheDir, metaPath);
if (reuse !== null) {
builtProjects.set(tsconfig, reuse);
return reuse;
}
fs.mkdirSync(root, { recursive: true });
const built = withBuildLock(cacheDir, metaPath, lockDir, () =>
buildDependency(tsconfig, cacheDir, metaPath),
);
builtProjects.set(tsconfig, built);
return built;
}
interface DependencyCachePaths {
/** Container of this dependency's generation-stamped emit directories. */
cacheDir: string;
/** Fenced cross-process coordination directory (`<key>.lock`). */
lockDir: string;
/** Atomic completion pointer (`<key>.json`) naming the live generation. */
metaPath: string;
root: string;
}
function dependencyCachePaths(tsconfig: string): DependencyCachePaths {
const key = crypto
.createHash("sha256")
.update(tsconfig)
.digest("hex")
.slice(0, 16);
const root = dependencyCacheRoot();
return {
cacheDir: path.join(root, key),
lockDir: path.join(root, `${key}.lock`),
metaPath: path.join(root, `${key}.json`),
root,
};
}
/** The immutable emit directory of one build generation under `cacheDir`. */
function dependencyGenerationDir(cacheDir: string, generation: string): string {
return path.join(cacheDir, `gen-${generation}`);
}
/** A fresh 128-bit build-generation identifier. */
function newDependencyGeneration(): string {
return crypto.randomBytes(16).toString("hex");
}
/** True for a well-formed 128-bit hex build generation. */
function isDependencyGeneration(value: unknown): value is string {
return typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
}
/**
* Reuse a dependency another process (or an earlier import) already built.
*
* The completion marker names the exact emit generation, so this reads metadata
* and emit as one unit: it returns a hit only when the marker parses to a valid
* generation AND that generation's directory holds emitted JavaScript. A reader
* that runs while a replacement build is populating a DIFFERENT generation
* directory keeps returning the previous complete generation until the atomic
* marker swap points at the new one — never a mix of old metadata and a partial
* new emit.
*
* Exported for the ttsx dependency-cache regressions.
*/
export function readDependencyCache(
cacheDir: string,
metaPath: string,
): BuiltProject | null {
let meta: DependencyCacheMeta;
try {
meta = JSON.parse(fs.readFileSync(metaPath, "utf8")) as DependencyCacheMeta;
} catch {
return null;
}
if (
!isDependencyGeneration(meta.generation) ||
typeof meta.rootDir !== "string" ||
// A marker with no `moduleOptions` object predates this field and cannot
// say which format its emit carries. Treating the absence as "no options"
// would classify a CommonJS emit as an ES module, so the generation is
// rejected and rebuilt instead. Every marker this version writes carries
// the object, empty or not.
typeof meta.moduleOptions !== "object" ||
meta.moduleOptions === null ||
Array.isArray(meta.moduleOptions)
) {
return null;
}
const emitDir = dependencyGenerationDir(cacheDir, meta.generation);
if (!emittedAnything(emitDir)) {
return null;
}
return {
emitDir,
emittedFiles: undefined,
moduleOptions: projectModuleOptions(
meta.moduleOptions as Record<string, unknown>,
),
// Resolved on the way out, not trusted as written. `rootDir` never gated
// reuse — the marker's generation, module options, and a non-empty emit do
// — so a marker carrying an unresolved spelling was already being reused,
// and then served every file of that dependency through the whole-tree stem
// rescan. The pass is idempotent and runs only on a hit, which
// `ensureProjectBuilt` memoizes per tsconfig. A marker from an earlier ttsc
// survives only under the shared `os.tmpdir()/ttsx-dep` fallback root; the
// manifest's own `depCacheDir` is per-process and removed with the run.
rootDir: resolvePhysicalPath(meta.rootDir),
};
}
/**
* Run `build` while holding the fenced lock for this dependency, re-checking
* the cache once the lock is held (a concurrent builder may have just
* finished). A loser polls for the winner's completion marker and, only when
* the holding generation is provably abandoned (dead owner or the steal budget
* elapsed), retires precisely that generation before retrying — never a
* successor's.
*/
function withBuildLock(
cacheDir: string,
metaPath: string,
lockDir: string,
build: () => BuiltProject,
): BuiltProject {
for (;;) {
const reuse = readDependencyCache(cacheDir, metaPath);
if (reuse !== null) {
return reuse;
}
let lease: DependencyBuildLockLease | null;
try {
lease = acquireDependencyBuildLock(lockDir);
} catch {
// An unusable coordination directory must not silently skip the build.
// Generation-stamped emit and the atomic marker swap still keep every
// reader's view of publication consistent without the lock.
return build();
}
if (lease === null) {
const waited = waitForDependencyBuild(
cacheDir,
metaPath,
lockDir,
DEP_BUILD_LOCK_STEAL_MS,
);
if (waited.outcome === "built") {
return waited.built;
}
if (waited.outcome === "abandoned") {
// Retire only the generation this observation named. Losing the rename
// race means the holder's own release (or another waiter) already made
// progress, so a stale result never removes a live successor.
reclaimDependencyBuildLock(lockDir, waited.fence);
}
// "released" needs no repair: the holder freed the lock normally, so
// retry the ordinary acquisition.
continue;
}
try {
const reuseUnderLock = readDependencyCache(cacheDir, metaPath);
return reuseUnderLock ?? build();
} finally {