UNPKG

ttsc

Version:

General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.

1,494 lines (1,421 loc) 85.7 kB
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 {