ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
529 lines • 24.5 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.prepareExecution = prepareExecution;
exports.linkVirtualEntry = linkVirtualEntry;
const node_fs_1 = __importDefault(require("node:fs"));
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const paths_1 = require("../../compiler/internal/paths");
const readProjectConfig_1 = require("../../compiler/internal/project/readProjectConfig");
const resolveEmittedJavaScript_1 = require("../../compiler/internal/resolveEmittedJavaScript");
const runBuild_1 = require("../../compiler/internal/runBuild");
const projectInputPathIdentity_1 = require("../../internal/projectInputPathIdentity");
const runtimeHooks_1 = require("./runtimeHooks");
/** Subdirectory name that isolates concurrent ttsx processes by PID. */
const PROCESS_CACHE_KEY = String(process.pid);
/**
* Maximum number of ancestor directories above the project root that the
* virtual filesystem overlay mirrors. Three levels covers the common monorepo
* layout (workspace-root → packages → package-root) so `node_modules` symlinks
* resolve correctly without reaching an unsafe boundary.
*/
const MAX_VIRTUAL_PARENT_DEPTH = 3;
/**
* Emit directory of the entry-only fallback build, a sibling of the virtual
* layout's volume-label directories so it can never collide with a mirrored
* project path.
*/
const ENTRY_PROJECT_EMIT_DIR = "entry-project";
/** Build the owning project and locate the emitted JavaScript entry for `ttsx`. */
function prepareExecution(entryFile, options = {}) {
// Two paths, because two different questions are being asked.
//
// *Which project compiles this?* is answered from the path the user named.
// Project discovery walks up from it, so resolving a symlinked entry first
// would start that walk in the target's tree — finding another project's
// tsconfig, or none at all.
//
// *Where does the compiler put the output, and what does the runtime load?*
// is answered from the physical path, because that is the spelling Node
// forces. See `resolveEntrySpelling`.
const cwd = node_path_1.default.resolve(options.cwd ?? process.cwd());
const entry = resolveEntrySpelling(cwd, entryFile);
const context = createProjectContext(cwd, node_path_1.default.resolve(cwd, entryFile), entry, options);
try {
buildProject(context, options);
let emittedEntry = emittedEntryOf(context, entry);
if (emittedEntry === null) {
buildEntryProject(context, options, entry);
emittedEntry = emittedEntryOf(context, entry);
}
if (emittedEntry === null) {
throw new Error(`ttsx: emitted entry not found for ${entryFile}`);
}
return {
cleanupDir: context.processDir,
emitDir: context.emitDir,
emittedFiles: context.emittedFiles ?? undefined,
entryFile: emittedEntry,
moduleOptions: context.moduleOptions,
projectRoot: context.root,
rootDir: context.runtimeRootDir,
};
}
catch (error) {
removeRuntimeOutput(context.processDir);
throw error;
}
}
/**
* The JavaScript this build emitted for `entry`, or `null` when it emitted none
* — which is the signal that the entry sits outside the project's file set.
*
* The guard is what makes this an ownership answer rather than a guess. tsgo
* strips `runtimeRootDir` from every output path, so a file outside that root
* cannot have an output under `outDir` at all; without the guard the lookup
* falls through to `resolveEmittedJavaScript`'s trailing-stem matcher, and a
* `build/release.ts` would happily match the `release.js` emitted for an
* unrelated `src/release.ts` — running the wrong file instead of compiling the
* requested one.
*
* It is lexical on purpose, and it is the same test `resolveEmittedJavaScript`
* then applies — including its rejection of an entry that _is_ the root. That
* only holds because both sides are _produced_ physically: the entry by
* `resolveEntrySpelling`, the root by `resolveRuntimeSourceRoot`. Folding here
* instead would paper over a mixed pair and disagree with the mirror that runs
* immediately after; keeping the pair honest is what makes folding
* unnecessary.
*/
function emittedEntryOf(context, entry) {
const relative = node_path_1.default.relative(context.runtimeRootDir, entry);
if (relative === "" || (0, paths_1.isOutsideRelativePath)(relative)) {
return null;
}
return (0, resolveEmittedJavaScript_1.resolveEmittedJavaScript)({
emittedFiles: context.emittedFiles ?? undefined,
outDir: context.emitDir,
projectRoot: context.runtimeRootDir,
sourceFile: entry,
});
}
/**
* The entry in the filesystem's own spelling, symlinked file included.
*
* Node is what forces the choice. Without `--preserve-symlinks` it keys a
* module by its real path, so the runtime hooks identify a served file that way
* too — and the emit has to be findable under the same name. tsgo does not
* force anything: it takes `files` verbatim and never resolves them, which is
* exactly why it must be handed the spelling Node will use rather than a
* different one.
*
* An entry spelled any other way is not a nicer name for the same file. The
* gate would claim to own an emit the runtime then refuses to serve, and the
* entry would run through the orphan type-strip lane with the project's
* transform plugins, `target`, `paths`, and source map all silently dropped —
* from a run that still prints and still exits zero.
*
* Resolving the link widens `rootDir` to the ancestor the two trees share,
* which is not a cost but the requirement: the file genuinely lives outside the
* project, and no root that excludes it can compile it.
*/
function resolveEntrySpelling(cwd, entryFile) {
const identities = (0, projectInputPathIdentity_1.createFilesystemPathIdentityContext)({
throwOnRealpathError: false,
});
return identities.resolve(node_path_1.default.resolve(cwd, entryFile)).path;
}
/**
* @param discoveryFile - The entry as the user named it. Project discovery
* walks up from here, so it must not be retargeted through a symlink.
* @param entry - The entry in its physical spelling, which is what the emit
* layout and the runtime hooks both speak.
*/
function createProjectContext(cwd, discoveryFile, entry, options) {
const project = (0, readProjectConfig_1.readProjectConfig)(options.project
? {
cwd,
projectRoot: options.projectRoot,
tsconfig: node_path_1.default.resolve(cwd, options.project),
}
: { cwd, file: discoveryFile, projectRoot: options.projectRoot });
const tsconfig = project.path;
const root = project.root;
const explicitCacheDir = resolveCacheDir(cwd, options.cacheDir);
const cacheDir = explicitCacheDir ??
node_path_1.default.join(root, "node_modules", ".cache", "ttsc", "ttsx");
const processDir = node_path_1.default.join(cacheDir, "project", PROCESS_CACHE_KEY);
const virtualRoot = node_path_1.default.join(processDir, "fs");
// Resolved once: it now costs a realpath (and, for a missing directory on
// Windows, a case-sensitivity probe) rather than a string join.
const runtimeRootDir = resolveRuntimeSourceRoot(project, entry);
return {
project,
tsconfig,
root,
cacheDir,
processDir,
pluginCacheDir: explicitCacheDir,
virtualRoot,
emitDir: project.compilerOptions.outDir
? virtualPath(virtualRoot, project.compilerOptions.outDir)
: virtualPath(virtualRoot, runtimeRootDir),
// The source-tree root the emit mirrors (tsgo strips this prefix). Used to
// map a source `.ts` back to its emitted `.js` when the runtime hooks serve
// the built entry under its source URL.
runtimeRootDir,
// The tsconfig options that decide the emit format, so the runtime hooks
// classify each served file the same way tsgo chose when emitting it.
// `target` belongs here as much as `module` does: with `module` absent tsgo
// derives the module kind from `target`, so publishing only `module` makes
// the hooks guess.
moduleOptions: (0, runtimeHooks_1.projectModuleOptions)(project.compilerOptions),
// Force a source map on the transient runtime emit only when the project
// configures none — when it already emits `sourceMap` or `inlineSourceMap`,
// the serve path inlines/absolutizes that map, so no override is needed
// (issue #353).
forceRuntimeSourceMap: project.compilerOptions.sourceMap !== true &&
project.compilerOptions.inlineSourceMap !== true,
built: false,
emittedFiles: undefined,
};
}
/**
* The source-tree root the emit mirrors, in the same physical spelling as the
* entry it will be compared against.
*
* Resolving it is the other half of `resolveEntrySpelling`, and skipping it
* leaves the comparison mixed rather than merely imprecise. `project.root`
* arrives through plain `fs.realpathSync`, which resolves reparse points but
* leaves a Windows 8.3 component alone, while the entry arrives through
* `fs.realpathSync.native`, which expands it — and `path.relative` folds case
* but not 8.3. A declared `rootDir` is worse still: it is joined verbatim, so a
* `rootDir` that is itself a symlinked directory never resolves at all. Either
* way the gate reads an in-project entry as outside its own root, pays a second
* whole build for it, and publishes a wider root than the project has.
*
* `path.dirname(entry)` is already physical, so only the declared branch needs
* the pass.
*/
function resolveRuntimeSourceRoot(project, entry) {
const rootDir = project.compilerOptions.rootDir;
if (typeof rootDir !== "string") {
return node_path_1.default.dirname(entry);
}
const identities = (0, projectInputPathIdentity_1.createFilesystemPathIdentityContext)({
throwOnRealpathError: false,
});
return identities.resolve(node_path_1.default.isAbsolute(rootDir) ? rootDir : node_path_1.default.resolve(project.root, rootDir)).path;
}
function buildProject(context, options) {
if (context.built)
return;
node_fs_1.default.mkdirSync(context.cacheDir, { recursive: true });
node_fs_1.default.rmSync(context.processDir, { recursive: true, force: true });
node_fs_1.default.mkdirSync(node_path_1.default.dirname(context.emitDir), { recursive: true });
const result = (0, runBuild_1.runBuild)({
binary: options.binary,
checkers: options.checkers,
cwd: context.root,
emit: true,
env: options.env,
forceListEmittedFiles: true,
cacheDir: context.pluginCacheDir,
outDir: context.emitDir,
passthrough: options.passthrough,
// Emit a source map on the transient entry emit (a PID-isolated temp dir,
// never the consumer's `outDir`) so the serve path can inline it under the
// source URL. 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: context.forceRuntimeSourceMap,
pluginConfigDir: options.pluginConfigDir,
plugins: options.plugins,
quiet: true,
resolvedProject: context.project,
singleThreaded: options.singleThreaded,
tsconfig: context.tsconfig,
});
if (result.status === 0) {
linkVirtualProjectLayout(context);
context.built = true;
context.emittedFiles =
result.emittedFiles && result.emittedFiles.length !== 0
? result.emittedFiles
: undefined;
return;
}
removeRuntimeOutput(context.processDir);
const detail = [
`ttsx: project check failed for ${context.tsconfig}`,
result.stderr || result.stdout,
]
.filter((line) => line.trim().length !== 0)
.join("\n");
throw new Error(detail);
}
/**
* Build an entry the owning project's file set does not contain.
*
* `ttsc` selects a _file set_: a project whose `include` is `src` must emit
* only `src` into `outDir`, and a `clear.ts`, a `build/release.ts`, or a
* `lint.config.ts` beside the tsconfig has no business in `lib`. `ttsx` selects
* an _entry_: it needs that same project's compiler options, not its file list.
* Those two requirements are not in conflict, but the whole-project build
* cannot satisfy the second one, so an entry it did not emit is compiled here
* through a project that inherits every option and declares only the entry.
*
* The synthesized tsconfig is written beside the real one on purpose. `extends`
* with an absolute path would resolve from anywhere, but `${configDir}` and
* `paths` are anchored to the directory of the config that consumes them, so
* any other location silently retargets them. It is removed as soon as the
* build returns.
*
* `rootDir` widens to the nearest directory holding both the project root and
* the entry — for the layout this exists for, the project root itself; for an
* entry that is a symlink out of the tree, the ancestor the two trees share. It
* has to widen at least that far, because the inherited `rootDir` (`src`) does
* not contain the entry and tsgo emits an input outside `rootDir` to its own
* source path.
*
* Widening costs precision, not safety. The manifest's `rootDir` bounds which
* files the runtime hooks will try to serve from this emit, so a wide one
* admits more sources to the lookup — but the lookup only ever answers with a
* file from this build's own emit directory, whether from `emittedFiles` or a
* scan of `outDir` (`resolveEmittedJavaScript`). What a wide root risks is the
* exact mirror missing and the trailing-stem matcher picking the wrong output
* _of this build_; it cannot reach a raw source on disk.
*/
function buildEntryProject(context, options, entry) {
// `entry` already carries the one spelling `prepareExecution` decided on.
// tsgo compares it against `rootDir` textually — `GetCommonSourceDirectory`
// takes `rootDir` verbatim and `ContainsPath` is lexical — so a mismatch here
// is not a near miss: the entry counts as outside `rootDir`, and tsgo emits
// it to its own source path with the extension changed instead of under
// `outDir`, writing a `.js` and its map beside the user's `.ts` where nothing
// cleans them up.
const rootDir = commonAncestorDirectory(node_path_1.default.dirname(entry), context.root);
const tsconfig = node_path_1.default.join(context.root, `.ttsx-entry.${PROCESS_CACHE_KEY}.tsconfig.json`);
node_fs_1.default.writeFileSync(tsconfig, JSON.stringify({
extends: context.tsconfig.replace(/\\/g, "/"),
compilerOptions: { rootDir: rootDir.replace(/\\/g, "/") },
// `files` alone does not displace an inherited `include`, and an
// inherited `exclude` could drop the entry back out of the program, so
// both are overridden explicitly.
files: [entry.replace(/\\/g, "/")],
include: [],
exclude: [],
}, null, 2), "utf8");
try {
const project = (0, readProjectConfig_1.readProjectConfig)({
cwd: context.root,
projectRoot: options.projectRoot,
tsconfig,
});
const emitDir = node_path_1.default.join(context.virtualRoot, ENTRY_PROJECT_EMIT_DIR);
node_fs_1.default.mkdirSync(emitDir, { recursive: true });
const result = (0, runBuild_1.runBuild)({
binary: options.binary,
checkers: options.checkers,
cwd: context.root,
emit: true,
env: options.env,
forceListEmittedFiles: true,
cacheDir: context.pluginCacheDir,
outDir: emitDir,
passthrough: options.passthrough,
forceRuntimeSourceMap: context.forceRuntimeSourceMap,
pluginConfigDir: options.pluginConfigDir,
plugins: options.plugins,
quiet: true,
resolvedProject: project,
singleThreaded: options.singleThreaded,
tsconfig,
});
if (result.status !== 0) {
removeRuntimeOutput(context.processDir);
throw new Error([
`ttsx: entry check failed for ${entry}`,
result.stderr || result.stdout,
]
.filter((line) => line.trim().length !== 0)
.join("\n"));
}
context.emitDir = emitDir;
context.runtimeRootDir = rootDir;
context.moduleOptions = (0, runtimeHooks_1.projectModuleOptions)(project.compilerOptions);
context.emittedFiles =
result.emittedFiles && result.emittedFiles.length !== 0
? result.emittedFiles
: undefined;
}
finally {
try {
node_fs_1.default.rmSync(tsconfig, { force: true });
}
catch {
// Best effort: a leftover synthesized tsconfig must not mask a build
// failure, and it is PID-scoped so it can never be mistaken for a real
// project config.
}
}
}
/**
* The nearest directory containing both `left` and `right`, in the physical
* spelling both of them share.
*
* Containment is asked through the same filesystem-identity predicate the
* runtime hooks use, and the answer is resolved through it too. Its caller now
* passes an already-resolved directory, so the two spellings agree before the
* walk starts; the predicate stays because this returns a `rootDir` that tsgo
* takes verbatim, and answering in anything but the physical spelling would
* leave tsgo unable to place a sibling source under it.
*
* Falls back to the entry's directory when there genuinely is no shared
* ancestor, as on two different Windows volumes: the entry still has to
* compile, and a root that contains it is the closest thing to correct
* available.
*/
function commonAncestorDirectory(left, right) {
const identities = (0, projectInputPathIdentity_1.createFilesystemPathIdentityContext)({
throwOnRealpathError: false,
});
const from = identities.resolve(node_path_1.default.resolve(left)).path;
const target = identities.resolve(node_path_1.default.resolve(right)).path;
let current = from;
for (;;) {
if (identities.isWithin(current, target)) {
return current;
}
const parent = node_path_1.default.dirname(current);
if (parent === current) {
return from;
}
current = parent;
}
}
function removeRuntimeOutput(directory) {
try {
node_fs_1.default.rmSync(directory, { recursive: true, force: true });
}
catch {
// Best effort: cleanup must not hide the original preparation failure.
}
}
function resolveCacheDir(cwd, cacheDir) {
if (!cacheDir) {
return undefined;
}
return node_path_1.default.isAbsolute(cacheDir) ? cacheDir : node_path_1.default.resolve(cwd, cacheDir);
}
function linkVirtualProjectLayout(context) {
for (const directory of collectLinkDirectories(context.root)) {
const virtualDirectory = virtualPath(context.virtualRoot, directory);
node_fs_1.default.mkdirSync(virtualDirectory, { recursive: true });
for (const entry of node_fs_1.default.readdirSync(directory, { withFileTypes: true })) {
const realEntry = node_path_1.default.join(directory, entry.name);
const virtualEntry = node_path_1.default.join(virtualDirectory, entry.name);
if (node_fs_1.default.existsSync(virtualEntry)) {
continue;
}
linkVirtualEntry(realEntry, virtualEntry, entry);
}
}
}
// Exported for direct exercise by the ttsx e2e suite: the Windows fallback
// branches below cannot be reached through a spawned run on CI (creating a
// file-symlink fixture needs the very privilege the fallback avoids).
function linkVirtualEntry(realEntry, virtualEntry, entry) {
if (entry.isDirectory()) {
// Use junction points on Windows; plain symlinks elsewhere.
node_fs_1.default.symlinkSync(realEntry, virtualEntry, process.platform === "win32" ? "junction" : undefined);
return;
}
if (entry.isFile()) {
try {
// Hard-link first: cheap, preserves inode, no extra disk usage.
node_fs_1.default.linkSync(realEntry, virtualEntry);
}
catch {
// Cross-device or unsupported filesystem: fall back to a full copy.
node_fs_1.default.copyFileSync(realEntry, virtualEntry);
}
return;
}
if (process.platform === "win32" &&
entry.isSymbolicLink() &&
isDirectorySymlinkTarget(realEntry)) {
node_fs_1.default.symlinkSync(realEntry, virtualEntry, "junction");
return;
}
// Symlinks (and other special entries) are re-symlinked as-is. On Windows,
// a file symlink needs SeCreateSymbolicLinkPrivilege (admin or Developer
// Mode), so mirror the plain-file branch's hard-link/copy fallback instead
// of failing the run (#306). A link whose target no longer exists is
// skipped: it can serve no module, and none of the fallbacks can
// materialize it without symlink privileges.
try {
node_fs_1.default.symlinkSync(realEntry, virtualEntry);
}
catch {
if (!node_fs_1.default.existsSync(realEntry)) {
return;
}
try {
node_fs_1.default.linkSync(realEntry, virtualEntry);
}
catch {
node_fs_1.default.copyFileSync(realEntry, virtualEntry);
}
}
}
function isDirectorySymlinkTarget(realEntry) {
try {
return node_fs_1.default.statSync(realEntry).isDirectory();
}
catch {
return false;
}
}
/**
* Walk from `projectRoot` upward (up to `MAX_VIRTUAL_PARENT_DEPTH` steps),
* stopping early at a workspace root (`pnpm-workspace.yaml` or `.git`). The
* collected directories are reversed so callers can iterate outermost-first,
* which lets inner symlinks override outer ones without conflicting mkdir
* calls.
*/
function collectLinkDirectories(projectRoot) {
const out = [];
let current = projectRoot;
for (let depth = 0; depth <= MAX_VIRTUAL_PARENT_DEPTH; depth += 1) {
out.push(current);
if (depth > 0 &&
(node_fs_1.default.existsSync(node_path_1.default.join(current, "pnpm-workspace.yaml")) ||
node_fs_1.default.existsSync(node_path_1.default.join(current, ".git")))) {
break;
}
const parent = node_path_1.default.dirname(current);
if (parent === current || isUnsafeVirtualParent(parent)) {
break;
}
current = parent;
}
return out.reverse();
}
function isUnsafeVirtualParent(directory) {
const resolved = node_path_1.default.resolve(directory);
const root = node_path_1.default.parse(resolved).root;
return resolved === root || resolved === node_path_1.default.resolve(node_os_1.default.tmpdir());
}
/**
* Map an absolute path into a stable, filesystem-safe subtree under `root`.
*
* On POSIX the root is always `/`, so every path shares the same prefix —
* represented here as `"posix"`. On Windows, drive letters and UNC roots each
* get a sanitized label (e.g. `"C_"` for `C:\`), preventing collisions between
* paths from different drives inside the same virtual root.
*/
function virtualPath(root, absolute) {
const parsed = node_path_1.default.parse(node_path_1.default.resolve(absolute));
const label = parsed.root === node_path_1.default.sep
? "posix"
: parsed.root.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") ||
"root";
const relative = node_path_1.default.relative(parsed.root, node_path_1.default.resolve(absolute));
return node_path_1.default.join(root, label, relative);
}
//# sourceMappingURL=prepareExecution.js.map