@timqi/pier 0.0.21 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/dist/agent/config-sync.js +124 -92
- package/dist/boards/boards.js +6 -2
- package/dist/db.js +12 -3
- package/dist/main.js +14 -14
- package/dist/service.js +17 -7
- package/dist/settings.js +0 -26
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +4 -0
- package/dist/tasks/groups.js +2 -1
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/tool.js +70 -13
- package/dist/web/auth.js +1 -22
- package/dist/web/fs.js +2 -2
- package/dist/web/instance.js +6 -12
- package/dist/web/public/assets/activity-atEIVnGA.js +5 -0
- package/dist/web/public/assets/activity-atEIVnGA.js.br +0 -0
- package/dist/web/public/assets/activity-atEIVnGA.js.gz +0 -0
- package/dist/web/public/assets/{boards-DMVbV84c.js → boards-DSkfMEvN.js} +1 -1
- package/dist/web/public/assets/boards-DSkfMEvN.js.br +0 -0
- package/dist/web/public/assets/boards-DSkfMEvN.js.gz +0 -0
- package/dist/web/public/assets/{code-C86xKk2k.js → code-1nUOCxAb.js} +1 -1
- package/dist/web/public/assets/explorer-CS1kLPMl.js +5 -0
- package/dist/web/public/assets/explorer-CS1kLPMl.js.br +0 -0
- package/dist/web/public/assets/explorer-CS1kLPMl.js.gz +0 -0
- package/dist/web/public/assets/index-DP1-BW-q.css +2 -0
- package/dist/web/public/assets/index-DP1-BW-q.css.br +0 -0
- package/dist/web/public/assets/index-DP1-BW-q.css.gz +0 -0
- package/dist/web/public/assets/index-a7A8xfoi.js +83 -0
- package/dist/web/public/assets/index-a7A8xfoi.js.br +0 -0
- package/dist/web/public/assets/index-a7A8xfoi.js.gz +0 -0
- package/dist/web/public/assets/runs-bHSlJwXk.js +1 -0
- package/dist/web/public/assets/runs-bHSlJwXk.js.br +0 -0
- package/dist/web/public/assets/runs-bHSlJwXk.js.gz +0 -0
- package/dist/web/public/assets/settings-CrC-yuXh.js +6 -0
- package/dist/web/public/assets/settings-CrC-yuXh.js.br +0 -0
- package/dist/web/public/assets/settings-CrC-yuXh.js.gz +0 -0
- package/dist/web/public/assets/task-runs-C2PHAhAf.js +3 -0
- package/dist/web/public/assets/task-runs-C2PHAhAf.js.br +0 -0
- package/dist/web/public/assets/task-runs-C2PHAhAf.js.gz +0 -0
- package/dist/web/public/assets/tasks-7S90iYr1.js +4 -0
- package/dist/web/public/assets/tasks-7S90iYr1.js.br +0 -0
- package/dist/web/public/assets/tasks-7S90iYr1.js.gz +0 -0
- package/dist/web/public/index.html +16 -24
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/server.js +4 -4
- package/docs/deploy.md +23 -15
- package/package.json +2 -9
- package/skills/pier-tasks/SKILL.md +24 -13
- package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/web/public/assets/activity-Cw0LRVNI.js +0 -5
- package/dist/web/public/assets/activity-Cw0LRVNI.js.br +0 -0
- package/dist/web/public/assets/activity-Cw0LRVNI.js.gz +0 -0
- package/dist/web/public/assets/boards-DMVbV84c.js.br +0 -0
- package/dist/web/public/assets/boards-DMVbV84c.js.gz +0 -0
- package/dist/web/public/assets/explorer-DTvsy5Rs.js +0 -5
- package/dist/web/public/assets/explorer-DTvsy5Rs.js.br +0 -0
- package/dist/web/public/assets/explorer-DTvsy5Rs.js.gz +0 -0
- package/dist/web/public/assets/ghostty-web-CvSZragH.js +0 -13
- package/dist/web/public/assets/ghostty-web-CvSZragH.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-CvSZragH.js.gz +0 -0
- package/dist/web/public/assets/index-DHxoYoeq.js +0 -79
- package/dist/web/public/assets/index-DHxoYoeq.js.br +0 -0
- package/dist/web/public/assets/index-DHxoYoeq.js.gz +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css +0 -2
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/runs-jY40JBP-.js +0 -1
- package/dist/web/public/assets/runs-jY40JBP-.js.br +0 -0
- package/dist/web/public/assets/runs-jY40JBP-.js.gz +0 -0
- package/dist/web/public/assets/settings-Dux7PIKF.js +0 -6
- package/dist/web/public/assets/settings-Dux7PIKF.js.br +0 -0
- package/dist/web/public/assets/settings-Dux7PIKF.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DKvtBVxS.js +0 -3
- package/dist/web/public/assets/task-runs-DKvtBVxS.js.br +0 -0
- package/dist/web/public/assets/task-runs-DKvtBVxS.js.gz +0 -0
- package/dist/web/public/assets/tasks-CqfrOVIt.js +0 -4
- package/dist/web/public/assets/tasks-CqfrOVIt.js.br +0 -0
- package/dist/web/public/assets/tasks-CqfrOVIt.js.gz +0 -0
- package/dist/web/public/assets/terminal-DL_0yEy7.js +0 -2
- package/dist/web/public/assets/terminal-DL_0yEy7.js.br +0 -0
- package/dist/web/public/assets/terminal-DL_0yEy7.js.gz +0 -0
- package/dist/web/terminal.js +0 -369
package/dist/web/terminal.js
DELETED
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
// Terminal backend: one shell pty per project cwd, mirrored to every attached
|
|
2
|
-
// WebSocket. The pty outlives the page — closing a tab only detaches, and a
|
|
3
|
-
// later attach replays the recent output ring; every attached page sees the
|
|
4
|
-
// same shell, and any of them may type (last resize wins). Detached for an
|
|
5
|
-
// hour → the shell is reaped; a Pier restart kills every pty (run tmux inside
|
|
6
|
-
// to survive that — durability is tmux's job, not ours). This is the one
|
|
7
|
-
// WebSocket surface: a keystroke is a round trip, which SSE cannot carry
|
|
8
|
-
// upstream. Everything else stays on the event stream.
|
|
9
|
-
import { spawn } from "node-pty";
|
|
10
|
-
import { WebSocketServer } from "ws";
|
|
11
|
-
import { logger } from "../log.js";
|
|
12
|
-
import { scoped } from "./fs.js";
|
|
13
|
-
import { ALL, upgradeAuthorized } from "./auth.js";
|
|
14
|
-
const log = logger("terminal");
|
|
15
|
-
/** Live shells at once — a guard against forgotten spawns, not a quota. */
|
|
16
|
-
const MAX_TERMS = 8;
|
|
17
|
-
/** Recent output kept for reattach. Scrollback beyond it lives in the page. */
|
|
18
|
-
const RING_BYTES = 1024 * 1024;
|
|
19
|
-
const IDLE_MS = 60 * 60_000;
|
|
20
|
-
const SWEEP_MS = 60_000;
|
|
21
|
-
const HEARTBEAT_MS = 30_000;
|
|
22
|
-
/** A slow mirror is detached before its send queue grows without bound; a
|
|
23
|
-
* healthy page must never be paused behind it. Reattach replays the ring. */
|
|
24
|
-
const HIGH_WATER = 4 * 1024 * 1024;
|
|
25
|
-
const MAX_FRAME_BYTES = 1024 * 1024;
|
|
26
|
-
// A Web Terminal is a new local tty, not the tmux/SSH client Pier happened to
|
|
27
|
-
// start under. Inheriting these can attach the shell back into Pier's parent
|
|
28
|
-
// tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
|
|
29
|
-
const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
|
|
30
|
-
// Pier's own configuration is Pier's, not this shell's. `NODE_ENV=production`
|
|
31
|
-
// alone turns an `npm i` typed here into an install with no dev dependencies,
|
|
32
|
-
// and every `PI_*`/`PIER_*` would point a `pi` started here at Pier's own
|
|
33
|
-
// instance rather than the person's. Everything else is inherited on purpose:
|
|
34
|
-
// PATH, LANG, SSH_AUTH_SOCK and the session's XDG/DBUS handles are what make
|
|
35
|
-
// the shell usable, and on a service-managed instance nothing else supplies
|
|
36
|
-
// them.
|
|
37
|
-
const PIER_OWN_ENV = ["NODE_ENV", "PORT", "HOST"];
|
|
38
|
-
const PIER_OWN_PREFIX = /^PI(ER)?_/;
|
|
39
|
-
const send = (sock, data) => {
|
|
40
|
-
try {
|
|
41
|
-
sock.send(data);
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
catch (err) {
|
|
45
|
-
log.warn(`terminal socket send failed: ${String(err)}`);
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
const control = (sock, msg) => send(sock, JSON.stringify(msg));
|
|
50
|
-
export class TerminalHub {
|
|
51
|
-
#terms = new Map();
|
|
52
|
-
#shell;
|
|
53
|
-
#idleMs;
|
|
54
|
-
#maxTerms;
|
|
55
|
-
#initCommand;
|
|
56
|
-
#sweeper;
|
|
57
|
-
#closeListeners = new Set();
|
|
58
|
-
#closed = false;
|
|
59
|
-
constructor(opts = {}) {
|
|
60
|
-
this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
|
|
61
|
-
this.#idleMs = opts.idleMs ?? IDLE_MS;
|
|
62
|
-
this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
|
|
63
|
-
this.#initCommand = opts.initCommand ?? (() => "");
|
|
64
|
-
this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
|
|
65
|
-
this.#sweeper.unref();
|
|
66
|
-
}
|
|
67
|
-
/** Attach a socket to `cwd`'s shell, spawning one if needed. A refusal is
|
|
68
|
-
* never silent: the socket gets a `{t:"error"}` frame and a close, and the
|
|
69
|
-
* caller gets `null`. */
|
|
70
|
-
async attach(cwd, sock) {
|
|
71
|
-
let key;
|
|
72
|
-
try {
|
|
73
|
-
key = await scoped(cwd); // absolute, real, and a directory — or it throws
|
|
74
|
-
}
|
|
75
|
-
catch (err) {
|
|
76
|
-
control(sock, { t: "error", message: String(err) });
|
|
77
|
-
sock.close(1008, "bad cwd");
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
if (this.#closed) {
|
|
81
|
-
control(sock, { t: "error", message: "terminal server is stopping" });
|
|
82
|
-
sock.close(1012, "server stopping");
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
let term = this.#terms.get(key);
|
|
86
|
-
if (!term) {
|
|
87
|
-
if (this.#terms.size >= this.#maxTerms) {
|
|
88
|
-
control(sock, { t: "error", message: `${this.#maxTerms} shells already running — close one first` });
|
|
89
|
-
sock.close(1013, "too many shells");
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
term = this.#spawn(key);
|
|
94
|
-
}
|
|
95
|
-
catch (err) {
|
|
96
|
-
log.error(`spawn ${this.#shell} in ${key} failed`, err);
|
|
97
|
-
control(sock, { t: "error", message: `could not start ${this.#shell}: ${String(err)}` });
|
|
98
|
-
sock.close(1011, "spawn failed");
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
term.clients.add(sock);
|
|
103
|
-
term.idleSince = Infinity;
|
|
104
|
-
const conn = {
|
|
105
|
-
message: (text) => this.#message(key, sock, text),
|
|
106
|
-
detach: () => {
|
|
107
|
-
const t = this.#terms.get(key);
|
|
108
|
-
if (!t || !t.clients.delete(sock))
|
|
109
|
-
return;
|
|
110
|
-
if (t.clients.size === 0)
|
|
111
|
-
t.idleSince = Date.now();
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
try {
|
|
115
|
-
if (term.ringBytes)
|
|
116
|
-
sock.send(Buffer.concat(term.ring));
|
|
117
|
-
}
|
|
118
|
-
catch (err) {
|
|
119
|
-
conn.detach();
|
|
120
|
-
throw err;
|
|
121
|
-
}
|
|
122
|
-
return conn;
|
|
123
|
-
}
|
|
124
|
-
#spawn(cwd) {
|
|
125
|
-
const env = { ...process.env };
|
|
126
|
-
for (const key of Object.keys(env)) {
|
|
127
|
-
if (PARENT_TERMINAL_ENV.includes(key) || PIER_OWN_ENV.includes(key) || PIER_OWN_PREFIX.test(key)) {
|
|
128
|
-
delete env[key];
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const pty = spawn(this.#shell, [], {
|
|
132
|
-
name: "xterm-256color",
|
|
133
|
-
cols: 120,
|
|
134
|
-
rows: 30,
|
|
135
|
-
cwd,
|
|
136
|
-
env,
|
|
137
|
-
});
|
|
138
|
-
const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
|
|
139
|
-
this.#terms.set(cwd, term);
|
|
140
|
-
log.info(`shell ${pty.pid} for ${cwd}`);
|
|
141
|
-
// Typed in, not exec'd: the shell stays the parent, so quitting whatever
|
|
142
|
-
// this starts leaves a usable prompt, and the echo plus any error is in
|
|
143
|
-
// the ring where the person can see what ran. The tty buffers it until the
|
|
144
|
-
// shell's first read, so no wait is needed. A reattach never repeats it —
|
|
145
|
-
// this runs once per pty, which is once per cwd.
|
|
146
|
-
const init = this.#initCommand().trim();
|
|
147
|
-
if (init)
|
|
148
|
-
pty.write(`${init}\r`);
|
|
149
|
-
pty.onData((data) => {
|
|
150
|
-
const chunk = Buffer.from(data);
|
|
151
|
-
term.ring.push(chunk);
|
|
152
|
-
term.ringBytes += chunk.byteLength;
|
|
153
|
-
while (term.ringBytes > RING_BYTES && term.ring.length > 1) {
|
|
154
|
-
term.ringBytes -= term.ring.shift().byteLength;
|
|
155
|
-
}
|
|
156
|
-
for (const client of term.clients) {
|
|
157
|
-
if (client.bufferedAmount <= HIGH_WATER && send(client, chunk))
|
|
158
|
-
continue;
|
|
159
|
-
term.clients.delete(client);
|
|
160
|
-
client.close(1013, "client too slow");
|
|
161
|
-
log.warn(`detached slow terminal client for ${cwd}`);
|
|
162
|
-
}
|
|
163
|
-
if (term.clients.size === 0 && term.idleSince === Infinity)
|
|
164
|
-
term.idleSince = Date.now();
|
|
165
|
-
});
|
|
166
|
-
pty.onExit(({ exitCode }) => {
|
|
167
|
-
log.info(`shell ${pty.pid} for ${cwd} exited (${exitCode})`);
|
|
168
|
-
this.#terms.delete(cwd);
|
|
169
|
-
for (const c of term.clients) {
|
|
170
|
-
control(c, { t: "exit", code: exitCode });
|
|
171
|
-
c.close(1000, "shell exited");
|
|
172
|
-
}
|
|
173
|
-
term.clients.clear();
|
|
174
|
-
});
|
|
175
|
-
return term;
|
|
176
|
-
}
|
|
177
|
-
/** One inbound frame. Malformed input is logged and dropped, never half-run. */
|
|
178
|
-
#message(cwd, sock, text) {
|
|
179
|
-
const term = this.#terms.get(cwd);
|
|
180
|
-
if (!term || !term.clients.has(sock))
|
|
181
|
-
return;
|
|
182
|
-
let raw;
|
|
183
|
-
try {
|
|
184
|
-
raw = JSON.parse(text);
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
log.warn(`dropped unparseable frame for ${cwd}`);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
191
|
-
log.warn(`dropped malformed frame for ${cwd}: ${text.slice(0, 80)}`);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
const msg = raw;
|
|
195
|
-
if (msg.t === "in" && typeof msg.d === "string") {
|
|
196
|
-
term.pty.write(msg.d);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
// The only way a page can end a shell: everything attached to it is told by
|
|
200
|
-
// the exit path below, exactly as if the shell had exited on its own.
|
|
201
|
-
if (msg.t === "restart") {
|
|
202
|
-
log.info(`shell ${term.pty.pid} for ${cwd} killed on request`);
|
|
203
|
-
term.pty.kill();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (msg.t === "resize" &&
|
|
207
|
-
typeof msg.cols === "number" && typeof msg.rows === "number" &&
|
|
208
|
-
Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
|
|
209
|
-
msg.cols >= 2 && msg.cols <= 500 && msg.rows >= 2 && msg.rows <= 200) {
|
|
210
|
-
term.pty.resize(msg.cols, msg.rows); // last resize wins, by design
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
log.warn(`dropped malformed frame for ${cwd}: ${text.slice(0, 80)}`);
|
|
214
|
-
}
|
|
215
|
-
/** Reap shells nobody has been attached to for `idleMs`. */
|
|
216
|
-
sweep(now) {
|
|
217
|
-
for (const [cwd, term] of this.#terms) {
|
|
218
|
-
if (now - term.idleSince < this.#idleMs)
|
|
219
|
-
continue;
|
|
220
|
-
log.info(`reaping idle shell ${term.pty.pid} for ${cwd}`);
|
|
221
|
-
term.pty.kill(); // onExit above deletes and notifies
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
get size() {
|
|
225
|
-
return this.#terms.size;
|
|
226
|
-
}
|
|
227
|
-
onClose(listener) {
|
|
228
|
-
this.#closeListeners.add(listener);
|
|
229
|
-
}
|
|
230
|
-
/** Shutdown: kill every shell so none outlives the workbench. */
|
|
231
|
-
close() {
|
|
232
|
-
if (this.#closed)
|
|
233
|
-
return;
|
|
234
|
-
this.#closed = true;
|
|
235
|
-
clearInterval(this.#sweeper);
|
|
236
|
-
for (const listener of this.#closeListeners) {
|
|
237
|
-
try {
|
|
238
|
-
listener();
|
|
239
|
-
}
|
|
240
|
-
catch (err) {
|
|
241
|
-
log.error("terminal shutdown cleanup failed", err);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
this.#closeListeners.clear();
|
|
245
|
-
for (const term of this.#terms.values())
|
|
246
|
-
term.pty.kill();
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
/** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
|
|
250
|
-
* as every route. SameSite=Lax already withholds the cookie cross-site; the
|
|
251
|
-
* Origin check is the explicit copy of that fact. */
|
|
252
|
-
export function attachTerminal(server, auth, opts = {}) {
|
|
253
|
-
const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
|
|
254
|
-
const hub = new TerminalHub(opts);
|
|
255
|
-
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
|
|
256
|
-
const alive = new WeakSet();
|
|
257
|
-
const heartbeat = setInterval(() => {
|
|
258
|
-
for (const client of wss.clients) {
|
|
259
|
-
if (client.readyState !== client.OPEN || !alive.delete(client))
|
|
260
|
-
client.terminate();
|
|
261
|
-
else
|
|
262
|
-
client.ping(); // browsers and proxies both see traffic
|
|
263
|
-
}
|
|
264
|
-
}, heartbeatMs);
|
|
265
|
-
heartbeat.unref();
|
|
266
|
-
hub.onClose(() => {
|
|
267
|
-
clearInterval(heartbeat);
|
|
268
|
-
for (const client of wss.clients)
|
|
269
|
-
client.terminate();
|
|
270
|
-
wss.close();
|
|
271
|
-
});
|
|
272
|
-
server.once("close", () => hub.close());
|
|
273
|
-
wss.on("error", (err) => log.error("terminal WebSocket server failed", err));
|
|
274
|
-
// A shell outlives the request that opened it, so revocation has to reach it
|
|
275
|
-
// here: the session that opened this socket was signed out (or every session
|
|
276
|
-
// was, by a password change), and the terminal goes with it.
|
|
277
|
-
const sessionOf = new WeakMap();
|
|
278
|
-
const revoked = new WeakSet();
|
|
279
|
-
auth.onRevoke((id) => {
|
|
280
|
-
for (const client of wss.clients) {
|
|
281
|
-
if (id !== ALL && sessionOf.get(client) !== id)
|
|
282
|
-
continue;
|
|
283
|
-
revoked.add(client);
|
|
284
|
-
client.close(1008, "signed out");
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
server.on("upgrade", (req, socket, head) => {
|
|
288
|
-
let url;
|
|
289
|
-
try {
|
|
290
|
-
url = new URL(req.url ?? "/", "http://localhost");
|
|
291
|
-
}
|
|
292
|
-
catch {
|
|
293
|
-
log.warn(`refused malformed terminal upgrade target: ${req.url ?? ""}`);
|
|
294
|
-
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
295
|
-
socket.destroy();
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
if (url.pathname !== "/api/terminal") {
|
|
299
|
-
socket.destroy();
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
const sessionId = upgradeAuthorized(auth, req);
|
|
303
|
-
if (!sessionId) {
|
|
304
|
-
log.warn("refused terminal upgrade (unauthorized)");
|
|
305
|
-
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
306
|
-
socket.destroy();
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
310
|
-
sessionOf.set(ws, sessionId);
|
|
311
|
-
alive.add(ws);
|
|
312
|
-
ws.on("pong", () => alive.add(ws));
|
|
313
|
-
// Frames can land while attach() is resolving realpath; hold a bounded
|
|
314
|
-
// handful. Close is registered first so an early disconnect cannot leave
|
|
315
|
-
// a phantom client that prevents the one-hour reap.
|
|
316
|
-
let conn = null;
|
|
317
|
-
let closed = false;
|
|
318
|
-
let pendingBytes = 0;
|
|
319
|
-
const pending = [];
|
|
320
|
-
ws.on("close", () => {
|
|
321
|
-
closed = true;
|
|
322
|
-
conn?.detach();
|
|
323
|
-
});
|
|
324
|
-
ws.on("message", (data, binary) => {
|
|
325
|
-
if (closed)
|
|
326
|
-
return;
|
|
327
|
-
// A frame can already be in flight when the close above goes out.
|
|
328
|
-
if (revoked.has(ws)) {
|
|
329
|
-
closed = true;
|
|
330
|
-
ws.close(1008, "signed out");
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (binary) {
|
|
334
|
-
closed = true;
|
|
335
|
-
ws.close(1003, "text frames only");
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
const text = String(data);
|
|
339
|
-
if (conn)
|
|
340
|
-
conn.message(text);
|
|
341
|
-
else if ((pendingBytes += Buffer.byteLength(text)) <= MAX_FRAME_BYTES)
|
|
342
|
-
pending.push(text);
|
|
343
|
-
else {
|
|
344
|
-
closed = true;
|
|
345
|
-
ws.close(1009, "too much pending input");
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
ws.on("error", (err) => log.warn(`terminal socket error: ${String(err)}`));
|
|
349
|
-
void hub.attach(url.searchParams.get("cwd") ?? "", ws).then((c) => {
|
|
350
|
-
if (!c)
|
|
351
|
-
return;
|
|
352
|
-
if (closed) {
|
|
353
|
-
c.detach();
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
conn = c;
|
|
357
|
-
for (const text of pending)
|
|
358
|
-
conn.message(text);
|
|
359
|
-
}).catch((err) => {
|
|
360
|
-
log.error("terminal attach failed", err);
|
|
361
|
-
if (closed)
|
|
362
|
-
return;
|
|
363
|
-
control(ws, { t: "error", message: `terminal attach failed: ${String(err)}` });
|
|
364
|
-
ws.close(1011, "attach failed");
|
|
365
|
-
});
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
return hub;
|
|
369
|
-
}
|