UNPKG

ttsc

Version:

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

605 lines (584 loc) 23.1 kB
import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isOutsideRelativePath } from "../../compiler/internal/paths"; import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig"; import { resolveEmittedJavaScript } from "../../compiler/internal/resolveEmittedJavaScript"; import { runBuild } from "../../compiler/internal/runBuild"; import { createFilesystemPathIdentityContext } from "../../internal/projectInputPathIdentity"; import type { TtscCommonOptions } from "../../structures/internal/TtscCommonOptions"; import { type OwningModuleOptions, projectModuleOptions } from "./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`. */ export function prepareExecution( entryFile: string, options: TtscCommonOptions & { cacheDir?: string; project?: string; } = {}, ): { cleanupDir: string; emitDir: string; emittedFiles?: readonly string[]; entryFile: string; moduleOptions: OwningModuleOptions; projectRoot: string; rootDir: string; } { // 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 = path.resolve(options.cwd ?? process.cwd()); const entry = resolveEntrySpelling(cwd, entryFile); const context = createProjectContext( cwd, path.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: ReturnType<typeof createProjectContext>, entry: string, ): string | null { const relative = path.relative(context.runtimeRootDir, entry); if (relative === "" || isOutsideRelativePath(relative)) { return null; } return 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: string, entryFile: string): string { const identities = createFilesystemPathIdentityContext({ throwOnRealpathError: false, }); return identities.resolve(path.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: string, discoveryFile: string, entry: string, options: NonNullable<Parameters<typeof prepareExecution>[1]>, ) { const project = readProjectConfig( options.project ? { cwd, projectRoot: options.projectRoot, tsconfig: path.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 ?? path.join(root, "node_modules", ".cache", "ttsc", "ttsx"); const processDir = path.join(cacheDir, "project", PROCESS_CACHE_KEY); const virtualRoot = path.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: 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 as string[] | 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: ReturnType<typeof readProjectConfig>, entry: string, ): string { const rootDir = project.compilerOptions.rootDir; if (typeof rootDir !== "string") { return path.dirname(entry); } const identities = createFilesystemPathIdentityContext({ throwOnRealpathError: false, }); return identities.resolve( path.isAbsolute(rootDir) ? rootDir : path.resolve(project.root, rootDir), ).path; } function buildProject( context: ReturnType<typeof createProjectContext>, options: NonNullable<Parameters<typeof prepareExecution>[1]>, ): void { if (context.built) return; fs.mkdirSync(context.cacheDir, { recursive: true }); fs.rmSync(context.processDir, { recursive: true, force: true }); fs.mkdirSync(path.dirname(context.emitDir), { recursive: true }); const result = 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: ReturnType<typeof createProjectContext>, options: NonNullable<Parameters<typeof prepareExecution>[1]>, entry: string, ): void { // `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(path.dirname(entry), context.root); const tsconfig = path.join( context.root, `.ttsx-entry.${PROCESS_CACHE_KEY}.tsconfig.json`, ); fs.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 = readProjectConfig({ cwd: context.root, projectRoot: options.projectRoot, tsconfig, }); const emitDir = path.join(context.virtualRoot, ENTRY_PROJECT_EMIT_DIR); fs.mkdirSync(emitDir, { recursive: true }); const result = 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 = projectModuleOptions(project.compilerOptions); context.emittedFiles = result.emittedFiles && result.emittedFiles.length !== 0 ? result.emittedFiles : undefined; } finally { try { fs.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: string, right: string): string { const identities = createFilesystemPathIdentityContext({ throwOnRealpathError: false, }); const from = identities.resolve(path.resolve(left)).path; const target = identities.resolve(path.resolve(right)).path; let current = from; for (;;) { if (identities.isWithin(current, target)) { return current; } const parent = path.dirname(current); if (parent === current) { return from; } current = parent; } } function removeRuntimeOutput(directory: string): void { try { fs.rmSync(directory, { recursive: true, force: true }); } catch { // Best effort: cleanup must not hide the original preparation failure. } } function resolveCacheDir(cwd: string, cacheDir?: string): string | undefined { if (!cacheDir) { return undefined; } return path.isAbsolute(cacheDir) ? cacheDir : path.resolve(cwd, cacheDir); } function linkVirtualProjectLayout( context: ReturnType<typeof createProjectContext>, ): void { for (const directory of collectLinkDirectories(context.root)) { const virtualDirectory = virtualPath(context.virtualRoot, directory); fs.mkdirSync(virtualDirectory, { recursive: true }); for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const realEntry = path.join(directory, entry.name); const virtualEntry = path.join(virtualDirectory, entry.name); if (fs.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). export function linkVirtualEntry( realEntry: string, virtualEntry: string, entry: fs.Dirent, ): void { if (entry.isDirectory()) { // Use junction points on Windows; plain symlinks elsewhere. fs.symlinkSync( realEntry, virtualEntry, process.platform === "win32" ? "junction" : undefined, ); return; } if (entry.isFile()) { try { // Hard-link first: cheap, preserves inode, no extra disk usage. fs.linkSync(realEntry, virtualEntry); } catch { // Cross-device or unsupported filesystem: fall back to a full copy. fs.copyFileSync(realEntry, virtualEntry); } return; } if ( process.platform === "win32" && entry.isSymbolicLink() && isDirectorySymlinkTarget(realEntry) ) { fs.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 { fs.symlinkSync(realEntry, virtualEntry); } catch { if (!fs.existsSync(realEntry)) { return; } try { fs.linkSync(realEntry, virtualEntry); } catch { fs.copyFileSync(realEntry, virtualEntry); } } } function isDirectorySymlinkTarget(realEntry: string): boolean { try { return fs.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: string): string[] { const out: string[] = []; let current = projectRoot; for (let depth = 0; depth <= MAX_VIRTUAL_PARENT_DEPTH; depth += 1) { out.push(current); if ( depth > 0 && (fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || fs.existsSync(path.join(current, ".git"))) ) { break; } const parent = path.dirname(current); if (parent === current || isUnsafeVirtualParent(parent)) { break; } current = parent; } return out.reverse(); } function isUnsafeVirtualParent(directory: string): boolean { const resolved = path.resolve(directory); const root = path.parse(resolved).root; return resolved === root || resolved === path.resolve(os.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: string, absolute: string): string { const parsed = path.parse(path.resolve(absolute)); const label = parsed.root === path.sep ? "posix" : parsed.root.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "root"; const relative = path.relative(parsed.root, path.resolve(absolute)); return path.join(root, label, relative); }