@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.
- package/README.md +7 -4
- package/dist/client.d.ts +18 -0
- package/dist/client.js +19 -1
- package/dist/generated/kit.d.ts +69 -2
- package/dist/generated/kit.js +86 -0
- package/dist/generated/workorder-events.d.ts +1 -1
- package/dist/generated/workorder.d.ts +50 -0
- package/dist/generated/wsstream.d.ts +34 -0
- package/dist/generated/wsstream.js +28 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +8 -0
- package/dist/stream/connection.d.ts +145 -0
- package/dist/stream/connection.js +377 -0
- package/dist/terminal/view.d.ts +168 -0
- package/dist/terminal/view.js +540 -0
- package/dist/terminalwire/codec.d.ts +176 -0
- package/dist/terminalwire/codec.js +547 -0
- package/dist/unreached.d.ts +26 -0
- package/dist/unreached.js +30 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|