@slopus/ghostty-web 0.1.0

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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -0
  3. package/dist/GhosttyRemoteTerminal.d.ts +69 -0
  4. package/dist/GhosttyRemoteTerminal.d.ts.map +1 -0
  5. package/dist/GhosttyRemoteTerminal.js +212 -0
  6. package/dist/RemoteTerminalProtocolClient.d.ts +30 -0
  7. package/dist/RemoteTerminalProtocolClient.d.ts.map +1 -0
  8. package/dist/RemoteTerminalProtocolClient.js +350 -0
  9. package/dist/RemoteTerminalProtocolServer.d.ts +75 -0
  10. package/dist/RemoteTerminalProtocolServer.d.ts.map +1 -0
  11. package/dist/RemoteTerminalProtocolServer.js +794 -0
  12. package/dist/WirePacket.d.ts +29 -0
  13. package/dist/WirePacket.d.ts.map +1 -0
  14. package/dist/WirePacket.js +24 -0
  15. package/dist/WirePacketDecoder.d.ts +8 -0
  16. package/dist/WirePacketDecoder.d.ts.map +1 -0
  17. package/dist/WirePacketDecoder.js +130 -0
  18. package/dist/applyGridPatch.d.ts +3 -0
  19. package/dist/applyGridPatch.d.ts.map +1 -0
  20. package/dist/applyGridPatch.js +19 -0
  21. package/dist/diffGridState.d.ts +3 -0
  22. package/dist/diffGridState.d.ts.map +1 -0
  23. package/dist/diffGridState.js +26 -0
  24. package/dist/encodeWirePacket.d.ts +7 -0
  25. package/dist/encodeWirePacket.d.ts.map +1 -0
  26. package/dist/encodeWirePacket.js +25 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +9 -0
  30. package/dist/jsonPayload.d.ts +3 -0
  31. package/dist/jsonPayload.d.ts.map +1 -0
  32. package/dist/jsonPayload.js +8 -0
  33. package/dist/testing/ThrottledTcpProxy.d.ts +15 -0
  34. package/dist/testing/ThrottledTcpProxy.d.ts.map +1 -0
  35. package/dist/testing/ThrottledTcpProxy.js +83 -0
  36. package/dist/types.d.ts +105 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +1 -0
  39. package/package.json +32 -0
@@ -0,0 +1,794 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { diffGridState } from "./diffGridState.js";
3
+ import { encodeWirePacket } from "./encodeWirePacket.js";
4
+ import { decodeJsonPayload, encodeJsonPayload } from "./jsonPayload.js";
5
+ import { WirePacketDecoder } from "./WirePacketDecoder.js";
6
+ import { WirePacketType } from "./WirePacket.js";
7
+ export class RemoteTerminalProtocolServer {
8
+ epoch;
9
+ metrics = {
10
+ compressedPackets: 0,
11
+ encodedPackets: 0,
12
+ payloadBytes: 0,
13
+ wireBytes: 0,
14
+ };
15
+ #connections = new Set();
16
+ #cols;
17
+ #deferredBytes = 0;
18
+ #deferredOutput = [];
19
+ #deferredGrid = [];
20
+ #deferredUpdates = [];
21
+ #exit;
22
+ #failure;
23
+ #grid;
24
+ #gridPackets = new Map();
25
+ #gridRevision = 0;
26
+ #inputLeases = new Map();
27
+ #lastResizeBarrier = 0;
28
+ #maxInputLeases;
29
+ #maxBufferedBytes;
30
+ #maxFrameBytes;
31
+ #maxReplayBytes;
32
+ #maxUnacknowledgedBytes;
33
+ #options;
34
+ #outputChunks = [];
35
+ #outputOffset = 0;
36
+ #pausedConnections = new Set();
37
+ #replayBytes = 0;
38
+ #resizeOperation = Promise.resolve();
39
+ #resizeRevision = 0;
40
+ #resizing = false;
41
+ #rows;
42
+ #wireChunkBytes;
43
+ constructor(options) {
44
+ this.#options = options;
45
+ this.#maxReplayBytes = boundedInteger(options.maxReplayBytes ?? 4 * 1024 * 1024, 1, 64 * 1024 * 1024, "replay bytes");
46
+ this.#maxBufferedBytes = boundedInteger(options.maxBufferedBytes ?? this.#maxReplayBytes, 1, 64 * 1024 * 1024, "buffered bytes");
47
+ this.#maxInputLeases = boundedInteger(options.maxInputLeases ?? 1_024, 1, 65_536, "input leases");
48
+ this.#maxFrameBytes = boundedInteger(options.maxFrameBytes ?? 4 * 1024 * 1024, 1, 64 * 1024 * 1024, "frame bytes");
49
+ this.#wireChunkBytes = boundedInteger(options.wireChunkBytes ?? 16 * 1024, 1, this.#maxFrameBytes, "wire chunk bytes");
50
+ this.#maxUnacknowledgedBytes = boundedInteger(options.maxUnacknowledgedBytes ?? 256 * 1024, this.#wireChunkBytes, 64 * 1024 * 1024, "unacknowledged bytes");
51
+ this.#cols = boundedInteger(options.initialCols ?? 80, 1, 1_000, "initial columns");
52
+ this.#rows = boundedInteger(options.initialRows ?? 24, 1, 1_000, "initial rows");
53
+ this.epoch = options.epoch ?? randomUUID();
54
+ }
55
+ attach(stream) {
56
+ const connection = new ServerConnection(this, stream, this.#maxFrameBytes);
57
+ this.#connections.add(connection);
58
+ let removed = false;
59
+ const cleanup = () => {
60
+ if (removed)
61
+ return;
62
+ removed = true;
63
+ connection.close();
64
+ this.#connections.delete(connection);
65
+ this.releaseInputLease(connection);
66
+ this.setFlowPaused(connection, false);
67
+ };
68
+ stream.once("close", cleanup);
69
+ if (this.#failure !== undefined)
70
+ queueMicrotask(() => connection.fail(this.#failure));
71
+ return cleanup;
72
+ }
73
+ publishExit(exitCode) {
74
+ if (this.#failure !== undefined)
75
+ throw this.#failure;
76
+ if (this.#exit !== undefined)
77
+ return;
78
+ if (this.#resizing) {
79
+ this.#resizeOperation = this.#resizeOperation.then(() => this.publishExit(exitCode));
80
+ return;
81
+ }
82
+ this.#exit = { exitCode, outputOffset: this.#outputOffset };
83
+ for (const connection of this.#connections)
84
+ connection.maybeSendExit();
85
+ }
86
+ publishGrid(state) {
87
+ if (this.#failure !== undefined)
88
+ throw this.#failure;
89
+ if (this.#exit !== undefined)
90
+ throw new Error("Terminal output has already exited.");
91
+ if (!Number.isSafeInteger(state.coversOutputOffset) ||
92
+ state.coversOutputOffset < 0 ||
93
+ state.coversOutputOffset > this.#outputOffset) {
94
+ throw new Error("Grid output coverage is invalid.");
95
+ }
96
+ if (this.#resizing) {
97
+ this.#reserveDeferredBytes(encodeJsonPayload(state).length);
98
+ this.#deferredGrid.push(state);
99
+ }
100
+ else
101
+ this.#commitGrid(state);
102
+ }
103
+ publishOutput(data) {
104
+ if (this.#failure !== undefined)
105
+ throw this.#failure;
106
+ if (data.length === 0)
107
+ return;
108
+ if (this.#exit !== undefined)
109
+ throw new Error("Terminal output has already exited.");
110
+ if (this.#resizing) {
111
+ this.#reserveDeferredBytes(data.length);
112
+ this.#deferredOutput.push(Buffer.from(data));
113
+ return;
114
+ }
115
+ this.#commitOutput(Buffer.from(data));
116
+ }
117
+ /** Parse output into the canonical emulator first, then publish bytes and its matching grid atomically. */
118
+ publishUpdate(data, state) {
119
+ if (this.#failure !== undefined)
120
+ throw this.#failure;
121
+ if (this.#exit !== undefined)
122
+ throw new Error("Terminal output has already exited.");
123
+ if (this.#resizing) {
124
+ this.#reserveDeferredBytes(data.length);
125
+ this.#deferredUpdates.push({ data: Buffer.from(data), state });
126
+ return;
127
+ }
128
+ this.#commitOutput(Buffer.from(data));
129
+ this.#commitGrid({ ...state, coversOutputOffset: this.#outputOffset });
130
+ }
131
+ claimInputLease(requested, connection) {
132
+ if (requested !== undefined) {
133
+ validateString(requested, "input lease", 128);
134
+ const lease = this.#inputLeases.get(requested);
135
+ if (lease === undefined)
136
+ throw new Error("Input lease is unavailable.");
137
+ if (lease.active !== undefined && lease.active !== connection)
138
+ throw new Error("Input lease is already attached.");
139
+ lease.active = connection;
140
+ lease.lastUsed = Date.now();
141
+ return {
142
+ outputOffset: lease.outputOffset,
143
+ resizeRevision: lease.resizeRevision,
144
+ sequence: lease.sequence,
145
+ token: requested,
146
+ };
147
+ }
148
+ while (this.#inputLeases.size >= this.#maxInputLeases) {
149
+ const idle = [...this.#inputLeases].find(([, lease]) => lease.active === undefined);
150
+ if (idle === undefined)
151
+ throw new Error("Too many active input leases.");
152
+ this.#inputLeases.delete(idle[0]);
153
+ }
154
+ const token = randomUUID();
155
+ this.#inputLeases.set(token, {
156
+ active: connection,
157
+ lastUsed: Date.now(),
158
+ outputOffset: 0,
159
+ resizeRevision: 0,
160
+ sequence: 0,
161
+ });
162
+ return { outputOffset: 0, resizeRevision: 0, sequence: 0, token };
163
+ }
164
+ releaseInputLease(connection) {
165
+ for (const lease of this.#inputLeases.values()) {
166
+ if (lease.active === connection)
167
+ lease.active = undefined;
168
+ }
169
+ }
170
+ async receiveInput(token, sequence, data) {
171
+ if (this.#failure !== undefined)
172
+ throw this.#failure;
173
+ if (this.#exit !== undefined)
174
+ throw new Error("Terminal has exited.");
175
+ const lease = this.#inputLeases.get(token);
176
+ if (lease === undefined)
177
+ throw new Error("Input lease is unavailable.");
178
+ if (sequence <= lease.sequence)
179
+ return;
180
+ if (sequence !== lease.sequence + 1)
181
+ throw new Error("Client input sequence has a gap.");
182
+ await this.#options.onInput(data);
183
+ lease.sequence = sequence;
184
+ lease.lastUsed = Date.now();
185
+ }
186
+ acknowledgeOutput(token, outputOffset) {
187
+ const lease = this.#inputLeases.get(token);
188
+ if (lease === undefined)
189
+ throw new Error("Input lease is unavailable.");
190
+ lease.outputOffset = outputOffset;
191
+ lease.lastUsed = Date.now();
192
+ }
193
+ acknowledgeResize(token, revision) {
194
+ const lease = this.#inputLeases.get(token);
195
+ if (lease === undefined ||
196
+ revision < lease.resizeRevision ||
197
+ revision > this.#resizeRevision)
198
+ throw new Error("Invalid resize acknowledgement.");
199
+ lease.resizeRevision = revision;
200
+ lease.lastUsed = Date.now();
201
+ }
202
+ requestResize(requester, requestSequence, cols, rows) {
203
+ if (this.#failure !== undefined)
204
+ return Promise.reject(this.#failure);
205
+ if (this.#exit !== undefined)
206
+ return Promise.reject(new Error("Terminal has exited."));
207
+ validateDimensions(cols, rows);
208
+ const operation = this.#resizeOperation.then(async () => {
209
+ await this.#options.onBeforeResize?.();
210
+ const barrier = this.#outputOffset;
211
+ this.#resizing = true;
212
+ try {
213
+ await this.#options.onResize(cols, rows);
214
+ this.#cols = cols;
215
+ this.#rows = rows;
216
+ this.#lastResizeBarrier = barrier;
217
+ this.#resizeRevision += 1;
218
+ for (const connection of this.#connections) {
219
+ connection.sendResize(cols, rows, barrier, this.#resizeRevision, connection === requester ? requestSequence : 0);
220
+ }
221
+ }
222
+ finally {
223
+ this.#resizing = false;
224
+ this.#flushDeferredOutput();
225
+ }
226
+ });
227
+ this.#resizeOperation = operation.catch(() => undefined);
228
+ return operation;
229
+ }
230
+ resize(cols, rows) {
231
+ return this.requestResize(undefined, 0, cols, rows);
232
+ }
233
+ scrollback(start, count, basis) {
234
+ if (this.#options.onScrollback === undefined)
235
+ throw new Error("Scrollback paging is unavailable.");
236
+ return this.#options.onScrollback(start, count, basis);
237
+ }
238
+ setFlowPaused(connection, paused) {
239
+ const wasPaused = this.#pausedConnections.size > 0;
240
+ if (paused)
241
+ this.#pausedConnections.add(connection);
242
+ else
243
+ this.#pausedConnections.delete(connection);
244
+ const isPaused = this.#pausedConnections.size > 0;
245
+ if (wasPaused !== isPaused)
246
+ this.#options.onFlowControl?.(isPaused);
247
+ }
248
+ encode(packet) {
249
+ if (packet.payload.length > this.#maxFrameBytes) {
250
+ throw new Error("Outbound remote terminal frame is too large.");
251
+ }
252
+ const encoded = encodeWirePacket(packet);
253
+ this.metrics.encodedPackets += 1;
254
+ this.metrics.payloadBytes += encoded.payloadBytes;
255
+ if (encoded.compressed)
256
+ this.metrics.compressedPackets += 1;
257
+ return { data: encoded.data };
258
+ }
259
+ gridPacket(grid) {
260
+ let packet = this.#gridPackets.get(grid.revision);
261
+ if (packet === undefined) {
262
+ packet = this.encode({
263
+ payload: encodeJsonPayload(grid),
264
+ sequence: grid.revision,
265
+ type: WirePacketType.GridKeyframe,
266
+ });
267
+ this.#gridPackets.set(grid.revision, packet);
268
+ }
269
+ return packet;
270
+ }
271
+ dimensions() {
272
+ return { cols: this.#cols, rows: this.#rows };
273
+ }
274
+ maxBufferedBytes() {
275
+ return this.#maxBufferedBytes;
276
+ }
277
+ exitState() {
278
+ return this.#exit;
279
+ }
280
+ grid() {
281
+ return this.#grid;
282
+ }
283
+ lastResizeBarrier() {
284
+ return this.#lastResizeBarrier;
285
+ }
286
+ maxUnacknowledgedBytes() {
287
+ return this.#maxUnacknowledgedBytes;
288
+ }
289
+ parserFingerprint() {
290
+ return this.#options.parserFingerprint ?? "libghostty-vt/0.2/defaults";
291
+ }
292
+ oldestOutputOffset() {
293
+ return this.#outputChunks[0]?.start ?? this.#outputOffset;
294
+ }
295
+ outputOffset() {
296
+ return this.#outputOffset;
297
+ }
298
+ resizeRevision() {
299
+ return this.#resizeRevision;
300
+ }
301
+ replayAfter(offset) {
302
+ return this.#outputChunks
303
+ .filter((chunk) => chunk.end > offset)
304
+ .map((chunk) => offset > chunk.start
305
+ ? this.#makeOutputChunk(chunk.data.subarray(offset - chunk.start), offset)
306
+ : chunk);
307
+ }
308
+ wireChunkBytes() {
309
+ return this.#wireChunkBytes;
310
+ }
311
+ fail(error) {
312
+ if (this.#failure !== undefined)
313
+ return;
314
+ this.#failure = error instanceof Error ? error : new Error(String(error));
315
+ for (const connection of this.#connections)
316
+ connection.fail(this.#failure);
317
+ }
318
+ #commitGrid(state) {
319
+ const previous = this.#grid;
320
+ const next = { ...state, revision: ++this.#gridRevision };
321
+ this.#grid = next;
322
+ this.#gridPackets.clear();
323
+ for (const connection of this.#connections)
324
+ connection.publishGrid(previous, next);
325
+ }
326
+ #commitOutput(data) {
327
+ if (data.length === 0)
328
+ return;
329
+ if (data.length > this.#wireChunkBytes) {
330
+ for (let offset = 0; offset < data.length; offset += this.#wireChunkBytes) {
331
+ this.#commitOutput(data.subarray(offset, offset + this.#wireChunkBytes));
332
+ }
333
+ return;
334
+ }
335
+ const chunk = this.#makeOutputChunk(data, this.#outputOffset);
336
+ this.#outputOffset = chunk.end;
337
+ this.#outputChunks.push(chunk);
338
+ this.#replayBytes += data.length;
339
+ while (this.#replayBytes > this.#maxReplayBytes && this.#outputChunks.length > 0) {
340
+ const removed = this.#outputChunks.shift();
341
+ this.#replayBytes -= removed.data.length;
342
+ }
343
+ for (const connection of this.#connections)
344
+ connection.publishOutput(chunk);
345
+ }
346
+ #flushDeferredOutput() {
347
+ const output = this.#deferredOutput.splice(0);
348
+ const updates = this.#deferredUpdates.splice(0);
349
+ const grids = this.#deferredGrid.splice(0);
350
+ this.#deferredBytes = 0;
351
+ if (this.#failure !== undefined)
352
+ return;
353
+ for (const data of output)
354
+ this.#commitOutput(data);
355
+ for (const update of updates) {
356
+ this.#commitOutput(update.data);
357
+ this.#commitGrid({ ...update.state, coversOutputOffset: this.#outputOffset });
358
+ }
359
+ for (const grid of grids)
360
+ this.#commitGrid(grid);
361
+ }
362
+ #reserveDeferredBytes(length) {
363
+ if (this.#deferredBytes + length > this.#maxBufferedBytes) {
364
+ throw new Error("Resize output buffer is full.");
365
+ }
366
+ this.#deferredBytes += length;
367
+ }
368
+ #makeOutputChunk(data, start) {
369
+ const end = start + data.length;
370
+ return {
371
+ data,
372
+ encoded: this.encode({ payload: data, sequence: end, type: WirePacketType.Output }),
373
+ end,
374
+ start,
375
+ };
376
+ }
377
+ }
378
+ class ServerConnection {
379
+ #acknowledgedOutput = 0;
380
+ #blocked = false;
381
+ #capabilities = { grid: false, vt: false };
382
+ #closed = false;
383
+ #creditBytes = 0;
384
+ #decoder;
385
+ #exitSent = false;
386
+ #gridAcknowledged;
387
+ #gridInFlight;
388
+ #initialized = false;
389
+ #inputLease;
390
+ #lastResizeRequest = 0;
391
+ #maxSentOutput = 0;
392
+ #mode = "vt";
393
+ #needsGridOffset;
394
+ #operation = Promise.resolve();
395
+ #pendingGrid;
396
+ #pendingVtBytes = 0;
397
+ #pendingVt = [];
398
+ #server;
399
+ #stream;
400
+ constructor(server, stream, maxFrameBytes) {
401
+ this.#server = server;
402
+ this.#stream = stream;
403
+ this.#decoder = new WirePacketDecoder(maxFrameBytes);
404
+ stream.on("data", (data) => {
405
+ try {
406
+ const packets = this.#decoder.push(data);
407
+ if (packets.length > 0)
408
+ stream.pause();
409
+ for (const packet of packets)
410
+ this.#operation = this.#operation.then(() => this.#receive(packet));
411
+ void this.#operation
412
+ .then(() => {
413
+ if (!this.#closed)
414
+ stream.resume();
415
+ })
416
+ .catch((error) => {
417
+ this.#sendError(error);
418
+ this.close();
419
+ });
420
+ }
421
+ catch (error) {
422
+ this.#sendError(error);
423
+ this.close();
424
+ }
425
+ });
426
+ stream.on("drain", () => this.#drain());
427
+ stream.on("error", () => this.close());
428
+ stream.on("close", () => this.close());
429
+ }
430
+ close() {
431
+ if (this.#closed)
432
+ return;
433
+ this.#closed = true;
434
+ this.#server.releaseInputLease(this);
435
+ this.#server.setFlowPaused(this, false);
436
+ this.#stream.destroy();
437
+ }
438
+ fail(error) {
439
+ this.#sendError(error);
440
+ this.close();
441
+ }
442
+ publishGrid(previous, next) {
443
+ if (!this.#initialized)
444
+ return;
445
+ if (this.#needsGridOffset !== undefined &&
446
+ this.#capabilities.grid &&
447
+ next.coversOutputOffset >= this.#needsGridOffset) {
448
+ this.#switchToGrid(next);
449
+ return;
450
+ }
451
+ if (this.#mode !== "grid")
452
+ return;
453
+ if (this.#blocked || this.#gridInFlight !== undefined) {
454
+ this.#pendingGrid = next;
455
+ return;
456
+ }
457
+ this.#sendGrid(previous, next);
458
+ }
459
+ publishOutput(chunk) {
460
+ if (!this.#initialized || this.#mode !== "vt")
461
+ return;
462
+ if (this.#needsGridOffset !== undefined) {
463
+ this.#needsGridOffset = chunk.end;
464
+ if (!this.#capabilities.grid)
465
+ this.#queueVt(chunk);
466
+ return;
467
+ }
468
+ if (chunk.end - this.#acknowledgedOutput > this.#creditBytes) {
469
+ this.#needsGridOffset = chunk.end;
470
+ if (this.#capabilities.grid) {
471
+ const grid = this.#server.grid();
472
+ if (grid !== undefined && grid.coversOutputOffset >= chunk.end)
473
+ this.#switchToGrid(grid);
474
+ }
475
+ else {
476
+ this.#queueVt(chunk);
477
+ this.#server.setFlowPaused(this, true);
478
+ }
479
+ return;
480
+ }
481
+ this.#sendOutput(chunk);
482
+ }
483
+ sendResize(cols, rows, barrier, resizeRevision, requestSequence) {
484
+ this.#sendPacket({
485
+ payload: encodeJsonPayload({ barrier, cols, requestSequence, resizeRevision, rows }),
486
+ sequence: barrier,
487
+ type: WirePacketType.ResizeAck,
488
+ });
489
+ }
490
+ maybeSendExit() {
491
+ const exit = this.#server.exitState();
492
+ if (exit === undefined || this.#exitSent || !this.#initialized)
493
+ return;
494
+ if (this.#mode === "vt") {
495
+ if (this.#needsGridOffset !== undefined || this.#maxSentOutput < exit.outputOffset)
496
+ return;
497
+ }
498
+ else if ((this.#gridAcknowledged?.coversOutputOffset ?? -1) < exit.outputOffset) {
499
+ const grid = this.#server.grid();
500
+ if (grid !== undefined &&
501
+ grid.coversOutputOffset >= exit.outputOffset &&
502
+ this.#gridInFlight === undefined)
503
+ this.#sendGridKeyframe(grid);
504
+ return;
505
+ }
506
+ this.#sendPacket({
507
+ payload: encodeJsonPayload(exit),
508
+ sequence: exit.outputOffset,
509
+ type: WirePacketType.Exit,
510
+ });
511
+ this.#exitSent = true;
512
+ }
513
+ #drain() {
514
+ this.#blocked = false;
515
+ this.#flushGrid();
516
+ }
517
+ async #initialize(packet) {
518
+ if (packet.type !== WirePacketType.ClientHello)
519
+ throw new Error("Client hello is required.");
520
+ if (packet.payload.length > 4_096)
521
+ throw new Error("Client hello is too large.");
522
+ const hello = decodeJsonPayload(packet.payload);
523
+ validateHello(hello);
524
+ const lease = this.#server.claimInputLease(hello.inputLease, this);
525
+ this.#inputLease = lease.token;
526
+ this.#capabilities = hello.capabilities;
527
+ this.#creditBytes = Math.min(hello.creditBytes, this.#server.maxUnacknowledgedBytes());
528
+ if (hello.creditBytes < this.#server.wireChunkBytes()) {
529
+ throw new Error("Client credit is smaller than the server wire chunk.");
530
+ }
531
+ const epochMatches = (hello.epoch === undefined && hello.resumeOutputOffset === 0) ||
532
+ hello.epoch === this.#server.epoch;
533
+ const freshReplay = hello.inputLease === undefined &&
534
+ hello.epoch === undefined &&
535
+ hello.resumeOutputOffset === 0 &&
536
+ this.#server.resizeRevision() === 0;
537
+ const leasedReplay = hello.inputLease !== undefined &&
538
+ epochMatches &&
539
+ hello.resumeOutputOffset === lease.outputOffset &&
540
+ lease.resizeRevision === this.#server.resizeRevision();
541
+ const canReplay = hello.capabilities.vt &&
542
+ hello.parserFingerprint === this.#server.parserFingerprint() &&
543
+ (freshReplay || leasedReplay) &&
544
+ hello.resumeOutputOffset >= this.#server.oldestOutputOffset() &&
545
+ hello.resumeOutputOffset <= this.#server.outputOffset();
546
+ if (!canReplay && !hello.capabilities.grid)
547
+ throw new Error("Terminal replay is unavailable.");
548
+ this.#mode = canReplay ? "vt" : "grid";
549
+ this.#acknowledgedOutput = canReplay ? hello.resumeOutputOffset : 0;
550
+ const dimensions = this.#server.dimensions();
551
+ this.#sendPacket({
552
+ payload: encodeJsonPayload({
553
+ ...dimensions,
554
+ epoch: this.#server.epoch,
555
+ gridRevision: this.#server.grid()?.revision ?? 0,
556
+ inputLease: lease.token,
557
+ inputSequence: lease.sequence,
558
+ resizeRevision: this.#server.resizeRevision(),
559
+ mode: this.#mode,
560
+ oldestOutputOffset: this.#server.oldestOutputOffset(),
561
+ outputOffset: this.#server.outputOffset(),
562
+ }),
563
+ sequence: this.#server.outputOffset(),
564
+ type: WirePacketType.Welcome,
565
+ });
566
+ this.#initialized = true;
567
+ if (this.#mode === "vt") {
568
+ for (const chunk of this.#server.replayAfter(hello.resumeOutputOffset))
569
+ this.publishOutput(chunk);
570
+ const grid = this.#server.grid();
571
+ if (this.#needsGridOffset !== undefined &&
572
+ grid !== undefined &&
573
+ grid.coversOutputOffset >= this.#needsGridOffset)
574
+ this.#switchToGrid(grid);
575
+ }
576
+ else {
577
+ const grid = this.#server.grid();
578
+ if (grid === undefined || grid.coversOutputOffset < this.#server.outputOffset())
579
+ throw new Error("A current semantic terminal keyframe is unavailable.");
580
+ this.#sendGridKeyframe(grid);
581
+ }
582
+ this.maybeSendExit();
583
+ }
584
+ async #receive(packet) {
585
+ if (!this.#initialized)
586
+ return this.#initialize(packet);
587
+ if (packet.type === WirePacketType.OutputAck) {
588
+ if (packet.payload.length !== 0 ||
589
+ packet.sequence < this.#acknowledgedOutput ||
590
+ packet.sequence > this.#maxSentOutput)
591
+ throw new Error("Invalid output acknowledgement.");
592
+ this.#acknowledgedOutput = packet.sequence;
593
+ this.#server.acknowledgeOutput(this.#inputLease, packet.sequence);
594
+ this.#flushPendingVt();
595
+ this.maybeSendExit();
596
+ return;
597
+ }
598
+ if (packet.type === WirePacketType.GridAck) {
599
+ if (packet.payload.length !== 0 ||
600
+ this.#gridInFlight === undefined ||
601
+ packet.sequence !== this.#gridInFlight.revision)
602
+ throw new Error("Invalid grid acknowledgement.");
603
+ this.#gridAcknowledged = this.#gridInFlight;
604
+ this.#gridInFlight = undefined;
605
+ this.#flushGrid();
606
+ this.maybeSendExit();
607
+ return;
608
+ }
609
+ if (packet.type === WirePacketType.Input) {
610
+ if (packet.payload.length > 64 * 1024)
611
+ throw new Error("Terminal input is too large.");
612
+ await this.#server.receiveInput(this.#inputLease, packet.sequence, packet.payload);
613
+ this.#sendPacket({
614
+ payload: Buffer.alloc(0),
615
+ sequence: packet.sequence,
616
+ type: WirePacketType.InputAck,
617
+ });
618
+ return;
619
+ }
620
+ if (packet.type === WirePacketType.Resize) {
621
+ if (packet.payload.length > 1_024 || packet.sequence !== this.#lastResizeRequest + 1)
622
+ throw new Error("Invalid resize sequence.");
623
+ this.#lastResizeRequest = packet.sequence;
624
+ const value = decodeJsonPayload(packet.payload);
625
+ await this.#server.requestResize(this, packet.sequence, value.cols, value.rows);
626
+ return;
627
+ }
628
+ if (packet.type === WirePacketType.ResizeApplied) {
629
+ if (packet.payload.length !== 0)
630
+ throw new Error("Invalid resize acknowledgement.");
631
+ this.#server.acknowledgeResize(this.#inputLease, packet.sequence);
632
+ return;
633
+ }
634
+ if (packet.type === WirePacketType.Resync) {
635
+ if (packet.payload.length !== 0)
636
+ throw new Error("Invalid resync request.");
637
+ const grid = this.#server.grid();
638
+ if (grid === undefined || grid.coversOutputOffset < this.#server.outputOffset())
639
+ throw new Error("A current semantic terminal keyframe is unavailable.");
640
+ this.#switchToGrid(grid);
641
+ return;
642
+ }
643
+ if (packet.type === WirePacketType.ScrollbackRequest) {
644
+ if (packet.payload.length > 1_024)
645
+ throw new Error("Scrollback request is too large.");
646
+ const value = decodeJsonPayload(packet.payload);
647
+ validateScrollbackRequest(value);
648
+ const page = await this.#server.scrollback(value.start, value.count, value.basis);
649
+ validateScrollbackPage(page, value.start, value.count);
650
+ this.#sendPacket({
651
+ payload: encodeJsonPayload(page),
652
+ sequence: packet.sequence,
653
+ type: WirePacketType.ScrollbackPage,
654
+ });
655
+ return;
656
+ }
657
+ throw new Error("Packet is invalid in the current server state.");
658
+ }
659
+ #flushGrid() {
660
+ const grid = this.#pendingGrid;
661
+ if (grid !== undefined && !this.#blocked && this.#gridInFlight === undefined) {
662
+ this.#pendingGrid = undefined;
663
+ this.#sendGridKeyframe(grid);
664
+ }
665
+ }
666
+ #flushPendingVt() {
667
+ while (this.#pendingVt.length > 0) {
668
+ const next = this.#pendingVt[0];
669
+ if (next.end - this.#acknowledgedOutput > this.#creditBytes)
670
+ break;
671
+ this.#pendingVt.shift();
672
+ this.#pendingVtBytes -= next.data.length;
673
+ this.#sendOutput(next);
674
+ }
675
+ if (this.#pendingVt.length === 0) {
676
+ this.#needsGridOffset = undefined;
677
+ this.#server.setFlowPaused(this, false);
678
+ }
679
+ }
680
+ #queueVt(chunk) {
681
+ if (this.#pendingVtBytes + chunk.data.length > this.#server.maxBufferedBytes()) {
682
+ this.fail(new Error("Client terminal output buffer is full."));
683
+ return;
684
+ }
685
+ this.#pendingVt.push(chunk);
686
+ this.#pendingVtBytes += chunk.data.length;
687
+ }
688
+ #sendEncoded(packet) {
689
+ if (this.#closed)
690
+ return;
691
+ this.#server.metrics.wireBytes += packet.data.length;
692
+ this.#blocked ||= !this.#stream.write(packet.data);
693
+ }
694
+ #sendPacket(packet) {
695
+ this.#sendEncoded(this.#server.encode(packet));
696
+ }
697
+ #sendError(error) {
698
+ this.#sendPacket({
699
+ payload: encodeJsonPayload({
700
+ error: error instanceof Error ? error.message : String(error),
701
+ }),
702
+ sequence: 0,
703
+ type: WirePacketType.Error,
704
+ });
705
+ }
706
+ #sendGrid(previous, grid) {
707
+ const base = this.#gridAcknowledged;
708
+ const patch = base === undefined || previous?.revision !== base.revision
709
+ ? undefined
710
+ : diffGridState(base, grid);
711
+ if (patch === undefined)
712
+ this.#sendEncoded(this.#server.gridPacket(grid));
713
+ else
714
+ this.#sendPacket({
715
+ payload: encodeJsonPayload(patch),
716
+ sequence: grid.revision,
717
+ type: WirePacketType.GridPatch,
718
+ });
719
+ this.#gridInFlight = grid;
720
+ }
721
+ #sendGridKeyframe(grid) {
722
+ this.#sendEncoded(this.#server.gridPacket(grid));
723
+ this.#gridInFlight = grid;
724
+ }
725
+ #sendOutput(chunk) {
726
+ this.#sendEncoded(chunk.encoded);
727
+ this.#maxSentOutput = Math.max(this.#maxSentOutput, chunk.end);
728
+ }
729
+ #switchToGrid(grid) {
730
+ if (this.#gridInFlight !== undefined)
731
+ this.#pendingGrid = grid;
732
+ this.#mode = "grid";
733
+ this.#needsGridOffset = undefined;
734
+ this.#pendingVt.length = 0;
735
+ this.#pendingVtBytes = 0;
736
+ this.#server.setFlowPaused(this, false);
737
+ this.#sendPacket({
738
+ payload: encodeJsonPayload({ mode: "grid" }),
739
+ sequence: grid.revision,
740
+ type: WirePacketType.Mode,
741
+ });
742
+ if (this.#gridInFlight === undefined)
743
+ this.#sendGridKeyframe(grid);
744
+ }
745
+ }
746
+ function boundedInteger(value, minimum, maximum, name) {
747
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
748
+ throw new Error(`Invalid ${name}.`);
749
+ return value;
750
+ }
751
+ function validateDimensions(cols, rows) {
752
+ boundedInteger(cols, 1, 1_000, "terminal columns");
753
+ boundedInteger(rows, 1, 1_000, "terminal rows");
754
+ }
755
+ function validateString(value, name, maximumLength) {
756
+ if (typeof value !== "string" || value.length < 1 || value.length > maximumLength)
757
+ throw new Error(`Invalid ${name}.`);
758
+ }
759
+ function validateHello(hello) {
760
+ if (typeof hello !== "object" || hello === null)
761
+ throw new Error("Invalid client hello.");
762
+ validateString(hello.clientId, "client identifier", 128);
763
+ validateString(hello.parserFingerprint, "parser fingerprint", 256);
764
+ boundedInteger(hello.creditBytes, 1, 64 * 1024 * 1024, "client credit");
765
+ boundedInteger(hello.resumeOutputOffset, 0, Number.MAX_SAFE_INTEGER, "resume output offset");
766
+ if (hello.epoch !== undefined)
767
+ validateString(hello.epoch, "terminal epoch", 128);
768
+ if (typeof hello.capabilities !== "object" ||
769
+ hello.capabilities === null ||
770
+ typeof hello.capabilities.grid !== "boolean" ||
771
+ typeof hello.capabilities.vt !== "boolean" ||
772
+ (!hello.capabilities.grid && !hello.capabilities.vt))
773
+ throw new Error("Invalid client capabilities.");
774
+ }
775
+ function validateScrollbackRequest(value) {
776
+ boundedInteger(value.start, 0, Number.MAX_SAFE_INTEGER, "scrollback start");
777
+ boundedInteger(value.count, 1, 1_000, "scrollback count");
778
+ if (value.basis !== undefined) {
779
+ validateString(value.basis.historyEpoch, "history epoch", 128);
780
+ boundedInteger(value.basis.historyRevision, 0, Number.MAX_SAFE_INTEGER, "history revision");
781
+ }
782
+ }
783
+ function validateScrollbackPage(page, start, count) {
784
+ validateString(page.historyEpoch, "history epoch", 128);
785
+ boundedInteger(page.historyRevision, 0, Number.MAX_SAFE_INTEGER, "history revision");
786
+ boundedInteger(page.baseRow, 0, Number.MAX_SAFE_INTEGER, "history base row");
787
+ boundedInteger(page.start, 0, Number.MAX_SAFE_INTEGER, "scrollback page start");
788
+ boundedInteger(page.totalRows, 0, Number.MAX_SAFE_INTEGER, "scrollback total rows");
789
+ if (page.start !== start ||
790
+ page.count !== count ||
791
+ !Array.isArray(page.rows) ||
792
+ page.rows.length > count)
793
+ throw new Error("Invalid scrollback page.");
794
+ }