@debugg-ai/debugg-ai-mcp 4.2.2 → 4.4.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 (34) hide show
  1. package/README.md +10 -4
  2. package/dist/handlers/createEnvironmentHandler.js +10 -0
  3. package/dist/handlers/probePageHandler.js +14 -7
  4. package/dist/handlers/runTestSuiteHandler.js +6 -5
  5. package/dist/handlers/testPageChangesHandler.js +20 -10
  6. package/dist/handlers/triggerCrawlHandler.js +7 -6
  7. package/dist/handlers/updateEnvironmentHandler.js +14 -2
  8. package/dist/services/index.js +21 -0
  9. package/dist/services/ngrok/tunnelManager.js +157 -161
  10. package/dist/services/tunnel/debuggTransport.js +439 -0
  11. package/dist/services/tunnel/ngrokTransport.js +165 -0
  12. package/dist/services/tunnel/probeRegistry.js +52 -0
  13. package/dist/services/tunnel/protocol/codec.js +327 -0
  14. package/dist/services/tunnel/protocol/conformance.js +171 -0
  15. package/dist/services/tunnel/protocol/constants.js +178 -0
  16. package/dist/services/tunnel/protocol/errors.js +51 -0
  17. package/dist/services/tunnel/protocol/handshake.js +134 -0
  18. package/dist/services/tunnel/protocol/index.js +17 -0
  19. package/dist/services/tunnel/protocol/session.js +668 -0
  20. package/dist/services/tunnel/protocol/stream.js +109 -0
  21. package/dist/services/tunnel/protocol/transport.js +106 -0
  22. package/dist/services/tunnel/transport.js +15 -0
  23. package/dist/services/tunnels.js +71 -3
  24. package/dist/services/verdictAdapter.js +25 -1
  25. package/dist/tools/environment.js +5 -2
  26. package/dist/tools/testPageChanges.js +1 -1
  27. package/dist/types/index.js +29 -2
  28. package/dist/utils/authorizedCredentialHosts.js +38 -0
  29. package/dist/utils/localReachability.js +160 -3
  30. package/dist/utils/tunnelContext.js +6 -3
  31. package/dist/utils/tunnelDisposition.js +16 -1
  32. package/dist/utils/tunnelDomains.js +113 -0
  33. package/dist/utils/urlParser.js +8 -2
  34. package/package.json +3 -1
@@ -0,0 +1,668 @@
1
+ /**
2
+ * Debugg tunnel wire protocol v1 — the multiplexed session.
3
+ *
4
+ * One TunnelSession owns one websocket and every stream on it. The two roles are
5
+ * asymmetric in exactly two ways: only a 'server' session opens streams and
6
+ * answers PROBEs, and only a 'client' session receives streams and sends PROBEs.
7
+ * Everything else — flow control, scheduling, liveness, GOAWAY — is symmetric.
8
+ *
9
+ * Events:
10
+ * 'stream' (stream: TunnelStream) client role: the peer opened a stream
11
+ * 'goaway' (info: GoAwayInfo) the peer is shutting down
12
+ * 'close' (info: SessionCloseInfo) the session is over; fires exactly once
13
+ * 'error' (err: Error) non-fatal diagnostics, only if listened for
14
+ *
15
+ * Spec: bead debugg_ai_mcp-xkoh.1.2.
16
+ */
17
+ import { EventEmitter } from 'node:events';
18
+ import { decodeFrame, encodeFrame, } from './codec.js';
19
+ import { ProtocolError, SessionClosedError, StreamResetError } from './errors.js';
20
+ import { TunnelStream } from './stream.js';
21
+ import { CLOSE_CODE_BASE, CloseCode, DEAD_PEER_TIMEOUT_MS, ErrorCode, INITIAL_WINDOW, MAX_CONCURRENT_STREAMS, MAX_DATA_PAYLOAD, MAX_GOAWAY_REASON_BYTES, MAX_STREAM_ID, MAX_UNACKED_SEND_BYTES, MAX_WINDOW, PING_INTERVAL_MS, PROBE_TIMEOUT_MS, WINDOW_UPDATE_THRESHOLD, } from './constants.js';
22
+ /**
23
+ * How long a GOAWAY'd session waits before giving up on the drain. A server
24
+ * should pass its own (ECS stopTimeout); this default only exists so a session
25
+ * that goes away with nothing to drain still closes eventually.
26
+ */
27
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
28
+ const KNOWN_ERROR_CODES = new Set(Object.values(ErrorCode));
29
+ /** Unknown codes must never be special-cased; they read as a plain reset. */
30
+ function normalizeResetCode(code) {
31
+ return KNOWN_ERROR_CODES.has(code) ? code : ErrorCode.RESET;
32
+ }
33
+ function truncateReason(reason) {
34
+ const buf = Buffer.from(reason, 'utf8');
35
+ return buf.length <= MAX_GOAWAY_REASON_BYTES ? reason : buf.subarray(0, MAX_GOAWAY_REASON_BYTES).toString('utf8');
36
+ }
37
+ function unref(timer) {
38
+ timer.unref?.();
39
+ return timer;
40
+ }
41
+ export class TunnelSession extends EventEmitter {
42
+ role;
43
+ transport;
44
+ onProbe;
45
+ streams = new Map();
46
+ /** Round-robin ring of stream ids with something to send. */
47
+ ready = [];
48
+ /** Control frames jump ahead of queued DATA. */
49
+ control = [];
50
+ pendingPings = new Map();
51
+ pendingProbes = new Map();
52
+ goAwayWaiters = [];
53
+ unackedBytes = 0;
54
+ pumping = false;
55
+ nextStreamId = 1;
56
+ highestStreamId = 0;
57
+ nextProbeId = 1;
58
+ nextNonce = 1n;
59
+ goAwaySent = false;
60
+ goAwayReceived = false;
61
+ drainStarted = false;
62
+ isClosed = false;
63
+ closeCode;
64
+ pingIntervalMs;
65
+ deadPeerTimeoutMs;
66
+ pingTimer;
67
+ deadTimer;
68
+ drainTimer;
69
+ constructor(transport, options) {
70
+ super();
71
+ this.role = options.role;
72
+ this.transport = transport;
73
+ this.onProbe = options.onProbe;
74
+ this.pingIntervalMs = options.pingIntervalMs ?? PING_INTERVAL_MS;
75
+ this.deadPeerTimeoutMs = options.deadPeerTimeoutMs ?? DEAD_PEER_TIMEOUT_MS;
76
+ transport.setHandlers({
77
+ onFrame: (bytes) => this.handleBytes(bytes),
78
+ onInvalidMessage: (detail) => this.fatal(ErrorCode.PROTOCOL_ERROR, `not a protocol frame: ${detail}`),
79
+ onClose: (code, reason) => this.finish(code, reason, true),
80
+ });
81
+ this.pingTimer = unref(setInterval(() => {
82
+ // Liveness only: no RTT to report, so no promise to leave dangling.
83
+ this.queueControl({ type: 'PING', streamId: 0, nonce: this.nextNonce++ });
84
+ }, this.pingIntervalMs));
85
+ this.armDeadPeerTimer();
86
+ }
87
+ /** Streams that are not yet fully closed, i.e. what counts against the cap. */
88
+ get activeStreamCount() {
89
+ return this.streams.size;
90
+ }
91
+ get closed() {
92
+ return this.isClosed;
93
+ }
94
+ // ── public API ─────────────────────────────────────────────────────────────
95
+ /**
96
+ * Server role only. Throws StreamResetError(REFUSED) at MAX_CONCURRENT_STREAMS
97
+ * or once GOAWAY has been sent or received, and SessionClosedError once closed.
98
+ */
99
+ openStream(metadata = {}) {
100
+ if (this.role !== 'server')
101
+ throw new Error('only a server session opens streams');
102
+ if (this.isClosed)
103
+ throw new SessionClosedError(this.closeCode ?? CloseCode.GOING_AWAY);
104
+ if (this.goAwaySent || this.goAwayReceived) {
105
+ throw new StreamResetError(ErrorCode.REFUSED, 'session is going away', false);
106
+ }
107
+ if (this.streams.size >= MAX_CONCURRENT_STREAMS) {
108
+ throw new StreamResetError(ErrorCode.REFUSED, `already at ${MAX_CONCURRENT_STREAMS} concurrent streams`, false);
109
+ }
110
+ if (this.nextStreamId > MAX_STREAM_ID) {
111
+ throw new StreamResetError(ErrorCode.REFUSED, 'stream ids exhausted; reconnect', false);
112
+ }
113
+ const streamId = this.nextStreamId++;
114
+ this.highestStreamId = streamId;
115
+ // encodeFrame normalises the metadata, so nothing address-shaped is emitted.
116
+ const open = { type: 'OPEN', streamId, metadata };
117
+ const record = this.createRecord(streamId, decodeFrame(encodeFrame(open)));
118
+ this.queueControl(open);
119
+ return record.stream;
120
+ }
121
+ /** Send PING; resolves with the round trip in ms when the PONG comes back. */
122
+ ping() {
123
+ if (this.isClosed)
124
+ return Promise.reject(new SessionClosedError(this.closeCode ?? CloseCode.NORMAL));
125
+ const nonce = this.nextNonce++;
126
+ const sentAt = Date.now();
127
+ return new Promise((resolve, reject) => {
128
+ this.pendingPings.set(nonce.toString(), { resolve, reject, sentAt });
129
+ this.queueControl({ type: 'PING', streamId: 0, nonce });
130
+ });
131
+ }
132
+ /**
133
+ * Client role only. Never throws or rejects — a deadline resolves
134
+ * `{ error: 'TIMEOUT' }` and a closed session `{ error: 'CLOSED' }`, because
135
+ * probeTunnelHealth's contract is that a probe always answers.
136
+ */
137
+ probe(path, opts = {}) {
138
+ if (this.role !== 'client')
139
+ throw new Error('only a client session sends probes');
140
+ const startedAt = Date.now();
141
+ if (this.isClosed)
142
+ return Promise.resolve({ error: 'CLOSED', elapsedMs: 0 });
143
+ const probeId = this.nextProbeId++;
144
+ const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
145
+ return new Promise((resolve) => {
146
+ const timer = unref(setTimeout(() => {
147
+ this.pendingProbes.delete(probeId);
148
+ resolve({ error: 'TIMEOUT', elapsedMs: Date.now() - startedAt });
149
+ }, timeoutMs));
150
+ this.pendingProbes.set(probeId, { resolve, timer, startedAt });
151
+ this.queueControl({ type: 'PROBE', streamId: probeId, request: { path } });
152
+ });
153
+ }
154
+ /**
155
+ * Send GOAWAY, refuse further OPENs in both directions, and close once every
156
+ * open stream has finished or the drain deadline passes.
157
+ */
158
+ goAway(opts = {}) {
159
+ if (this.isClosed)
160
+ return Promise.resolve();
161
+ if (!this.goAwaySent) {
162
+ this.goAwaySent = true;
163
+ this.drainStarted = true;
164
+ this.queueControl({
165
+ type: 'GOAWAY',
166
+ streamId: 0,
167
+ code: opts.code ?? ErrorCode.GOING_AWAY,
168
+ reason: truncateReason(opts.reason ?? ''),
169
+ });
170
+ // The socket deliberately stays open even with nothing to drain: the peer
171
+ // may still have OPENs in flight, and those must be refused rather than
172
+ // dropped on the floor.
173
+ this.drainTimer = unref(setTimeout(() => this.endDrain(true), opts.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS));
174
+ }
175
+ return new Promise((resolve) => {
176
+ this.goAwayWaiters.push(resolve);
177
+ });
178
+ }
179
+ /** Tear the session down now: fail open streams and close the socket. */
180
+ close(code = ErrorCode.GOING_AWAY, reason = '') {
181
+ if (this.isClosed)
182
+ return;
183
+ this.sendImmediate({ type: 'GOAWAY', streamId: 0, code, reason: truncateReason(reason) });
184
+ this.closeTransport(CLOSE_CODE_BASE + code, reason);
185
+ }
186
+ // ── StreamHost (called by TunnelStream) ────────────────────────────────────
187
+ streamWrite(stream, chunk, cb) {
188
+ const record = this.streams.get(stream.id);
189
+ if (!record) {
190
+ // The stream is already gone (session closed, or reset). Settling without
191
+ // an error is deliberate: node turns an error handed to a write callback
192
+ // into an 'error' on the stream, which would be a second report of a
193
+ // teardown the stream is already reporting — and a crash if the consumer
194
+ // only listened for 'close'.
195
+ cb(null);
196
+ return;
197
+ }
198
+ if (chunk.length === 0) {
199
+ cb(null);
200
+ return;
201
+ }
202
+ record.queue.push({ chunk, offset: 0, cb });
203
+ this.markReady(record);
204
+ this.pump();
205
+ }
206
+ streamFinal(stream, cb) {
207
+ const record = this.streams.get(stream.id);
208
+ if (!record) {
209
+ cb(null);
210
+ return;
211
+ }
212
+ record.finalPending = true;
213
+ record.finalCb = cb;
214
+ this.markReady(record);
215
+ this.pump();
216
+ }
217
+ streamDestroy(stream, err, resetCode) {
218
+ const record = this.streams.get(stream.id);
219
+ if (!record)
220
+ return;
221
+ this.streams.delete(stream.id);
222
+ // Settled without an error on purpose: the stream is being destroyed in the
223
+ // same breath, and handing an error to a write callback makes node emit a
224
+ // SECOND 'error' on a stream that is already reporting the real one.
225
+ this.settleJobs(record);
226
+ if (!record.suppressReset) {
227
+ const code = resetCode ?? (err instanceof StreamResetError ? err.code : err ? ErrorCode.RESET : ErrorCode.RESET);
228
+ this.queueControl({ type: 'RST', streamId: stream.id, code });
229
+ }
230
+ this.afterStreamClosed();
231
+ }
232
+ streamConsumed(stream, bytes) {
233
+ const record = this.streams.get(stream.id);
234
+ if (!record)
235
+ return;
236
+ record.unannounced += bytes;
237
+ if (record.unannounced >= WINDOW_UPDATE_THRESHOLD) {
238
+ const increment = record.unannounced;
239
+ record.unannounced = 0;
240
+ record.receiveAllowance += increment;
241
+ this.queueControl({ type: 'WINDOW', streamId: stream.id, increment });
242
+ }
243
+ }
244
+ // ── inbound ────────────────────────────────────────────────────────────────
245
+ handleBytes(bytes) {
246
+ if (this.isClosed)
247
+ return;
248
+ this.armDeadPeerTimer();
249
+ let frame;
250
+ try {
251
+ frame = decodeFrame(bytes);
252
+ }
253
+ catch (err) {
254
+ this.fatal(ErrorCode.PROTOCOL_ERROR, err instanceof ProtocolError ? err.message : String(err));
255
+ return;
256
+ }
257
+ this.handleFrame(frame);
258
+ }
259
+ handleFrame(frame) {
260
+ switch (frame.type) {
261
+ case 'OPEN':
262
+ this.handleOpen(frame);
263
+ return;
264
+ case 'DATA': {
265
+ const record = this.lookup(frame.streamId);
266
+ if (!record)
267
+ return;
268
+ if (record.endReceived) {
269
+ this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'DATA after END');
270
+ return;
271
+ }
272
+ if (frame.data.length > record.receiveAllowance) {
273
+ this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'flow control window overrun');
274
+ return;
275
+ }
276
+ record.receiveAllowance -= frame.data.length;
277
+ if (!record.stream.destroyed)
278
+ record.stream.deliver(frame.data);
279
+ return;
280
+ }
281
+ case 'END': {
282
+ const record = this.lookup(frame.streamId);
283
+ if (!record)
284
+ return;
285
+ if (record.endReceived) {
286
+ this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'duplicate END');
287
+ return;
288
+ }
289
+ record.endReceived = true;
290
+ if (!record.stream.destroyed)
291
+ record.stream.deliverEnd();
292
+ this.maybeFinishStream(record);
293
+ return;
294
+ }
295
+ case 'RST': {
296
+ const record = this.streams.get(frame.streamId);
297
+ if (!record) {
298
+ // Either it raced our own RST, or it is for a stream we never opened.
299
+ if (frame.streamId > this.highestStreamId) {
300
+ this.fatal(ErrorCode.PROTOCOL_ERROR, `RST for stream ${frame.streamId}, which was never opened`);
301
+ }
302
+ return;
303
+ }
304
+ const code = normalizeResetCode(frame.code);
305
+ this.failStream(record, new StreamResetError(code, 'reset by peer', true));
306
+ return;
307
+ }
308
+ case 'WINDOW': {
309
+ const record = this.lookup(frame.streamId);
310
+ if (!record)
311
+ return;
312
+ record.sendWindow += frame.increment;
313
+ if (record.sendWindow > MAX_WINDOW) {
314
+ this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'send window overflow');
315
+ return;
316
+ }
317
+ this.markReady(record);
318
+ this.pump();
319
+ return;
320
+ }
321
+ case 'PING':
322
+ this.queueControl({ type: 'PONG', streamId: 0, nonce: frame.nonce });
323
+ return;
324
+ case 'PONG': {
325
+ const pending = this.pendingPings.get(frame.nonce.toString());
326
+ if (!pending)
327
+ return; // a nonce we never sent, or a late answer
328
+ this.pendingPings.delete(frame.nonce.toString());
329
+ pending.resolve(Date.now() - pending.sentAt);
330
+ return;
331
+ }
332
+ case 'GOAWAY':
333
+ this.goAwayReceived = true;
334
+ this.emit('goaway', { code: frame.code, reason: frame.reason });
335
+ return;
336
+ case 'PROBE':
337
+ if (this.role !== 'server') {
338
+ this.fatal(ErrorCode.PROTOCOL_ERROR, 'PROBE received by a client session');
339
+ return;
340
+ }
341
+ this.answerProbe(frame.streamId, frame.request);
342
+ return;
343
+ case 'PROBE_RESULT': {
344
+ if (this.role !== 'client') {
345
+ this.fatal(ErrorCode.PROTOCOL_ERROR, 'PROBE_RESULT received by a server session');
346
+ return;
347
+ }
348
+ const pending = this.pendingProbes.get(frame.streamId);
349
+ if (!pending)
350
+ return; // timed out already, or never asked
351
+ this.pendingProbes.delete(frame.streamId);
352
+ clearTimeout(pending.timer);
353
+ pending.resolve(frame.result);
354
+ return;
355
+ }
356
+ default:
357
+ this.fatal(ErrorCode.PROTOCOL_ERROR, 'unknown frame');
358
+ }
359
+ }
360
+ handleOpen(frame) {
361
+ if (this.role !== 'server') {
362
+ // Only servers open streams, so a client is the one that receives OPEN.
363
+ if (frame.streamId <= this.highestStreamId) {
364
+ this.fatal(ErrorCode.PROTOCOL_ERROR, `OPEN reuses stream id ${frame.streamId}`);
365
+ return;
366
+ }
367
+ this.highestStreamId = frame.streamId;
368
+ if (this.goAwaySent || this.streams.size >= MAX_CONCURRENT_STREAMS) {
369
+ this.queueControl({ type: 'RST', streamId: frame.streamId, code: ErrorCode.REFUSED });
370
+ return;
371
+ }
372
+ const record = this.createRecord(frame.streamId, frame);
373
+ this.emit('stream', record.stream);
374
+ return;
375
+ }
376
+ this.fatal(ErrorCode.PROTOCOL_ERROR, 'OPEN received by a server session');
377
+ }
378
+ answerProbe(probeId, request) {
379
+ const startedAt = Date.now();
380
+ const reply = (result) => {
381
+ if (this.isClosed)
382
+ return;
383
+ this.queueControl({ type: 'PROBE_RESULT', streamId: probeId, result });
384
+ };
385
+ const handler = this.onProbe;
386
+ if (!handler) {
387
+ reply({ error: 'UNSUPPORTED', elapsedMs: 0 });
388
+ return;
389
+ }
390
+ void Promise.resolve()
391
+ .then(() => handler(request))
392
+ .then(reply)
393
+ .catch(() => reply({ error: 'PROBE_FAILED', elapsedMs: Date.now() - startedAt }));
394
+ }
395
+ /**
396
+ * A frame for a stream we don't have is either a race with our own RST (fine,
397
+ * ignore it) or a stream that never existed (fatal).
398
+ */
399
+ lookup(streamId) {
400
+ const record = this.streams.get(streamId);
401
+ if (record)
402
+ return record;
403
+ if (streamId > this.highestStreamId) {
404
+ this.fatal(ErrorCode.PROTOCOL_ERROR, `frame for stream ${streamId}, which was never opened`);
405
+ }
406
+ return undefined;
407
+ }
408
+ // ── stream bookkeeping ─────────────────────────────────────────────────────
409
+ createRecord(streamId, open) {
410
+ const stream = new TunnelStream({ id: streamId, metadata: open.metadata, host: this });
411
+ // See failStream: the session owns an error sink so a teardown can never
412
+ // throw out of a stream nobody is listening to.
413
+ stream.on('error', () => undefined);
414
+ const record = {
415
+ stream,
416
+ sendWindow: INITIAL_WINDOW,
417
+ receiveAllowance: INITIAL_WINDOW,
418
+ unannounced: 0,
419
+ queue: [],
420
+ finalPending: false,
421
+ finalCb: undefined,
422
+ endSent: false,
423
+ endReceived: false,
424
+ suppressReset: false,
425
+ queued: false,
426
+ };
427
+ this.streams.set(streamId, record);
428
+ return record;
429
+ }
430
+ /**
431
+ * Destroy a stream the session is tearing down, reporting why.
432
+ *
433
+ * Safe to always pass the error because createRecord attaches a session-owned
434
+ * 'error' sink: without one, a stream teardown (a dropped tunnel — a routine
435
+ * event) would throw out of an EventEmitter and take the process with it, and
436
+ * node's own pipe() re-emits on a destination that has no other listener.
437
+ * A consumer's own 'error' listener still gets the error; one that only
438
+ * listens for 'close' simply doesn't.
439
+ */
440
+ failStream(record, err) {
441
+ record.suppressReset = true;
442
+ record.stream.destroy(err);
443
+ }
444
+ /** Release the callbacks a torn-down stream still holds. See streamDestroy. */
445
+ settleJobs(record) {
446
+ for (const job of record.queue)
447
+ job.cb(null);
448
+ record.queue.length = 0;
449
+ const cb = record.finalCb;
450
+ record.finalCb = undefined;
451
+ cb?.(null);
452
+ }
453
+ /** A violation that belongs to one stream, not to the session. */
454
+ resetStream(record, code, detail) {
455
+ this.queueControl({ type: 'RST', streamId: record.stream.id, code });
456
+ this.streams.delete(record.stream.id);
457
+ this.failStream(record, new StreamResetError(code, detail, false));
458
+ this.afterStreamClosed();
459
+ }
460
+ /**
461
+ * Both ENDs exchanged: the stream is done as far as the protocol is concerned,
462
+ * so it stops counting against the cap and stops holding up a drain. The
463
+ * Duplex itself stays alive until the application has read what it buffered.
464
+ */
465
+ maybeFinishStream(record) {
466
+ if (!record.endSent || !record.endReceived)
467
+ return;
468
+ if (!this.streams.has(record.stream.id))
469
+ return;
470
+ this.streams.delete(record.stream.id);
471
+ this.afterStreamClosed();
472
+ }
473
+ afterStreamClosed() {
474
+ if (this.drainStarted && this.streams.size === 0 && !this.isClosed)
475
+ this.endDrain(false);
476
+ }
477
+ endDrain(timedOut) {
478
+ if (this.isClosed)
479
+ return;
480
+ if (this.drainTimer) {
481
+ clearTimeout(this.drainTimer);
482
+ this.drainTimer = undefined;
483
+ }
484
+ if (timedOut && this.streams.size > 0) {
485
+ for (const record of [...this.streams.values()]) {
486
+ this.streams.delete(record.stream.id);
487
+ this.queueControl({ type: 'RST', streamId: record.stream.id, code: ErrorCode.GOING_AWAY });
488
+ this.failStream(record, new StreamResetError(ErrorCode.GOING_AWAY, 'drain deadline passed', false));
489
+ }
490
+ this.closeTransport(CloseCode.GOING_AWAY, 'drain deadline passed');
491
+ return;
492
+ }
493
+ this.closeTransport(CloseCode.NORMAL, '');
494
+ }
495
+ // ── outbound ───────────────────────────────────────────────────────────────
496
+ queueControl(frame) {
497
+ if (this.isClosed)
498
+ return;
499
+ this.control.push(Buffer.from(encodeFrame(frame)));
500
+ this.pump();
501
+ }
502
+ /** Bypasses the queue for a frame that has to go out before the socket closes. */
503
+ sendImmediate(frame) {
504
+ if (this.isClosed)
505
+ return;
506
+ try {
507
+ this.transport.send(Buffer.from(encodeFrame(frame)), () => undefined);
508
+ }
509
+ catch {
510
+ /* the socket is already gone; the close below is what matters */
511
+ }
512
+ }
513
+ markReady(record) {
514
+ if (record.queued)
515
+ return;
516
+ if (!this.streams.has(record.stream.id))
517
+ return;
518
+ if (!this.hasWork(record))
519
+ return;
520
+ record.queued = true;
521
+ this.ready.push(record.stream.id);
522
+ }
523
+ hasWork(record) {
524
+ if (record.queue.length > 0)
525
+ return record.sendWindow > 0;
526
+ return record.finalPending && !record.endSent;
527
+ }
528
+ /**
529
+ * One writer for the whole session. Control frames first, then one DATA frame
530
+ * per ready stream in round-robin order — which is what keeps a 10 MB download
531
+ * from delaying a 1 KB request by more than a single frame.
532
+ */
533
+ pump() {
534
+ if (this.pumping || this.isClosed)
535
+ return;
536
+ this.pumping = true;
537
+ try {
538
+ while (!this.isClosed && this.unackedBytes < MAX_UNACKED_SEND_BYTES) {
539
+ const control = this.control.shift();
540
+ if (control) {
541
+ this.writeBytes(control);
542
+ continue;
543
+ }
544
+ const record = this.nextReady();
545
+ if (!record)
546
+ break;
547
+ this.writeStreamFrame(record);
548
+ }
549
+ }
550
+ finally {
551
+ this.pumping = false;
552
+ }
553
+ }
554
+ nextReady() {
555
+ while (this.ready.length > 0) {
556
+ const streamId = this.ready.shift();
557
+ if (streamId === undefined)
558
+ return undefined;
559
+ const record = this.streams.get(streamId);
560
+ if (!record)
561
+ continue;
562
+ record.queued = false;
563
+ if (this.hasWork(record))
564
+ return record;
565
+ }
566
+ return undefined;
567
+ }
568
+ writeStreamFrame(record) {
569
+ const job = record.queue[0];
570
+ if (job) {
571
+ const size = Math.min(MAX_DATA_PAYLOAD, record.sendWindow, job.chunk.length - job.offset);
572
+ const slice = job.chunk.subarray(job.offset, job.offset + size);
573
+ job.offset += size;
574
+ record.sendWindow -= size;
575
+ this.writeBytes(Buffer.from(encodeFrame({ type: 'DATA', streamId: record.stream.id, data: slice })));
576
+ if (job.offset >= job.chunk.length) {
577
+ record.queue.shift();
578
+ job.cb(null);
579
+ }
580
+ }
581
+ else if (record.finalPending && !record.endSent) {
582
+ record.endSent = true;
583
+ this.writeBytes(Buffer.from(encodeFrame({ type: 'END', streamId: record.stream.id })));
584
+ const cb = record.finalCb;
585
+ record.finalCb = undefined;
586
+ cb?.(null);
587
+ this.maybeFinishStream(record);
588
+ }
589
+ // Back of the ring, so every other ready stream gets a turn first.
590
+ this.markReady(record);
591
+ }
592
+ writeBytes(bytes) {
593
+ this.unackedBytes += bytes.length;
594
+ this.transport.send(bytes, (err) => {
595
+ this.unackedBytes -= bytes.length;
596
+ if (err) {
597
+ this.finish(CloseCode.ABNORMAL, err.message, false);
598
+ return;
599
+ }
600
+ this.pump();
601
+ });
602
+ }
603
+ // ── liveness and teardown ──────────────────────────────────────────────────
604
+ armDeadPeerTimer() {
605
+ if (this.isClosed)
606
+ return;
607
+ if (this.deadTimer)
608
+ clearTimeout(this.deadTimer);
609
+ this.deadTimer = unref(setTimeout(() => {
610
+ // A peer that has said nothing for a minute cannot complete a close
611
+ // handshake, so there is nothing to wait for.
612
+ this.transport.terminate();
613
+ this.finish(CloseCode.TIMEOUT, 'peer went silent', false);
614
+ }, this.deadPeerTimeoutMs));
615
+ }
616
+ fatal(code, detail) {
617
+ if (this.isClosed)
618
+ return;
619
+ this.sendImmediate({ type: 'GOAWAY', streamId: 0, code, reason: truncateReason(detail) });
620
+ this.closeTransport(CLOSE_CODE_BASE + code, detail);
621
+ }
622
+ closeTransport(closeCode, reason) {
623
+ if (this.isClosed)
624
+ return;
625
+ try {
626
+ this.transport.close(closeCode, reason);
627
+ }
628
+ catch {
629
+ /* already gone */
630
+ }
631
+ this.finish(closeCode, reason, false);
632
+ }
633
+ finish(closeCode, reason, remote) {
634
+ if (this.isClosed)
635
+ return;
636
+ this.isClosed = true;
637
+ this.closeCode = closeCode;
638
+ if (this.pingTimer)
639
+ clearInterval(this.pingTimer);
640
+ if (this.deadTimer)
641
+ clearTimeout(this.deadTimer);
642
+ if (this.drainTimer)
643
+ clearTimeout(this.drainTimer);
644
+ this.pingTimer = undefined;
645
+ this.deadTimer = undefined;
646
+ this.drainTimer = undefined;
647
+ const err = new SessionClosedError(closeCode, reason);
648
+ for (const record of [...this.streams.values()]) {
649
+ this.streams.delete(record.stream.id);
650
+ this.settleJobs(record);
651
+ this.failStream(record, err);
652
+ }
653
+ this.ready.length = 0;
654
+ this.control.length = 0;
655
+ for (const pending of this.pendingPings.values())
656
+ pending.reject(err);
657
+ this.pendingPings.clear();
658
+ for (const probe of this.pendingProbes.values()) {
659
+ clearTimeout(probe.timer);
660
+ probe.resolve({ error: 'CLOSED', elapsedMs: Date.now() - probe.startedAt });
661
+ }
662
+ this.pendingProbes.clear();
663
+ for (const resolve of this.goAwayWaiters)
664
+ resolve();
665
+ this.goAwayWaiters.length = 0;
666
+ this.emit('close', { closeCode, reason, remote });
667
+ }
668
+ }