UNPKG

ttsc

Version:

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

529 lines 24.5 kB
"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