ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
1,237 lines • 83.6 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TTSX_MINIMUM_NODE_VERSION = void 0;
exports.checkNodeRuntimeSupport = checkNodeRuntimeSupport;
exports.installRuntimeHooks = installRuntimeHooks;
exports.entryModuleFormat = entryModuleFormat;
exports.restoreStrippedNodeBuiltinScheme = restoreStrippedNodeBuiltinScheme;
exports.projectModuleOptions = projectModuleOptions;
exports.isWithin = isWithin;
exports.readDependencyCache = readDependencyCache;
exports.acquireDependencyBuildLock = acquireDependencyBuildLock;
exports.releaseDependencyBuildLock = releaseDependencyBuildLock;
exports.reclaimDependencyBuildLock = reclaimDependencyBuildLock;
exports.inspectDependencyBuildLock = inspectDependencyBuildLock;
exports.realPath = realPath;
const node_crypto_1 = __importDefault(require("node:crypto"));
const node_fs_1 = __importDefault(require("node:fs"));
const node_module_1 = require("node:module");
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const node_url_1 = require("node:url");
const readProjectConfig_1 = require("../../compiler/internal/project/readProjectConfig");
const resolveEmittedJavaScript_1 = require("../../compiler/internal/resolveEmittedJavaScript");
const resolveTsgo_1 = require("../../compiler/internal/resolveTsgo");
const runBuild_1 = require("../../compiler/internal/runBuild");
const spawnNative_1 = require("../../compiler/internal/spawnNative");
const projectInputPathIdentity_1 = require("../../internal/projectInputPathIdentity");
const servedSourceMap_1 = require("./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",
];
/** TypeScript source extensions these hooks compile. */
const TYPESCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
let manifestCache;
function manifest() {
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(node_fs_1.default.readFileSync(file, "utf8"));
}
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`.
*/
exports.TTSX_MINIMUM_NODE_VERSION = "22.15.0";
const TTSX_MINIMUM_NODE_PARTS = [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.
*/
function checkNodeRuntimeSupport(version) {
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 ${exports.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) {
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, b) {
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() {
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.
*/
function installRuntimeHooks() {
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);
}
(0, node_module_1.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() {
const extensions = node_module_1.Module._extensions;
const compile = (module, filename) => {
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`.
*/
function entryModuleFormat(entryFile) {
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, context, nextResolve) {
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.
*/
function restoreStrippedNodeBuiltinScheme(specifier, result) {
return (0, node_module_1.isBuiltin)(specifier) &&
specifier.startsWith("node:") &&
result.url === specifier.slice("node:".length)
? { ...result, url: specifier }
: result;
}
const builtProjects = new Map();
/** File URLs whose CommonJS source was reached from an ESM parent import. */
const commonJsNamedInteropUrls = new Set();
const commonJsNameScanSources = new Map();
function load(url, context, nextLoad) {
if (!url.startsWith("file:")) {
return nextLoad(url, context);
}
const filename = (0, node_url_1.fileURLToPath)(url);
if (!isTypeScriptSource(filename)) {
return nextLoad(url, context);
}
const { format, source } = resolveRuntimeSource(filename, url);
return {
format,
shortCircuit: true,
source,
};
}
function resolveRuntimeSource(filename, url = (0, node_url_1.pathToFileURL)(filename).href) {
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, context) {
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, parentURL) {
if (parentURL === undefined ||
!url.startsWith("file:") ||
!parentURL.startsWith("file:")) {
return false;
}
const parentFile = (0, node_url_1.fileURLToPath)(parentURL);
if (moduleFormat(parentFile, owningModuleOptions(parentFile)) !== "module") {
return false;
}
return isTypeScriptSource((0, node_url_1.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) {
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 = {};
try {
const project = (0, readProjectConfig_1.readProjectConfig)({
cwd: node_path_1.default.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. */
function projectModuleOptions(compilerOptions) {
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, url = (0, node_url_1.pathToFileURL)(filename).href) {
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) {
const source = (0, servedSourceMap_1.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, url) {
if (moduleFormat(filename, null) === "commonjs") {
const lowered = emitOrphanAsCommonJs(filename);
if (lowered !== null) {
return lowered;
}
}
return (0, node_module_1.stripTypeScriptTypes)(node_fs_1.default.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) {
let tsgo;
try {
tsgo = (0, resolveTsgo_1.resolveTsgo)({ cwd: node_path_1.default.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 = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "ttsx-orphan-"));
try {
const res = (0, spawnNative_1.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: node_path_1.default.dirname(filename), encoding: "utf8" });
const emitted = parseFirstEmittedFile((0, spawnNative_1.outputText)(res.stdout));
const lowered = emitted === null ? null : readFileOrNull(emitted);
if (lowered !== null && cacheFile !== null) {
writeOrphanCache(cacheFile, lowered);
}
return lowered;
}
catch {
return null;
}
finally {
node_fs_1.default.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) {
const real = realPath(filename);
const cached = commonJsNameScanSources.get(real);
if (cached !== undefined) {
return cached;
}
let tsgo;
try {
tsgo = (0, resolveTsgo_1.resolveTsgo)({ cwd: node_path_1.default.dirname(real) }).binary;
}
catch {
commonJsNameScanSources.set(real, null);
return null;
}
const outDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "ttsx-export-scan-"));
try {
const res = (0, spawnNative_1.spawnNative)(tsgo, [
real,
"--ignoreConfig",
"--module",
"commonjs",
"--target",
"es2022",
"--noCheck",
"--skipLibCheck",
"--outDir",
outDir,
"--listEmittedFiles",
], { cwd: node_path_1.default.dirname(real), encoding: "utf8" });
const emitted = pickEmittedJavaScript(real, parseEmittedFiles((0, spawnNative_1.outputText)(res.stdout)));
const lowered = emitted === null ? null : readFileOrNull(emitted);
commonJsNameScanSources.set(real, lowered);
return lowered;
}
catch {
commonJsNameScanSources.set(real, null);
return null;
}
finally {
node_fs_1.default.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() {
const base = process.env.TTSC_CACHE_DIR && process.env.TTSC_CACHE_DIR.length !== 0
? process.env.TTSC_CACHE_DIR
: node_path_1.default.join(node_os_1.default.tmpdir(), "ttsc-orphan");
return node_path_1.default.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, tsgo) {
let source;
try {
source = node_fs_1.default.readFileSync(filename);
}
catch {
return null;
}
const key = node_crypto_1.default
.createHash("sha256")
.update(tsgo)
.update("\0")
.update(source)
.digest("hex")
.slice(0, 32);
return node_path_1.default.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, lowered) {
try {
node_fs_1.default.mkdirSync(node_path_1.default.dirname(cacheFile), { recursive: true });
const tmp = `${cacheFile}.${process.pid}.${Date.now()}.tmp`;
node_fs_1.default.writeFileSync(tmp, lowered);
node_fs_1.default.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) {
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) {
const files = [];
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, emittedFiles) {
const stem = node_path_1.default
.basename(filename)
.replace(/\.[cm]?tsx?$/i, "")
.toLowerCase();
const candidates = emittedFiles.filter((file) => {
const parsed = node_path_1.default.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, emittedFile, sourceFile) {
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, indent, _quote, specifier) => {
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, sourceFile, specifier) {
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, seen) {
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) {
// 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();
const pattern = /(?:^|[^\w$])(?:exports|module\.exports)\.([A-Za-z_$][\w$]*)\s*=/g;
let match;
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) {
const out = source.split("");
const n = out.length;
const blank = (index) => {
const ch = out[index];
if (ch !== "\n" && ch !== "\r") {
out[index] = " ";
}
};
const stack = [{ 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, seen) {
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) {
const specifiers = [];
const pattern = /^(\s*)__exportStar\(\s*require\((["'])([^"']+)\2\)\s*,\s*exports\s*\);/gm;
let match;
while ((match = pattern.exec(source)) !== null) {
specifiers.push(match[3]);
}
return specifiers;
}
function resolveEmittedRequire(emittedFile, specifier) {
if (!isRelativeSpecifier(specifier)) {
return null;
}
const base = node_path_1.default.resolve(node_path_1.default.dirname(emittedFile), specifier);
if (node_path_1.default.extname(base).length !== 0) {
return isFile(base) ? base : null;
}
for (const extension of [".js", ".cjs", ".mjs"]) {
const candidate = base + extension;
if (isFile(candidate)) {
return candidate;
}
}
for (const extension of [".js", ".cjs", ".mjs"]) {
const candidate = node_path_1.default.join(base, `index${extension}`);
if (isFile(candidate)) {
return candidate;
}
}
return null;
}
function resolveSourceSpecifier(sourceFile, specifier) {
if (!isRelativeSpecifier(specifier)) {
return null;
}
const base = node_path_1.default.resolve(node_path_1.default.dirname(sourceFile), specifier);
if (node_path_1.default.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 = node_path_1.default.join(base, `index${extension}`);
if (isFile(candidate)) {
return candidate;
}
}
return null;
}
function isIdentifierName(name) {
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) {
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, real) {
const cached = entryEmitPathCache.get(real);
if (cached !== undefined) {
return cached;
}
const resolved = isWithin(real, m.rootDir)
? (0, resolveEmittedJavaScript_1.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();
/**
* 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.
*/
function isWithin(real, directory, identities = (0, projectInputPathIdentity_1.createFilesystemPathIdentityContext)({ throwOnRealpathError: false })) {
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) {
const tsconfig = nearestTsconfig(real);
if (tsconfig === null) {
return null;
}
let built;
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, real) {
const emitted = (0, resolveEmittedJavaScript_1.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,
};
}
/**
* 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) {
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;
}
node_fs_1.default.mkdirSync(root, { recursive: true });
const built = withBuildLock(cacheDir, metaPath, lockDir, () => buildDependency(tsconfig, cacheDir, metaPath));
builtProjects.set(tsconfig, built);
return built;
}
function dependencyCachePaths(tsconfig) {
const key = node_crypto_1.default
.createHash("sha256")
.update(tsconfig)
.digest("hex")
.slice(0, 16);
const root = dependencyCacheRoot();
return {
cacheDir: node_path_1.default.join(root, key),
lockDir: node_path_1.default.join(root, `${key}.lock`),
metaPath: node_path_1.default.join(root, `${key}.json`),
root,
};
}
/** The immutable emit directory of one build generation under `cacheDir`. */
function dependencyGenerationDir(cacheDir, generation) {
return node_path_1.default.join(cacheDir, `gen-${generation}`);
}
/** A fresh 128-bit build-generation identifier. */
function newDependencyGeneration() {
return node_crypto_1.default.randomBytes(16).toString("hex");
}
/** True for a well-formed 128-bit hex build generation. */
function isDependencyGeneration(value) {
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.
*/
function readDependencyCache(cacheDir, metaPath) {
let meta;
try {
meta = JSON.parse(node_fs_1.default.readFileSync(metaPath, "utf8"));
}
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),
// 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, metaPath, lockDir, build) {
for (;;) {
const reuse = readDependencyCache(cacheDir, metaPath);
if (reuse !== null) {
return reuse;
}
let lease;
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 {
releaseDependencyBuildLock(lockDir, lease);
}
}
}
/** Block the current (synchronous) thread for `ms` without busy-spinning. */
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
/**
* Compile a dependency project into a fresh generation directory and publish
* its completion marker atomically.
*
* The emit lands in `<cacheDir>/gen-<generation>`, a directory no other
* generation or process shares, so it is never mutated in place while a reader
* looks at it. Only after the emit is proven non-empty is the marker written by
* temp-and-rename, binding metadata to that exact generation. A build that
* produced no output drops its partial directory so a failed generation can
* never be reused.
*/
function buildDependency(tsconfig, cacheDir, metaPath) {
const project = (0, readProjectConfig_1.readProjectConfig)({ cwd: node_path_1.default.dirname(tsconfig), tsconfig });
const generation = newDependencyGeneration();
const emitDir = dependencyGenerationDir(cacheDir, generation);
node_fs_1.default.rmSync(emitDir, { force: true, recursive: true });
node_fs_1.default.mkdirSync(emitDir, { recursive: true });
const result = (0, runBuild_1.runBuild)({
cwd: project.root,
emit: true,
forceListEmittedFiles: true,
outDir: emitDir,
// Emit a source map on the transient dependency emit (it never reaches the
// dependency's published `lib/`) so the serve path can inline it under the
// source URL, but only when the dependency configures none itself. Routed
// as a dedicated build option, not a forwarded tsgo flag, so it never
// reaches a native plugin host's argument parser (issue #353).
forceRuntimeSourceMap: project.compilerOptions.sourceMap !== true &&
project.compilerOptions.inlineSourceMap !== true,
// Honour the dependency's own transform plugins: a source-shipping package
// can itself depend on a transform (e.g. a fix