ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
645 lines • 26.6 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.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