UNPKG

ttsc

Version:

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

645 lines 26.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.runTtscserver = runTtscserver; exports.materializeLSPPluginManifest = materializeLSPPluginManifest; exports.fingerprintInitialLSPProjectInputSnapshot = fingerprintInitialLSPProjectInputSnapshot; exports.initialLSPProjectInputSnapshotIsCurrent = initialLSPProjectInputSnapshotIsCurrent; exports.needsStdio = needsStdio; const node_child_process_1 = require("node:child_process"); const node_crypto_1 = require("node:crypto"); 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 createNativeProjectContextArgs_1 = require("../../compiler/internal/project/createNativeProjectContextArgs"); const readProjectConfig_1 = require("../../compiler/internal/project/readProjectConfig"); const resolveBinary_1 = require("../../compiler/internal/resolveBinary"); const resolveTsgo_1 = require("../../compiler/internal/resolveTsgo"); const spawnNative_1 = require("../../compiler/internal/spawnNative"); const projectInputPathIdentity_1 = require("../../internal/projectInputPathIdentity"); const loadProjectPlugins_1 = require("../../plugin/internal/loadProjectPlugins"); const resolveTtscserverBinary_1 = require("./resolveTtscserverBinary"); const LSP_SELECTION_STABILITY_ATTEMPTS = 3; /** * Drive the ttscserver native binary from a node launcher. The launcher is * deliberately thin: argument parsing, version banners, and help text are owned * by the Go binary so future flags only need to change one layer. The JS side * performs the Node-owned setup that depends on package resolution: * * - Resolve the platform binary, * - Resolve the project TypeScript-Go binary for the native wrapper, * - Resolve the project config and materialize the private LSP plugin manifest, * - Inject the Node/ttsx helper paths used by disk-backed LSP sidecars, * - Inject `--stdio` when the first arg is not a meta-command, * - Delegate to the binary with inherited stdio so OS-level signals reach the * child via the parent's process group. */ function runTtscserver(argv = process.argv.slice(2)) { const binary = (0, resolveTtscserverBinary_1.resolveTtscserverBinary)(); if (!binary) { process.stderr.write([ `ttscserver: platform-specific binary not found (@ttsc/${process.platform}-${process.arch}).`, `Set TTSCSERVER_BINARY to an absolute path or reinstall ttsc with optional dependencies enabled.`, ].join("\n") + "\n"); return 1; } ensureExecutable(binary); const args = needsStdio(argv) ? ["--stdio", ...argv] : [...argv]; let execution; try { execution = resolveTtscserverEnv(args); } catch (error) { process.stderr.write(`ttscserver: ${stripTtscPrefix(formatError(error))}\n`); return 1; } let result; try { result = (0, node_child_process_1.spawnSync)(binary, [...execution.args, ...args], { stdio: "inherit", env: execution.env, windowsHide: true, }); } finally { execution.dispose(); } if (result.error) { process.stderr.write(`ttscserver: ${result.error.message}\n`); return 1; } if (result.signal) { // POSIX convention: 128 + signum so wrappers (bash, npm-script, CI) // can decode the signal that killed the child (130 = SIGINT, 143 = // SIGTERM, etc.). On Windows, `spawnSync` does not surface a signal // (TerminateProcess carries no signum) so this branch is POSIX-only // by design; Windows-killed children take the `result.status ?? 1` // path below. const signum = node_os_1.default.constants.signals[result.signal]; return typeof signum === "number" ? 128 + signum : 1; } return result.status ?? 1; } /** * Build the environment for the native binary. In `--stdio` (LSP) mode the Go * binary needs the project tsgo binary plus any LSP-capable plugin sidecars the * JS loader resolved from config. Pass the manifest through a private temporary * file and inject canonical helper paths so the native host and every later * sidecar refresh use the same launch context. */ function resolveTtscserverEnv(argv) { if (!argv.includes("--stdio")) { // Non-LSP invocations (--version, --help) do not shell out to tsgo. return { args: [], dispose() { }, env: process.env }; } const context = resolveLspExecutionContext(argv); const env = lspSidecarEnvironment({ pluginConfigOrigin: context.projectContext?.pluginConfigOrigin, tsgoBinary: context.tsgoBinary, }); delete env.TTSC_LSP_PLUGINS_JSON; delete env.TTSC_LSP_PLUGINS_FILE; const lspPlugins = context.nativePlugins.filter((plugin) => plugin.capabilities?.lsp === true); if (lspPlugins.length === 0) { // Nothing would be lost by an older native host, so keep it startable. return { args: [], dispose() { }, env }; } const transport = materializeLSPPluginManifest({ initialProjectInputs: Object.fromEntries(context.initialProjectInputs), plugins: serializeNativePlugins(context.nativePlugins), projectContext: context.projectContext, lspPlugins: lspPlugins.map((plugin) => ({ binary: plugin.binary, ...(plugin.capabilities?.projectInputs === true ? { initialProjectInputKey: lspPluginTransportKey(plugin) } : {}), name: plugin.name, projectDiagnostics: plugin.capabilities?.projectDiagnostics === true, projectInputs: plugin.capabilities?.projectInputs === true, projectContextArgs: plugin.capabilities?.projectContextArgs === true, stage: plugin.stage, })), }); // Deliver the manifest as an explicit flag rather than an inherited variable. // A native host that predates the flag rejects the invocation instead of // starting without the plugins this project declared, and nothing downstream // of the host inherits either a path to the manifest or its payload. return { args: ["--lsp-plugins-file", transport.path], dispose: transport.dispose, env, }; } function materializeLSPPluginManifest(manifest) { const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "ttsc-lsp-")); const location = node_path_1.default.join(directory, "plugins.json"); try { node_fs_1.default.writeFileSync(location, JSON.stringify(manifest), { encoding: "utf8", flag: "wx", mode: 0o600, }); } catch (error) { node_fs_1.default.rmSync(directory, { force: true, recursive: true }); throw error; } let disposed = false; return { dispose() { if (disposed) return; disposed = true; node_fs_1.default.rmSync(directory, { force: true, recursive: true }); }, path: location, }; } function lspSidecarEnvironment(options) { const env = { ...process.env, TTSC_NODE_BINARY: process.env.TTSC_NODE_BINARY ?? process.execPath, TTSC_TSGO_BINARY: options.tsgoBinary, TTSC_TTSX_BINARY: process.env.TTSC_TTSX_BINARY ?? node_path_1.default.join(__dirname, "..", "..", "launcher", "ttsx.js"), }; if (options.pluginConfigOrigin === undefined) { delete env.TTSC_PLUGIN_CONFIG_DIR; } else { env.TTSC_PLUGIN_CONFIG_DIR = options.pluginConfigOrigin; } return env; } function resolveLspExecutionContext(argv) { const cwd = node_path_1.default.resolve(optionValue(argv, "--cwd") ?? process.cwd()); const tsconfig = optionValue(argv, "--tsconfig"); const pluginConfigOrigin = process.env.TTSC_PLUGIN_CONFIG_DIR === undefined || process.env.TTSC_PLUGIN_CONFIG_DIR === "" ? undefined : node_path_1.default.resolve(cwd, process.env.TTSC_PLUGIN_CONFIG_DIR); let initialProject; try { initialProject = (0, readProjectConfig_1.readProjectConfig)({ cwd, tsconfig }); } catch (error) { if (tsconfig) { throw error; } const tsgo = (0, resolveTsgo_1.resolveTsgo)({ binary: optionValue(argv, "--tsgo"), cwd, resolveFrom: __filename, }); return { initialProjectInputs: new Map(), nativePlugins: [], tsgoBinary: tsgo.binary, }; } let project = initialProject; for (let attempt = 1; attempt <= LSP_SELECTION_STABILITY_ATTEMPTS; attempt++) { const loaded = loadLSPProjectPlugins(project, cwd, pluginConfigOrigin); const selectedProject = loaded.project; const tsgo = (0, resolveTsgo_1.resolveTsgo)({ binary: optionValue(argv, "--tsgo"), cwd: selectedProject.root, resolveFrom: __filename, }); const initialProjectInputs = captureInitialLSPProjectInputs({ nativePlugins: loaded.nativePlugins, pluginConfigOrigin, project: selectedProject, tsgoBinary: tsgo.binary, }); const confirmationProject = (0, readProjectConfig_1.readProjectConfig)({ cwd, tsconfig }); const confirmation = loadLSPProjectPlugins(confirmationProject, cwd, pluginConfigOrigin); const confirmedProject = confirmation.project; const confirmedTsgo = (0, resolveTsgo_1.resolveTsgo)({ binary: optionValue(argv, "--tsgo"), cwd: confirmedProject.root, resolveFrom: __filename, }); const confirmedProjectInputs = captureInitialLSPProjectInputs({ nativePlugins: confirmation.nativePlugins, pluginConfigOrigin, project: confirmedProject, tsgoBinary: confirmedTsgo.binary, }); if (lspSelectionSignature(selectedProject, loaded.nativePlugins) === lspSelectionSignature(confirmedProject, confirmation.nativePlugins) && initialLSPProjectInputsEqual(initialProjectInputs, confirmedProjectInputs) && [...confirmedProjectInputs.values()].every(initialLSPProjectInputSnapshotIsCurrent)) { return { initialProjectInputs: confirmedProjectInputs, nativePlugins: confirmation.nativePlugins, projectContext: { ...confirmedProject.identity, ...(pluginConfigOrigin === undefined ? {} : { pluginConfigOrigin }), }, tsgoBinary: confirmedTsgo.binary, }; } project = confirmedProject; } throw new Error(`ttscserver: project plugin selection remained unstable across ${LSP_SELECTION_STABILITY_ATTEMPTS} bounded startup attempts`); } function loadLSPProjectPlugins(project, cwd, pluginConfigOrigin) { return (0, loadProjectPlugins_1.hasProjectPluginEntries)(project) ? (0, loadProjectPlugins_1.loadProjectPlugins)({ binary: (0, resolveBinary_1.resolveBinary)() ?? "", cwd, pluginConfigDir: pluginConfigOrigin, tsconfig: project.identity.logicalConfigPath, }) : { nativePlugins: [], project }; } function captureInitialLSPProjectInputs(options) { const snapshots = new Map(); const pluginsJSON = JSON.stringify(serializeNativePlugins(options.nativePlugins)); for (const plugin of options.nativePlugins) { if (plugin.capabilities?.lsp !== true || plugin.capabilities.projectInputs !== true) { continue; } const transportKey = lspPluginTransportKey(plugin); if (snapshots.has(transportKey)) continue; const args = [ "project-inputs", "--tsconfig=" + options.project.path, "--plugins-json=" + pluginsJSON, "--cwd=" + options.project.root, ]; if (plugin.capabilities.projectContextArgs === true) { args.push(...(0, createNativeProjectContextArgs_1.createNativeProjectContextArgs)(options.project, options.pluginConfigOrigin)); } const env = lspSidecarEnvironment({ pluginConfigOrigin: options.pluginConfigOrigin, tsgoBinary: options.tsgoBinary, }); const result = (0, spawnNative_1.spawnNative)(plugin.binary, args, { cwd: options.project.root, env, }); if (result.error) { throw new Error(`ttscserver: ${plugin.name ?? plugin.binary} project-inputs failed: ${result.error.message}`); } const stdout = (0, spawnNative_1.outputText)(result.stdout).trim(); if (result.status !== 0) { const detail = (0, spawnNative_1.outputText)(result.stderr).trim() || stdout; throw new Error(`ttscserver: ${plugin.name ?? plugin.binary} project-inputs failed${detail ? `: ${detail}` : ""}`); } snapshots.set(transportKey, fingerprintInitialLSPProjectInputSnapshot(parseInitialLSPProjectInputSnapshot(stdout, plugin))); } return snapshots; } function lspPluginTransportKey(plugin) { return (plugin.binary + "\0" + (plugin.capabilities?.projectContextArgs === true ? "1" : "0")); } function initialLSPProjectInputsEqual(left, right) { if (left.size !== right.size) return false; for (const [key, leftSnapshot] of left) { const rightSnapshot = right.get(key); if (rightSnapshot === undefined || initialLSPProjectInputSnapshotSignature(leftSnapshot) !== initialLSPProjectInputSnapshotSignature(rightSnapshot)) { return false; } } return true; } function initialLSPProjectInputSnapshotSignature(snapshot) { const sorted = (values) => [...(values ?? [])].sort(); const reloadDirectories = sorted(snapshot.reloadDirectories); const reloadFiles = sorted(snapshot.reloadFiles); return JSON.stringify({ files: sorted(snapshot.files), globs: sorted(snapshot.globs), reloadDirectories: reloadDirectories.map((directory) => [ directory, snapshot.reloadDirectoryDigests[directory], ]), reloadFiles: reloadFiles.map((file) => [ file, snapshot.reloadFileDigests[file], ]), root: snapshot.root, }); } function parseInitialLSPProjectInputSnapshot(text, plugin) { let value; try { value = JSON.parse(text); } catch (error) { throw new Error(`ttscserver: ${plugin.name ?? plugin.binary} project-inputs returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`); } if (value === null || typeof value !== "object" || typeof value.root !== "string" || !Array.isArray(value.files) || !Array.isArray(value.globs) || (value.reloadFiles !== undefined && !Array.isArray(value.reloadFiles)) || (value.reloadDirectories !== undefined && !Array.isArray(value.reloadDirectories))) { throw new Error(`ttscserver: ${plugin.name ?? plugin.binary} project-inputs returned a malformed snapshot`); } return value; } function fingerprintInitialLSPProjectInputSnapshot(snapshot) { const reloadDirectoryDigests = {}; const reloadFileDigests = {}; for (const directory of snapshot.reloadDirectories ?? []) { reloadDirectoryDigests[directory] = lspProjectInputReloadDirectoryDigest(directory); } for (const file of snapshot.reloadFiles ?? []) { reloadFileDigests[file] = lspProjectInputFileDigest(realLSPProjectInputEntryPath(file)); } return { ...snapshot, reloadDirectoryDigests, reloadFileDigests, }; } function initialLSPProjectInputSnapshotIsCurrent(snapshot) { return ((snapshot.reloadDirectories ?? []).every((directory) => snapshot.reloadDirectoryDigests[directory] === lspProjectInputReloadDirectoryDigest(directory)) && (snapshot.reloadFiles ?? []).every((file) => snapshot.reloadFileDigests[file] === lspProjectInputFileDigest(realLSPProjectInputEntryPath(file)))); } function lspSelectionSignature(project, plugins) { return JSON.stringify({ identity: project.identity, plugins: plugins.map((plugin) => ({ binary: plugin.binary, capabilities: plugin.capabilities, config: plugin.config, contributors: plugin.contributors, kind: plugin.kind, name: plugin.name, source: plugin.source, stage: plugin.stage, })), }); } function lspProjectInputReloadDirectoryDigest(location) { const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)(); const identity = lspProjectInputPhysicalPathIdentity(location, identities); return (0, node_crypto_1.createHash)("sha256") .update(Buffer.concat([ Buffer.from("directory\0"), identity, Buffer.from([0]), Buffer.from(lspProjectInputDirectoryTopologyDigest(process.platform === "win32" ? identities.resolve(location).path : identity)), ])) .digest("hex"); } function lspProjectInputPhysicalPathIdentity(location, identities) { if (process.platform === "win32") { return Buffer.from(identities.resolve(location).path.replaceAll("\\", "/"), "utf8"); } let existing = node_path_1.default.resolve(location); const missing = []; while (true) { try { const realpath = node_fs_1.default.realpathSync.native ?? node_fs_1.default.realpathSync; let physical = realpath(Buffer.from(existing), { encoding: "buffer", }); for (const segment of missing) { physical = Buffer.concat([ physical, physical.at(-1) === 0x2f ? Buffer.alloc(0) : Buffer.from("/"), segment, ]); } return physical; } catch (error) { if (error instanceof Error && "code" in error && error.code !== "ENOENT" && error.code !== "ENOTDIR") { throw error; } const parent = node_path_1.default.dirname(existing); if (parent === existing) return Buffer.from(existing); missing.unshift(Buffer.from(node_path_1.default.basename(existing))); existing = parent; } } } function lspProjectInputDirectoryTopologyDigest(location) { const entries = []; try { if (process.platform === "win32") { const directory = typeof location === "string" ? location : location.toString("utf8"); for (const entry of node_fs_1.default.readdirSync(directory, { withFileTypes: true })) { let target = Buffer.alloc(0); if (entry.isSymbolicLink()) { try { target = Buffer.from(node_fs_1.default.readlinkSync(node_path_1.default.join(directory, entry.name)), "utf8"); } catch { target = Buffer.from("<unreadable>"); } } entries.push(lspProjectInputDirectoryRecord(Buffer.from(entry.name), entry, target)); } } else { for (const entry of node_fs_1.default.readdirSync(location, { encoding: "buffer", withFileTypes: true, })) { let target = Buffer.alloc(0); if (entry.isSymbolicLink()) { try { target = node_fs_1.default.readlinkSync(Buffer.concat([ Buffer.isBuffer(location) ? location : Buffer.from(location), Buffer.from(node_path_1.default.sep), entry.name, ]), { encoding: "buffer" }); } catch { target = Buffer.from("<unreadable>"); } } entries.push(lspProjectInputDirectoryRecord(entry.name, entry, target)); } } } catch { return (0, node_crypto_1.createHash)("sha256").update("missing\0").digest("hex"); } entries.sort(Buffer.compare); const serialized = Buffer.concat(entries.flatMap((entry, index) => index === 0 ? [entry] : [Buffer.from([0]), entry])); return (0, node_crypto_1.createHash)("sha256").update(serialized).digest("hex"); } function lspProjectInputDirectoryRecord(name, entry, target) { const kind = entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other"; return Buffer.concat([name, Buffer.from("\0" + kind + "\0"), target]); } function lspProjectInputFileDigest(location) { try { const info = node_fs_1.default.lstatSync(location); if (info.isSymbolicLink()) { let target = Buffer.from("<unreadable>"); try { target = node_fs_1.default.readlinkSync(Buffer.from(location), { encoding: "buffer", }); } catch { // Preserve the same explicit unreadable state as the Go validator. } let content = Buffer.from("missing\0"); try { content = Buffer.concat([ Buffer.from("file\0"), node_fs_1.default.readFileSync(location), ]); } catch { // A dangling or unreadable target remains part of the symlink state. } return (0, node_crypto_1.createHash)("sha256") .update(Buffer.concat([ Buffer.from("symlink\0"), target, Buffer.from([0]), content, ])) .digest("hex"); } if (info.isFile()) { return (0, node_crypto_1.createHash)("sha256") .update(Buffer.concat([Buffer.from("file\0"), node_fs_1.default.readFileSync(location)])) .digest("hex"); } return (0, node_crypto_1.createHash)("sha256").update("other\0").digest("hex"); } catch { return (0, node_crypto_1.createHash)("sha256").update("missing\0").digest("hex"); } } /** * Physical path of a location whose leaf may not exist yet. * * Both ends go through the shared spelling rule rather than `path.resolve` * alone. Windows hands back extended-length paths from a native realpath, and a * record written as `\?\C:\project` never matches a lookup for `C:\project` * even though one file is meant — the split identity every other consumer on * this branch was taught to avoid. */ function realLSPProjectInputPath(location) { const absolute = (0, projectInputPathIdentity_1.resolveProjectInputPath)(location); let probe = absolute; const suffix = []; for (;;) { try { let resolved = (0, projectInputPathIdentity_1.resolveProjectInputPath)(node_fs_1.default.realpathSync.native(probe)); for (let index = suffix.length - 1; index >= 0; index--) { resolved = node_path_1.default.join(resolved, suffix[index]); } return node_path_1.default.normalize(resolved); } catch { const parent = node_path_1.default.dirname(probe); if (parent === probe) return node_path_1.default.normalize(absolute); suffix.push(node_path_1.default.basename(probe)); probe = parent; } } } function realLSPProjectInputEntryPath(location) { const absolute = (0, projectInputPathIdentity_1.resolveProjectInputPath)(location); return node_path_1.default.join(realLSPProjectInputPath(node_path_1.default.dirname(absolute)), node_path_1.default.basename(absolute)); } function optionValue(argv, name) { for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === name) { return argv[i + 1]; } if (arg.startsWith(name + "=")) { return arg.slice(name.length + 1); } } return undefined; } function serializeNativePlugins(plugins) { return plugins.map((plugin) => ({ config: plugin.config, name: plugin.name, stage: plugin.stage, })); } function formatError(error) { return error instanceof Error ? error.message : String(error); } function stripTtscPrefix(message) { return message.startsWith("ttsc: ") ? message.slice("ttsc: ".length) : message; } /** * `--stdio` is the only transport the native host accepts today. The launcher * injects it only when the first argv token looks like a forwarded option; * meta-commands (`-v`, `--help`, `version`, etc.) pass through untouched so the * Go binary owns the canonical banner. This mirrors * `cmd/ttscserver/main.go::run`, which dispatches on `args[0]` only. */ function needsStdio(argv) { if (argv.length === 0) return false; if (argv.includes("--stdio")) return false; const head = argv[0]; if (head === "-v" || head === "--version" || head === "version" || head === "-h" || head === "--help" || head === "help") { return false; } return true; } /** Mirror the ttsc helper-binary chmod hint so first-run from npm works. */ function ensureExecutable(binary) { if (process.platform === "win32") return; try { node_fs_1.default.accessSync(binary, node_fs_1.default.constants.X_OK); return; } catch { try { const mode = node_fs_1.default.statSync(binary).mode & 0o777; node_fs_1.default.chmodSync(binary, mode | 0o755); } catch { /* spawn will surface the underlying error */ } } } //# sourceMappingURL=runTtscserver.js.map