@kottera/tera-web 0.1.0-rc.6 → 0.1.0-rc.7

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.
@@ -0,0 +1,540 @@
1
+ /**
2
+ * The terminal view: one view of an attended session's terminal (ADR-181
3
+ * D11 item 2), speaking `terminalwire` (ADR-180 D4) over a
4
+ * {@link StreamConnection} to the kit's terminal route. It holds ADR-180's
5
+ * client duties itself, so a page hands it a renderer and keystrokes and
6
+ * keeps none of them.
7
+ *
8
+ * # Output: two offsets
9
+ *
10
+ * - **The resume offset is bytes handed to the renderer.** It counts from the
11
+ * last `WELCOME` or `RESYNC`, as the minder does, and a reconnect names it,
12
+ * so the minder never sends twice what the renderer already has queued
13
+ * (ADR-180 D2 item 1). A replay cut off before its `REPLAY_END` names none
14
+ * and is replayed whole: a resume could not mark the rest of it as history.
15
+ * - **An `ACK` counts bytes the renderer's `parsed` callback reported**, never
16
+ * bytes received (ADR-180 D6 item 1), coalesced to one per
17
+ * {@link ACK_EVERY} or when the renderer has parsed all it was handed,
18
+ * whichever is first. A callback for bytes handed over an earlier socket
19
+ * acknowledges nothing: the minder's window is per connection.
20
+ *
21
+ * A `DATA` payload reaches the renderer as a `Uint8Array` over the message's
22
+ * own buffer (ADR-181 D11 item 5): nothing on the receive path copies a byte.
23
+ *
24
+ * # Input: the window, and what rides outside it
25
+ *
26
+ * - **At most {@link INPUT_WINDOW} of `INPUT` beyond what `INPUT_ACK` has
27
+ * granted back** (ADR-180 D6 item 2). A paste is queued in the view and
28
+ * sent in frames of at most `MAX_DATA_PAYLOAD` as the grants return; the
29
+ * queue holds at most {@link MAX_QUEUED_INPUT}. Each new socket starts with
30
+ * the whole window, since the minder counts it per connection.
31
+ * - **A one-byte keystroke the window cannot hold is sent as `SIGNAL`**
32
+ * (P-R2, W4-X1; `terminalwire.encodeSignal`), once, ahead of whatever is
33
+ * queued: with the window full and the process not reading, it is how
34
+ * Ctrl-C still reaches the session. The minder signals the foreground
35
+ * process group for the pty's interrupt, quit or suspend character and
36
+ * drops any other byte. At most {@link MAX_SIGNALS_PER_GRANT} are sent
37
+ * between two grants, half of ADR-180 D6 item 3's 8 KiB control allowance;
38
+ * past that a keystroke is dropped. The view's other frames outside the
39
+ * window (`ACK`, `RESIZE`, `CONTROL`) are each sent on an event — a parsed
40
+ * chunk, a debounced resize, a role change — and never per keystroke, and
41
+ * none of them waits behind input.
42
+ * - **A grant outstanding {@link INPUT_GRANT_TIMEOUT_MS}** closes the socket
43
+ * and reconnects ({@link INPUT_GRANT_TIMEOUT}): a page cannot send a ping,
44
+ * so this is how it finds a dead connection while the person types.
45
+ * - **History is not answered** (ADR-180 D2 item 4): from a `WELCOME` or
46
+ * `RESYNC` marked replay until the renderer has parsed past `REPLAY_END`,
47
+ * `input` is dropped and `status.restoring` is true, so the renderer's own
48
+ * replies to queries in the history never reach the process. A replay
49
+ * discards input still queued: it was typed at a screen being redrawn.
50
+ * - **Control first** (ADR-180 D7 item 2): a viewer's first `INPUT` or
51
+ * `SIGNAL` is preceded by `CONTROL {take}`.
52
+ *
53
+ * # Size
54
+ *
55
+ * `resize` is sent, debounced {@link RESIZE_DEBOUNCE_MS}, only while this
56
+ * view sets the size (ADR-180 D7 item 3), which control does not change. A
57
+ * view that does not set it renders at `status.size`, the pty's size; the
58
+ * page resizes its renderer to it. A view that becomes the size-setter sends
59
+ * its own size if the minder does not already have it.
60
+ *
61
+ * # Ending
62
+ *
63
+ * A `CLOSE` frame says what the normal closure after it does (ADR-181 D9
64
+ * item 2): `handover` and `reauthenticate` reconnect at once,
65
+ * `runner-unreachable` after backoff, and every other reason ends the view.
66
+ * `detach` is the page's own end: a `CLOSE {detached}`, then the socket's.
67
+ */
68
+ import { routePath } from "../routes.js";
69
+ import { StreamConnection, } from "../stream/connection.js";
70
+ import { CloseReason, ControlAction, FrameType, MAX_DATA_PAYLOAD, Role, SUBPROTOCOL, decodeClose, decodeCount, decodeFrame, decodeResync, decodeStatus, decodeWelcome, encodeBytes, encodeClose, encodeControl, encodeCount, encodeResize, encodeSignal, } from "../terminalwire/codec.js";
71
+ /** The `INPUT` a view sends beyond what the minder has granted back (ADR-180 D6 item 2). */
72
+ export const INPUT_WINDOW = 64 << 10;
73
+ /** The parsed bytes that make one `ACK`; a renderer with nothing left to parse acknowledges sooner. */
74
+ export const ACK_EVERY = 16 << 10;
75
+ /** How long a grant may stay outstanding before the view treats its socket as dead. */
76
+ export const INPUT_GRANT_TIMEOUT_MS = 5_000;
77
+ /** The close reason the view gives up on a socket with after {@link INPUT_GRANT_TIMEOUT_MS}. */
78
+ export const INPUT_GRANT_TIMEOUT = "input_grant_timeout";
79
+ /** How long `resize` waits for the next before the size is sent. */
80
+ export const RESIZE_DEBOUNCE_MS = 100;
81
+ /** The most input the view queues for credit; a paste past it is dropped. */
82
+ export const MAX_QUEUED_INPUT = 4 << 20;
83
+ /**
84
+ * The `SIGNAL`s sent between two grants: half of the 8 KiB control allowance
85
+ * outside the input window (ADR-180 D6 item 3), at five bytes a frame.
86
+ */
87
+ export const MAX_SIGNALS_PER_GRANT = Math.floor((8 << 10) / 2 / 5);
88
+ const realClock = {
89
+ now: () => Date.now(),
90
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
91
+ clearTimeout: (handle) => clearTimeout(handle),
92
+ random: () => Math.random(),
93
+ };
94
+ const encoder = new TextEncoder();
95
+ const MAX_U16 = 0xffff;
96
+ function checkSize(cols, rows) {
97
+ for (const v of [cols, rows]) {
98
+ if (!Number.isInteger(v) || v < 1 || v > MAX_U16)
99
+ throw new RangeError(`TerminalView: a size of ${v} cells is not from 1 to 65535`);
100
+ }
101
+ return { cols, rows };
102
+ }
103
+ /** A canonical lower-case UUIDv7 (RFC 9562) at `ms`, which the kit's terminal route requires of a view id. */
104
+ export function newViewId(ms) {
105
+ const b = new Uint8Array(16);
106
+ globalThis.crypto.getRandomValues(b);
107
+ let t = Math.max(0, Math.floor(ms));
108
+ for (let i = 5; i >= 0; i--) {
109
+ b[i] = t % 256;
110
+ t = Math.floor(t / 256);
111
+ }
112
+ b[6] = 0x70 | (b[6] & 0x0f);
113
+ b[8] = 0x80 | (b[8] & 0x3f);
114
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
115
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
116
+ }
117
+ /** What the normal closure after a `CLOSE` frame of `reason` does (ADR-181 D11 item 2). */
118
+ export function nextAfterClose(reason) {
119
+ switch (reason) {
120
+ case CloseReason.Handover:
121
+ case CloseReason.Reauthenticate:
122
+ return "reconnect";
123
+ case CloseReason.RunnerUnreachable:
124
+ return "backoff";
125
+ default:
126
+ return "end";
127
+ }
128
+ }
129
+ export class TerminalView {
130
+ viewId;
131
+ renderer;
132
+ clock;
133
+ onStatus;
134
+ conn;
135
+ want;
136
+ local;
137
+ // The size the minder has from this view: the connect's query or the last RESIZE.
138
+ sent;
139
+ // Every socket is one generation; a parsed callback for bytes handed over
140
+ // an older one acknowledges nothing.
141
+ gen = 0;
142
+ connection = { phase: "connecting", attempt: 0 };
143
+ welcomed = false;
144
+ ended = false;
145
+ role = Role.Viewer;
146
+ setsSize = false;
147
+ views = 0;
148
+ size = { cols: 0, rows: 0 };
149
+ controller = "";
150
+ sizeSetter = "";
151
+ handoverPending = false;
152
+ exit = null;
153
+ closeReason = null;
154
+ dropped = 0;
155
+ takeSent = false;
156
+ // Output: the base from the last WELCOME or RESYNC, and bytes handed since.
157
+ hasOffset = false;
158
+ base = 0;
159
+ handed = 0;
160
+ // Bytes parsed on this socket and not yet acknowledged, and writes not yet parsed.
161
+ unacked = 0;
162
+ pending = 0;
163
+ // Restoring: every write is numbered; history ends at the write REPLAY_END followed.
164
+ restoring = false;
165
+ writeSeq = 0;
166
+ parsedSeq = 0;
167
+ replayEndSeq = null;
168
+ // Input: queued chunks, the offset into the first, and the bytes queued.
169
+ queue = [];
170
+ queueHead = 0;
171
+ queued = 0;
172
+ inFlight = 0;
173
+ signalsSinceGrant = 0;
174
+ grantTimer = null;
175
+ resizeTimer = null;
176
+ /** Opens the view's socket at once. */
177
+ constructor(options) {
178
+ if (options.want !== "control" && options.want !== "view")
179
+ throw new TypeError(`TerminalView: want is "control" or "view"`);
180
+ this.local = checkSize(options.cols, options.rows);
181
+ this.sent = this.local;
182
+ this.want = options.want;
183
+ this.renderer = options.renderer;
184
+ this.clock = options.clock ?? realClock;
185
+ this.onStatus = options.onStatus;
186
+ this.viewId = newViewId(this.clock.now());
187
+ this.conn = new StreamConnection({
188
+ path: routePath("workorders", options.workOrder, "terminal"),
189
+ subprotocol: SUBPROTOCOL,
190
+ query: () => this.query(),
191
+ onMessage: (data) => this.message(data),
192
+ onState: (state) => this.connectionState(state),
193
+ base: options.base,
194
+ socket: options.socket,
195
+ fetch: options.fetch,
196
+ reSignIn: options.reSignIn,
197
+ clock: this.clock,
198
+ signals: options.signals,
199
+ });
200
+ }
201
+ get status() {
202
+ return {
203
+ viewId: this.viewId,
204
+ connection: this.connection,
205
+ welcomed: this.welcomed,
206
+ role: this.role,
207
+ setsSize: this.setsSize,
208
+ views: this.views,
209
+ size: { ...this.size },
210
+ controller: this.controller,
211
+ sizeSetter: this.sizeSetter,
212
+ handoverPending: this.handoverPending,
213
+ exit: this.exit,
214
+ restoring: this.restoring,
215
+ closeReason: this.closeReason,
216
+ dropped: this.dropped,
217
+ };
218
+ }
219
+ /**
220
+ * Sends what the person typed: what xterm.js's `onData` hands the page, a
221
+ * string sent as its UTF-8 bytes. A `Uint8Array` is copied, so the caller
222
+ * may reuse it.
223
+ */
224
+ input(data) {
225
+ const bytes = typeof data === "string" ? encoder.encode(data) : data.slice();
226
+ if (bytes.length === 0)
227
+ return;
228
+ if (this.ended || this.restoring) {
229
+ this.drop(bytes.length);
230
+ return;
231
+ }
232
+ if (bytes.length === 1 && this.ready && (this.queued > 0 || this.inFlight >= INPUT_WINDOW)) {
233
+ this.signal(bytes[0]);
234
+ return;
235
+ }
236
+ if (this.queued + bytes.length > MAX_QUEUED_INPUT) {
237
+ this.drop(bytes.length);
238
+ return;
239
+ }
240
+ this.queue.push(bytes);
241
+ this.queued += bytes.length;
242
+ this.pump();
243
+ }
244
+ /** The view's own size changed. It reaches the pty only while this view sets the size. */
245
+ resize(cols, rows) {
246
+ this.local = checkSize(cols, rows);
247
+ if (this.setsSize)
248
+ this.scheduleResize();
249
+ }
250
+ /** Asks for control now, rather than at the next keystroke. */
251
+ takeControl() {
252
+ this.want = "control";
253
+ this.takeSent = false;
254
+ if (this.ready && this.role !== Role.Controller)
255
+ this.sendTake();
256
+ }
257
+ /** The page's own end: tells the minder the view detached, and closes the socket. */
258
+ detach() {
259
+ if (this.ended)
260
+ return;
261
+ if (this.ready)
262
+ this.conn.send(encodeClose({ reason: CloseReason.Detached, reconnect: false }));
263
+ this.conn.close();
264
+ }
265
+ get ready() {
266
+ return this.welcomed && this.connection.phase === "open";
267
+ }
268
+ emit() {
269
+ this.onStatus?.(this.status);
270
+ }
271
+ query() {
272
+ const q = new URLSearchParams();
273
+ q.set("view", this.viewId);
274
+ q.set("want", this.want);
275
+ // A replay cut off before REPLAY_END is replayed whole, so its history
276
+ // is still marked as history.
277
+ const replayCut = this.restoring && this.replayEndSeq === null;
278
+ if (this.hasOffset && !replayCut)
279
+ q.set("resume", String(this.base + this.handed));
280
+ q.set("cols", String(this.local.cols));
281
+ q.set("rows", String(this.local.rows));
282
+ this.sent = this.local;
283
+ return q;
284
+ }
285
+ connectionState(state) {
286
+ this.connection = state;
287
+ this.gen += 1;
288
+ this.welcomed = false;
289
+ this.unacked = 0;
290
+ this.pending = 0;
291
+ this.inFlight = 0;
292
+ this.signalsSinceGrant = 0;
293
+ this.takeSent = false;
294
+ this.clearGrantTimer();
295
+ if (state.phase === "ended") {
296
+ this.ended = true;
297
+ this.clearResizeTimer();
298
+ this.discardQueue();
299
+ }
300
+ this.emit();
301
+ }
302
+ message(data) {
303
+ let f;
304
+ try {
305
+ f = decodeFrame(new Uint8Array(data));
306
+ }
307
+ catch {
308
+ return; // a frame the codec refuses carries nothing the view can act on
309
+ }
310
+ try {
311
+ this.apply(f);
312
+ }
313
+ catch {
314
+ // A body this version cannot read is ignored, as an unknown type is.
315
+ }
316
+ }
317
+ apply(f) {
318
+ switch (f.type) {
319
+ case FrameType.Welcome:
320
+ this.welcome(f);
321
+ return;
322
+ case FrameType.Data:
323
+ if (this.welcomed)
324
+ this.data(f.payload);
325
+ return;
326
+ case FrameType.Resync: {
327
+ if (!this.welcomed)
328
+ return;
329
+ const r = decodeResync(f);
330
+ this.base = r.offset;
331
+ this.handed = 0;
332
+ if (r.replay)
333
+ this.startRestoring();
334
+ return;
335
+ }
336
+ case FrameType.ReplayEnd:
337
+ if (this.welcomed && this.restoring) {
338
+ this.replayEndSeq = this.writeSeq;
339
+ this.restored();
340
+ }
341
+ return;
342
+ case FrameType.InputAck:
343
+ if (this.welcomed)
344
+ this.granted(decodeCount(f));
345
+ return;
346
+ case FrameType.Status:
347
+ if (this.welcomed)
348
+ this.statusFrame(f);
349
+ return;
350
+ case FrameType.Close: {
351
+ const c = decodeClose(f);
352
+ this.closeReason = c.reason;
353
+ this.conn.onNormalClose(nextAfterClose(c.reason));
354
+ this.emit();
355
+ return;
356
+ }
357
+ }
358
+ // A frame type this version does not know, or one a client never receives, is ignored.
359
+ }
360
+ welcome(f) {
361
+ const w = decodeWelcome(f);
362
+ this.welcomed = true;
363
+ this.hasOffset = true;
364
+ this.base = w.offset;
365
+ this.handed = 0;
366
+ this.role = w.role;
367
+ this.setsSize = w.setsSize;
368
+ this.size = w.size;
369
+ this.views = w.views;
370
+ this.closeReason = null;
371
+ this.takeSent = false;
372
+ this.want = w.role === Role.Controller ? "control" : this.want;
373
+ if (w.replay)
374
+ this.startRestoring();
375
+ this.emit();
376
+ if (this.want === "control" && this.role !== Role.Controller)
377
+ this.sendTake();
378
+ if (this.setsSize)
379
+ this.scheduleResize();
380
+ this.pump();
381
+ }
382
+ statusFrame(f) {
383
+ const s = decodeStatus(f);
384
+ const wasController = this.role === Role.Controller;
385
+ this.role = s.role;
386
+ this.views = s.views;
387
+ this.size = s.size;
388
+ this.controller = s.controller;
389
+ this.sizeSetter = s.sizeSetter;
390
+ this.setsSize = s.sizeSetter === this.viewId;
391
+ this.handoverPending = s.handoverPending;
392
+ if (s.exited)
393
+ this.exit = { status: s.exitStatus, signal: s.exitSignal };
394
+ if (s.role === Role.Controller)
395
+ this.want = "control";
396
+ else {
397
+ // Control moved to another device: a reconnect does not take it back,
398
+ // and the next keystroke here takes it again.
399
+ if (wasController)
400
+ this.want = "view";
401
+ this.takeSent = false;
402
+ }
403
+ this.emit();
404
+ if (this.setsSize)
405
+ this.scheduleResize();
406
+ }
407
+ data(payload) {
408
+ if (payload.length === 0)
409
+ return;
410
+ const gen = this.gen;
411
+ const n = payload.length;
412
+ const seq = ++this.writeSeq;
413
+ this.handed += n;
414
+ this.pending += 1;
415
+ let done = false;
416
+ this.renderer.write(payload, () => {
417
+ if (done)
418
+ return;
419
+ done = true;
420
+ this.parsed(gen, n, seq);
421
+ });
422
+ }
423
+ parsed(gen, n, seq) {
424
+ this.parsedSeq = seq;
425
+ if (this.restoring)
426
+ this.restored();
427
+ if (gen !== this.gen)
428
+ return;
429
+ this.pending -= 1;
430
+ this.unacked += n;
431
+ if (this.unacked >= ACK_EVERY || this.pending === 0) {
432
+ const count = this.unacked;
433
+ this.unacked = 0;
434
+ this.conn.send(encodeCount(FrameType.Ack, count));
435
+ }
436
+ }
437
+ startRestoring() {
438
+ this.restoring = true;
439
+ this.replayEndSeq = null;
440
+ this.discardQueue();
441
+ this.emit();
442
+ }
443
+ /** Ends the restoring once the renderer has parsed every write before REPLAY_END. */
444
+ restored() {
445
+ if (this.replayEndSeq === null || this.parsedSeq < this.replayEndSeq)
446
+ return;
447
+ this.restoring = false;
448
+ this.replayEndSeq = null;
449
+ this.emit();
450
+ }
451
+ granted(n) {
452
+ this.inFlight = Math.max(0, this.inFlight - n);
453
+ if (n > 0) {
454
+ this.signalsSinceGrant = 0;
455
+ this.clearGrantTimer();
456
+ }
457
+ this.pump();
458
+ this.armGrantTimer();
459
+ }
460
+ /** Sends queued input as far as the window allows. */
461
+ pump() {
462
+ while (this.ready && this.queued > 0 && this.inFlight < INPUT_WINDOW) {
463
+ if (this.role !== Role.Controller && !this.takeSent)
464
+ this.sendTake();
465
+ const head = this.queue[0];
466
+ const n = Math.min(head.length - this.queueHead, MAX_DATA_PAYLOAD, INPUT_WINDOW - this.inFlight);
467
+ if (!this.conn.send(encodeBytes(FrameType.Input, head.subarray(this.queueHead, this.queueHead + n))))
468
+ return;
469
+ this.inFlight += n;
470
+ this.queued -= n;
471
+ this.queueHead += n;
472
+ if (this.queueHead === head.length) {
473
+ this.queue.shift();
474
+ this.queueHead = 0;
475
+ }
476
+ }
477
+ this.armGrantTimer();
478
+ }
479
+ signal(char) {
480
+ if (this.signalsSinceGrant >= MAX_SIGNALS_PER_GRANT) {
481
+ this.drop(1);
482
+ return;
483
+ }
484
+ if (this.role !== Role.Controller && !this.takeSent)
485
+ this.sendTake();
486
+ if (this.conn.send(encodeSignal(char)))
487
+ this.signalsSinceGrant += 1;
488
+ }
489
+ sendTake() {
490
+ this.want = "control";
491
+ if (this.conn.send(encodeControl({ action: ControlAction.Take })))
492
+ this.takeSent = true;
493
+ }
494
+ drop(n) {
495
+ this.dropped += n;
496
+ this.emit();
497
+ }
498
+ discardQueue() {
499
+ if (this.queued === 0)
500
+ return;
501
+ this.dropped += this.queued;
502
+ this.queue.length = 0;
503
+ this.queueHead = 0;
504
+ this.queued = 0;
505
+ }
506
+ armGrantTimer() {
507
+ if (this.inFlight === 0) {
508
+ this.clearGrantTimer();
509
+ return;
510
+ }
511
+ if (this.grantTimer !== null)
512
+ return;
513
+ this.grantTimer = this.clock.setTimeout(() => {
514
+ this.grantTimer = null;
515
+ this.conn.reconnect(INPUT_GRANT_TIMEOUT);
516
+ }, INPUT_GRANT_TIMEOUT_MS);
517
+ }
518
+ clearGrantTimer() {
519
+ if (this.grantTimer !== null)
520
+ this.clock.clearTimeout(this.grantTimer);
521
+ this.grantTimer = null;
522
+ }
523
+ scheduleResize() {
524
+ this.clearResizeTimer();
525
+ this.resizeTimer = this.clock.setTimeout(() => {
526
+ this.resizeTimer = null;
527
+ if (!this.ready || !this.setsSize)
528
+ return;
529
+ if (this.local.cols === this.sent.cols && this.local.rows === this.sent.rows)
530
+ return;
531
+ if (this.conn.send(encodeResize(this.local)))
532
+ this.sent = this.local;
533
+ }, RESIZE_DEBOUNCE_MS);
534
+ }
535
+ clearResizeTimer() {
536
+ if (this.resizeTimer !== null)
537
+ this.clock.clearTimeout(this.resizeTimer);
538
+ this.resizeTimer = null;
539
+ }
540
+ }