ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
330 lines • 13.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ResidentTransformProcess = void 0;
const node_child_process_1 = require("node:child_process");
const node_readline_1 = require("node:readline");
/** Cap on retained stderr so a long-lived host cannot grow it without bound. */
const STDERR_TAIL_LIMIT = 64 * 1024;
/** Cap on how much of an offending line an error message echoes back. */
const REPLY_ECHO_LIMIT = 200;
/** Allow a cooperative host a short shutdown window before forcing it down. */
const TERMINATION_GRACE_MS = 1_000;
/**
* Async client for the long-lived `utility-host serve` process.
*
* The host transforms the whole project once at startup, caches every file's
* transformed TypeScript, then answers newline-delimited requests. This class
* speaks that protocol: each {@link request} writes one JSON line and the host
* replies with one line, matched FIFO. A transform request (`{"file":...}`) is
* answered with `{"typescript":...,"found":...}`, and an update request
* (`{"update":...,"content":...}`) with `{"updated":...}`.
*
* One resident process answers every request from one service instead of
* spawning a fresh `transform` subprocess per call, so a single process pays
* the project compile once (samchon/ttsc#255).
*/
class ResidentTransformProcess {
child;
reader;
pending = [];
stderr = "";
failure;
constructor(options) {
// Default stdio is "pipe" for stdin/stdout/stderr, which is exactly what the
// line protocol needs; spelling it out as a string[] would not narrow to
// StdioOptions, so it is left implicit.
this.child = (0, node_child_process_1.spawn)(options.binary, [...options.args], {
cwd: options.cwd,
env: options.env,
windowsHide: true,
});
const stdout = this.child.stdout;
const stdin = this.child.stdin;
if (stdout === null || stdin === null) {
this.child.kill();
throw new Error("ttsc: resident transform host has no stdio pipes");
}
this.reader = (0, node_readline_1.createInterface)({ input: stdout });
this.reader.on("line", (line) => this.onLine(line));
// Drive completion off stdout's end, not the process "exit" event: every
// buffered "line" is delivered before "close", so a reply that arrived just
// before the host exited still resolves its request instead of racing the
// exit-time rejection.
this.reader.on("close", () => this.onReaderClose());
this.child.stderr?.on("data", (chunk) => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
// Keep only the tail: stderr is consulted for the crash message, and a
// long resident session can write diagnostics on every failed update.
this.stderr = (this.stderr + text).slice(-STDERR_TAIL_LIMIT);
});
this.child.on("error", (error) => this.fail(error));
// A dead host's pipes emit "error" (EPIPE on POSIX, often ECONNRESET on
// Windows). Without these listeners Node turns a pipe error into an uncaught
// exception that crashes the whole consumer process; route them through
// fail() (stderr is non-critical) so a pipe death fails in-flight requests
// instead, and also closes the window where stdin.destroyed has not flipped.
stdin.on("error", (error) => this.fail(error));
stdout.on("error", (error) => this.fail(error));
this.child.stderr?.on("error", () => { });
}
/**
* Send one request to the host and resolve with its validated JSON reply. The
* host answers in FIFO order; `kind` tells the client which reply shape the
* payload asks for, so the reply is validated as a well-formed `kind` reply
* before it resolves. Rejects when the reply is not a valid `kind` reply,
* when the host has already failed or exited, or if writing the request
* fails.
*/
request(payload, kind, options = {}) {
if (this.failure !== undefined) {
return Promise.reject(this.failure);
}
if (options.signal?.aborted) {
return Promise.reject(cancelledError(options.signal));
}
const stdin = this.child.stdin;
if (stdin === null || stdin.destroyed) {
return Promise.reject(new Error("ttsc: resident transform host stdin is closed"));
}
let line;
try {
line = `${JSON.stringify(payload)}\n`;
}
catch (error) {
return Promise.reject(asError(error));
}
return new Promise((resolve, reject) => {
let pending;
pending = {
kind,
reject,
resolve,
settled: false,
signal: options.signal,
};
if (options.signal !== undefined) {
pending.abort = () => this.cancel(pending, options.signal);
options.signal.addEventListener("abort", pending.abort, { once: true });
}
this.pending.push(pending);
if (options.signal?.aborted) {
pending.abort();
return;
}
try {
stdin.write(line, (error) => {
if (error === null || error === undefined || pending.settled) {
return;
}
this.settlePending(pending, error);
this.fail(new Error(`ttsc: resident transform host could not write a request: ${error.message}`));
});
}
catch (error) {
if (pending.settled)
return;
const reason = asError(error);
this.settlePending(pending, reason);
this.fail(new Error(`ttsc: resident transform host could not write a request: ${reason.message}`));
}
});
}
/**
* Terminate the resident process and reject any in-flight requests. Safe to
* call more than once.
*/
dispose() {
if (this.failure !== undefined)
return;
// If the host already died, reject with its real exit error (stderr + exit
// code) rather than a bland "disposed" message.
this.fail(this.child.exitCode !== null || this.child.signalCode !== null
? this.exitError()
: new Error("ttsc: resident transform host disposed"));
}
onLine(line) {
if (this.failure !== undefined)
return;
const trimmed = line.trim();
if (trimmed.length === 0) {
return;
}
const request = this.pending[0];
if (request === undefined) {
// The host must emit exactly one reply per request, so an unmatched line
// is a protocol violation that would desync every later reply into the
// wrong request. Fail fast rather than silently returning wrong output.
// A line arriving after teardown (failure already set) is benign.
if (this.failure === undefined) {
this.fail(new Error("ttsc: resident transform host sent an unsolicited reply"));
}
return;
}
const reply = parseReplyObject(trimmed);
if (reply === undefined) {
// Framing violation: the line is not a JSON object, so it cannot
// represent any reply. This request already left the FIFO, so reject it,
// then fail the whole process: a bad line may be corruption that shifted
// the stream, and the host's real reply that follows must not be paired
// with a later request. `fail` marks the failure so that trailing line is
// treated as benign instead of unsolicited.
const error = new Error(`ttsc: resident transform host sent a malformed reply: ${echoLine(trimmed)}`);
this.settlePending(request, error);
this.fail(error);
return;
}
if (!isValidReply(reply, request.kind)) {
// Operation-shape violation: a well-formed JSON object that is not a valid
// reply for the operation this request sent. FIFO framing is intact — one
// line consumed exactly one slot — so only this request is corrupt; later
// replies still pair correctly. Reject just this request instead of
// failing the whole process.
this.settlePending(request, new Error(`ttsc: resident transform host sent an invalid ${request.kind} reply: ${echoLine(trimmed)}`));
return;
}
this.settlePending(request, reply);
}
onReaderClose() {
if (this.failure === undefined)
this.fail(this.exitError());
}
fail(error) {
if (this.failure !== undefined)
return;
this.failure = error;
this.rejectAll(error);
this.terminate();
}
rejectAll(error) {
while (this.pending.length !== 0) {
this.settlePending(this.pending[0], error);
}
}
settlePending(pending, result) {
if (pending.settled)
return;
pending.settled = true;
const index = this.pending.indexOf(pending);
if (index !== -1)
this.pending.splice(index, 1);
if (pending.signal !== undefined && pending.abort !== undefined) {
pending.signal.removeEventListener("abort", pending.abort);
}
if (result instanceof Error)
pending.reject(result);
else
pending.resolve(result);
}
cancel(pending, signal) {
if (pending.settled)
return;
this.settlePending(pending, cancelledError(signal));
this.fail(new Error(`ttsc: resident transform host retired after another request was cancelled${abortDetail(signal)}${stderrSuffix(this.stderr)}`));
}
terminate() {
const stdin = this.child.stdin;
if (stdin !== null && !stdin.destroyed)
stdin.destroy();
if (this.child.exitCode !== null || this.child.signalCode !== null)
return;
try {
this.child.kill();
}
catch {
// The host exited between the liveness check and termination.
return;
}
const force = setTimeout(() => {
if (this.child.exitCode !== null || this.child.signalCode !== null) {
return;
}
try {
this.child.kill("SIGKILL");
}
catch {
// The host exited between the liveness check and the forced signal.
}
}, TERMINATION_GRACE_MS);
force.unref();
this.child.once("exit", () => clearTimeout(force));
}
exitError() {
const detail = this.stderr.trim();
if (detail.length !== 0) {
return new Error(detail);
}
// Read the child's own exit fields (set synchronously by Node) so the
// message is accurate even before this instance's "exit" handler would run.
const code = this.child.exitCode;
const signalCode = this.child.signalCode;
const signal = signalCode === null ? "" : `, signal ${signalCode}`;
return new Error(`ttsc: resident transform host exited (code ${code ?? "null"}${signal})`);
}
}
exports.ResidentTransformProcess = ResidentTransformProcess;
function cancelledError(signal) {
const error = new Error(`ttsc: resident transform request cancelled${abortDetail(signal)}`);
error.name = "AbortError";
return error;
}
function abortDetail(signal) {
const reason = signal.reason;
if (reason === undefined)
return "";
try {
return `: ${reason instanceof Error ? reason.message : String(reason)}`;
}
catch {
return "";
}
}
function stderrSuffix(stderr) {
const detail = stderr.trim();
return detail.length === 0 ? "" : `: ${detail}`;
}
function asError(error) {
return error instanceof Error ? error : new Error(String(error));
}
/**
* Parse one reply line into a plain object, or `undefined` when the line is not
* a JSON object. Invalid JSON, arrays, primitives, and `null` all yield
* `undefined`: none of them can carry a reply's fields, so the caller treats
* them as a framing failure rather than an empty reply. Returning `{}` here
* would let a corrupt line masquerade as a valid negative result.
*/
function parseReplyObject(line) {
let parsed;
try {
parsed = JSON.parse(line);
}
catch {
return undefined;
}
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return undefined;
}
/**
* Whether a parsed reply object is a well-formed reply for the given operation.
* A transform reply must carry a boolean `found`, and when `found` is `true` a
* string `typescript` (a found file always carries its transformed text); when
* `found` is `false` the text is irrelevant. An update reply must carry a
* boolean `updated`. Every other object shape is a protocol error.
*/
function isValidReply(reply, kind) {
if (kind === "transform") {
if (typeof reply.found !== "boolean") {
return false;
}
return reply.found ? typeof reply.typescript === "string" : true;
}
return typeof reply.updated === "boolean";
}
/** Truncate an offending reply line so error messages stay bounded. */
function echoLine(line) {
return line.length > REPLY_ECHO_LIMIT
? `${line.slice(0, REPLY_ECHO_LIMIT)}…`
: line;
}
//# sourceMappingURL=residentTransformProcess.js.map