UNPKG

ttsc

Version:

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

1,237 lines 83.6 kB
"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