feat(run): add /ws-pty/:runId PTY transport bridging WS to tmux attach
This commit is contained in:
+82
-11
@@ -59,6 +59,61 @@ function initWebSocket(server) {
|
||||
return wss;
|
||||
}
|
||||
|
||||
let ptyWss = null;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(ccam-lane-\d+)$/;
|
||||
|
||||
/**
|
||||
* Second WebSocket server, dedicated to the terminal-run PTY transport
|
||||
* (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so
|
||||
* raw binary PTY frames never have to coexist with the typed
|
||||
* `{type, data, timestamp}` envelope the rest of the app relies on. Reuses
|
||||
* the exact same auth guard as `/ws`.
|
||||
*
|
||||
* `runId` is a path SEGMENT, not a fixed string, so this can't use the `ws`
|
||||
* library's `{server, path}` shorthand (that option only matches an exact
|
||||
* string). Instead this server is created with `noServer: true` and the
|
||||
* upgrade is handled manually — the same `server.on("upgrade", ...)` pattern
|
||||
* `ws` itself uses internally, just filtered to `/ws-pty/*` first so `/ws`'s
|
||||
* own upgrade handling (already registered by `initWebSocket`) is untouched.
|
||||
*/
|
||||
function initPtyWebSocket(server) {
|
||||
const { attach } = require("./lib/pty-attach");
|
||||
ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
||||
|
||||
ptyWss.on("connection", (ws, runId) => {
|
||||
try {
|
||||
attach(ws, runId, { cols: 80, rows: 24 });
|
||||
} catch (err) {
|
||||
try {
|
||||
ws.close(1008, err.message);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const match = url.pathname.match(PTY_PATH_RE);
|
||||
if (!match) return; // not ours — the `/ws` WebSocketServer (registered
|
||||
// by initWebSocket, also attached to this same http.Server) handles its
|
||||
// own path independently and ignores upgrades it doesn't match too.
|
||||
if (!isHostAllowed(req.headers.host)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!isWebSocketAuthorized(req)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
ptyWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ptyWss.emit("connection", ws, match[1]);
|
||||
});
|
||||
});
|
||||
|
||||
return ptyWss;
|
||||
}
|
||||
|
||||
function broadcast(type, data) {
|
||||
if (!wss) return;
|
||||
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
|
||||
@@ -90,20 +145,36 @@ function getConnectionCount() {
|
||||
* clients first lets the HTTP server drain and close promptly.
|
||||
*/
|
||||
function closeWebSocket() {
|
||||
if (!wss) return;
|
||||
wss.clients.forEach((client) => {
|
||||
if (wss) {
|
||||
wss.clients.forEach((client) => {
|
||||
try {
|
||||
client.terminate();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
});
|
||||
try {
|
||||
client.terminate();
|
||||
wss.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
try {
|
||||
wss.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
wss = null;
|
||||
}
|
||||
if (ptyWss) {
|
||||
ptyWss.clients.forEach((client) => {
|
||||
try {
|
||||
client.terminate();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
});
|
||||
try {
|
||||
ptyWss.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ptyWss = null;
|
||||
}
|
||||
wss = null;
|
||||
}
|
||||
|
||||
module.exports = { initWebSocket, broadcast, getConnectionCount, closeWebSocket };
|
||||
module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket };
|
||||
|
||||
Reference in New Issue
Block a user