feat(run): add /ws-pty/:runId PTY transport bridging WS to tmux attach

This commit is contained in:
2026-08-12 10:13:14 +07:00
parent 1bc237198c
commit 872c698132
4 changed files with 293 additions and 12 deletions
+104
View File
@@ -0,0 +1,104 @@
/**
* @file pty-attach.js
* @description Bridges one WebSocket connection to a `node-pty`-backed
* `tmux attach-session` process. Binary WS frames carry raw PTY bytes in
* both directions; text WS frames carry small JSON control messages
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
* session ends). Multiple browser tabs each get their own PTY attach
* process — tmux itself is what keeps them all in sync, this module does no
* cross-connection coordination.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const RUN_ID_RE = /^ccam-lane-\d+$/;
/**
* Reject anything that isn't exactly `ccam-lane-<digits>` before it can ever
* reach a tmux/PTY command — the trust boundary for this WS path, since a
* validated runId is the only thing standing between an authenticated WS
* client and naming an arbitrary session on the host.
*/
function validateRunId(runId) {
if (typeof runId !== "string" || !RUN_ID_RE.test(runId)) {
const err = new Error(`EBADRUNID: invalid runId: ${runId}`);
err.code = "EBADRUNID";
throw err;
}
}
// Test seam — real implementation set in Step 4.
let spawnImpl = null;
function __setSpawnImpl(fn) {
spawnImpl = fn;
}
/**
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t <runId>` per call.
*/
function attach(ws, runId, { cols, rows }) {
validateRunId(runId);
const pty = spawnImpl("tmux", ["attach-session", "-t", runId], {
name: "xterm-256color",
cols: cols || 80,
rows: rows || 24,
});
pty.onData((data) => {
try {
ws.send(data, { binary: true });
} catch {
/* client gone between data event and send — safe to ignore */
}
});
pty.onExit(({ exitCode }) => {
try {
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
} catch {
/* ignore */
}
try {
ws.close();
} catch {
/* ignore */
}
});
ws.on("message", (data, isBinary) => {
// node-pty attach processes for `tmux attach` are already tolerant of
// resize mid-stream; the `ws` lib passes isBinary either as the second
// callback arg (newer) or via `data.binary` on some transports — this
// helper's own tests exercise the `{binary}` option shape used above.
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
if (binary) {
pty.write(data.toString("utf8"));
return;
}
let msg;
try {
msg = JSON.parse(data.toString("utf8"));
} catch {
return;
}
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
pty.resize(msg.cols, msg.rows);
}
});
ws.on("close", () => {
try {
pty.kill();
} catch {
/* already gone */
}
});
return pty;
}
// Real spawn implementation — lazy-required so unit tests never load the
// native node-pty addon unless they explicitly opt in.
__setSpawnImpl((...args) => require("node-pty").spawn(...args));
module.exports = { attach, validateRunId, __setSpawnImpl };