quico 0.2.0 → 0.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.
package/index.js CHANGED
@@ -18,6 +18,8 @@ import { Agent, globalAgent } from './src/agent.js';
18
18
  import { IncomingMessage, ServerResponse, ClientRequest } from './src/streams.js';
19
19
  import { createSecureContext } from './src/tls_bridge.js';
20
20
  import { WebTransport } from './src/webtransport.js';
21
+ import { createQuicServer } from './src/quic_server.js';
22
+ import { createQuicClientSocket } from './src/quic_socket.js';
21
23
 
22
24
  export {
23
25
  createServer,
@@ -29,7 +31,9 @@ export {
29
31
  ServerResponse,
30
32
  ClientRequest,
31
33
  createSecureContext,
32
- WebTransport
34
+ WebTransport,
35
+ createQuicServer,
36
+ createQuicClientSocket
33
37
  };
34
38
 
35
39
  export default {
@@ -42,5 +46,7 @@ export default {
42
46
  ServerResponse,
43
47
  ClientRequest,
44
48
  createSecureContext,
45
- WebTransport
49
+ WebTransport,
50
+ createQuicServer,
51
+ createQuicClientSocket
46
52
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quico",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A pure JavaScript implementation of QUIC, HTTP/3, QPACK, and WebTransport for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.cjs",
@@ -78,6 +78,6 @@
78
78
  ],
79
79
  "dependencies": {
80
80
  "flat-ranges": "^2.0.0",
81
- "lemon-tls": "^0.2.0"
81
+ "lemon-tls": "^0.6.0"
82
82
  }
83
83
  }
package/src/agent.js CHANGED
@@ -56,6 +56,17 @@ class Agent extends EventEmitter {
56
56
  // Allows multiplexing multiple requests over one QUIC connection
57
57
  this._h3Pool = {};
58
58
 
59
+ // Connections still being ESTABLISHED: "host:port" → [waiter callbacks].
60
+ // Without this third state the pool only knows "have one" / "don't", so N
61
+ // requests issued in the same tick each see an empty pool and open N
62
+ // separate QUIC connections to the same origin — N handshakes, N
63
+ // congestion controllers, zero multiplexing (the QUIC interop runner's
64
+ // http3 test flags exactly this: "Expected exactly 1 handshake. Got: 3").
65
+ // The first request to miss the pool becomes the lead and creates the
66
+ // entry; requests arriving before it finishes park a callback here and
67
+ // are replayed once the lead settles.
68
+ this._h3Pending = {};
69
+
59
70
  // Active HTTP/2 sessions: "host:port" → http2.ClientHttp2Session
60
71
  this._h2Pool = {};
61
72
 
@@ -158,13 +169,49 @@ class Agent extends EventEmitter {
158
169
  var conn = this._h3Pool[key];
159
170
  if (!conn) return null;
160
171
  var state = conn.quic ? conn.quic.state : 'closed';
161
- if (state === 'closed' || state === 'draining') {
172
+ if (state === 'closed' || state === 'draining' || state === 'closing') {
162
173
  delete this._h3Pool[key];
163
174
  return null;
164
175
  }
165
176
  return conn;
166
177
  }
167
178
 
179
+ /**
180
+ * Coalesce concurrent connection attempts to one origin.
181
+ *
182
+ * Returns false if the caller is the LEAD: no connection exists and nobody
183
+ * else is establishing one. The lead MUST eventually call
184
+ * resolveH3Pending(host, port, connOrNull) — on success, failure, or
185
+ * abandonment — or every later request to this origin parks forever.
186
+ *
187
+ * Returns true if the caller was parked as a follower; `cb` fires (with no
188
+ * arguments) once the lead settles, and the caller should simply retry its
189
+ * pool lookup: on success the connection is already registered, and on
190
+ * failure the retry becomes the next lead — serial retry, no thundering
191
+ * herd.
192
+ */
193
+ joinH3Pending(host, port, cb) {
194
+ var key = host + ':' + (port || 443);
195
+ if (this._h3Pending[key]) {
196
+ this._h3Pending[key].push(cb);
197
+ return true;
198
+ }
199
+ this._h3Pending[key] = [];
200
+ return false;
201
+ }
202
+
203
+ /** Lead's settlement. Registers the connection (if any) BEFORE waking the
204
+ * waiters, so their retried pool lookups succeed synchronously. */
205
+ resolveH3Pending(host, port, conn) {
206
+ var key = host + ':' + (port || 443);
207
+ var waiters = this._h3Pending[key] || [];
208
+ delete this._h3Pending[key];
209
+ if (conn) this.setH3Connection(host, port, conn);
210
+ for (var i = 0; i < waiters.length; i++) {
211
+ try { waiters[i](); } catch (e) { /* a waiter's failure is its own */ }
212
+ }
213
+ }
214
+
168
215
  setH3Connection(host, port, conn) {
169
216
  var key = host + ':' + (port || 443);
170
217
  this._h3Pool[key] = conn;
@@ -173,6 +220,14 @@ class Agent extends EventEmitter {
173
220
  if (this._h3Pool[key] === conn) delete this._h3Pool[key];
174
221
  });
175
222
  }
223
+ // RFC 9114 §5.2: once the peer sends GOAWAY it won't accept new requests, so
224
+ // stop reusing this connection for new ones. In-flight requests keep running
225
+ // on it; they just won't be joined by new requests (which open a fresh conn).
226
+ if (conn.h3 && typeof conn.h3.on === 'function') {
227
+ conn.h3.on('goaway', () => {
228
+ if (this._h3Pool[key] === conn) delete this._h3Pool[key];
229
+ });
230
+ }
176
231
  }
177
232
 
178
233
 
@@ -254,6 +309,17 @@ class Agent extends EventEmitter {
254
309
  destroy() {
255
310
  if (this._sweepTimer) { clearInterval(this._sweepTimer); this._sweepTimer = null; }
256
311
 
312
+ // Wake anyone parked on an in-flight connection attempt; their retried
313
+ // pool lookup will find nothing and proceed (or fail) on its own.
314
+ for (var key in this._h3Pending) {
315
+ var waiters = this._h3Pending[key];
316
+ delete this._h3Pending[key];
317
+ for (var i = 0; i < waiters.length; i++) {
318
+ try { waiters[i](); } catch (e) {}
319
+ }
320
+ }
321
+ this._h3Pending = {};
322
+
257
323
  for (var key in this._h3Pool) {
258
324
  try { this._h3Pool[key].quic.close(0, 'agent destroy'); } catch (e) {}
259
325
  }
package/src/client.js CHANGED
@@ -206,21 +206,53 @@ function request(url, options, callback) {
206
206
  port: port,
207
207
  path: path,
208
208
  method: method,
209
- headers: Object.assign({}, headers)
209
+ headers: Object.assign({}, headers),
210
+ // The H2 and HTTPS fallbacks below honour this; dropping it here made
211
+ // H3 — alone of the three — unable to talk to self-signed or
212
+ // private-CA servers, and every such request silently fell back to
213
+ // TCP (or hung when the server was QUIC-only).
214
+ rejectUnauthorized: options.rejectUnauthorized,
215
+ ca: options.ca
210
216
  };
211
217
 
212
- // Check Agent for existing QUIC connection (multiplexing)
218
+ // Check Agent for existing QUIC connection (multiplexing).
219
+ // With an agent, the connection's lifecycle is owned by the pool
220
+ // (sweep / idle timer / agent.destroy), so signal h3_client not to
221
+ // auto-close it when this request finishes — otherwise the first
222
+ // request would tear down a connection still pooled for reuse.
213
223
  if (agent) {
224
+ h3Opts._managed = true;
214
225
  var existing = agent.getH3Connection(hostname, port);
215
- if (existing) h3Opts._connection = existing;
226
+ if (existing) {
227
+ h3Opts._connection = existing;
228
+ } else if (agent.joinH3Pending &&
229
+ agent.joinH3Pending(hostname, port, function () {
230
+ // The lead settled. Retry from the top: on success the
231
+ // pool lookup now hits and we multiplex on the lead's
232
+ // connection; on failure we become the next lead — a
233
+ // serial retry, never a thundering herd.
234
+ tryH3(onFail);
235
+ })) {
236
+ return; // parked as a follower until the lead settles
237
+ }
238
+ // else: we are the LEAD for this origin. _settleH3Pending below MUST
239
+ // run on every exit path, or followers park forever.
240
+ }
241
+
242
+ var _pendingLead = !!(agent && !h3Opts._connection);
243
+ function _settleH3Pending(conn) {
244
+ if (!_pendingLead) return;
245
+ _pendingLead = false;
246
+ if (agent.resolveH3Pending) agent.resolveH3Pending(hostname, port, conn);
247
+ else if (conn) agent.setH3Connection(hostname, port, conn);
216
248
  }
217
249
 
218
250
  var h3Req = h3Request(h3Opts, function (h3Res) {
219
251
  if (responded) return;
220
252
 
221
- // Store connection for future reuse
222
- if (agent && h3Req._connection && !h3Opts._connection) {
223
- agent.setH3Connection(hostname, port, h3Req._connection);
253
+ // Register the connection and wake coalesced followers.
254
+ if (h3Req._connection && !h3Opts._connection) {
255
+ _settleH3Pending(h3Req._connection);
224
256
  }
225
257
 
226
258
  var res = new IncomingMessage({
@@ -241,6 +273,7 @@ function request(url, options, callback) {
241
273
  });
242
274
 
243
275
  h3Req.on('error', function (err) {
276
+ _settleH3Pending(null); // release followers before failing over
244
277
  onFail(err);
245
278
  });
246
279
 
@@ -249,6 +282,7 @@ function request(url, options, callback) {
249
282
  h3Req.end();
250
283
 
251
284
  } catch (err) {
285
+ if (typeof _settleH3Pending === 'function') _settleH3Pending(null);
252
286
  onFail(err);
253
287
  }
254
288
  }
package/src/crypto.js CHANGED
@@ -60,8 +60,16 @@ var INITIAL_SALTS = {
60
60
  // ============================================================
61
61
 
62
62
  /**
63
- * Derive Initial encryption keys from the client DCID.
64
- * direction: 'read' = client keys, 'write' = server keys
63
+ * Derive an Initial-packet secret + keys from the client's original DCID.
64
+ *
65
+ * `direction` selects WHICH endpoint's Initial secret to derive — it is NOT
66
+ * "the keys I read/write with" in the obvious sense:
67
+ * 'read' → the client's secret (TLS label "client in")
68
+ * 'write' → the server's secret (TLS label "server in")
69
+ * Each role then maps these to its own read/write keys: a client WRITES with
70
+ * the "client in" secret and READS with "server in"; a server is the mirror.
71
+ * So callers pass 'read' to get the client-side keys and 'write' for the
72
+ * server-side keys, and assign them to initial_read / initial_write per role.
65
73
  */
66
74
  function quic_derive_init_secrets(client_dcid, version, direction) {
67
75
  var hashName = 'sha256';
@@ -237,8 +245,10 @@ function expandPacketNumber(truncated, pnLen, largestReceived) {
237
245
 
238
246
  function decode_packet_number(array, offset, pnLength) {
239
247
  var value = 0;
248
+ // Use *256 (not <<8): a 4-byte packet number whose leading byte has the high
249
+ // bit set would overflow JS's 32-bit signed bitwise math and go negative.
240
250
  for (var i = 0; i < pnLength; i++) {
241
- value = (value << 8) | array[offset + i];
251
+ value = value * 256 + array[offset + i];
242
252
  }
243
253
  return value;
244
254
  }
@@ -354,10 +364,32 @@ function encrypt_quic_packet(packetType, encodedFrames, writeKey, writeIv, write
354
364
 
355
365
  /**
356
366
  * Decrypt a QUIC packet (raw bytes → plaintext frames).
367
+ *
368
+ * `ownCid` is the connection ID by which the receiver is addressed. It is used
369
+ * ONLY for short (1-RTT) headers, which carry no DCID-length field — the
370
+ * receiver must already know its own CID length to locate the packet number.
371
+ * Long headers encode the DCID length on the wire, so `ownCid` is ignored there.
357
372
  */
358
- function decrypt_quic_packet(array, readKey, readIv, readHp, dcid, largestPn) {
373
+ function decrypt_quic_packet(array, readKey, readIv, readHp, ownCid, largestPn) {
359
374
  if (!(array instanceof Uint8Array)) throw new Error('Invalid input');
360
375
 
376
+ // Work on a private copy — this function must not mutate its input.
377
+ //
378
+ // remove_header_protection XORs the first byte and the packet-number bytes
379
+ // IN PLACE. That is fine for exactly one decryption attempt, but the key
380
+ // update path retries this function on the same packet with derived keys
381
+ // (RFC 9001 §6): the second call re-applies the (unchanged) HP mask to a
382
+ // header that is already unprotected, re-scrambling it — and reads pnLength
383
+ // from the scrambled byte, so it may even XOR the wrong number of bytes.
384
+ // The retry then fails even with the correct keys, every phase-flipped
385
+ // packet is dropped, and the connection goes permanently deaf in that
386
+ // direction. Measured against quic-go (which key-updates shortly after
387
+ // handshake confirmation): ACK processing died at t≈2s, BBR froze mid-
388
+ // startup, and the sender spent 58s retransmitting one 8 KB band 29 times.
389
+ //
390
+ // One ≤1500 B copy per decryption attempt is noise next to the AEAD itself.
391
+ array = array.slice();
392
+
361
393
  var firstByte = array[0];
362
394
  var isShort = (firstByte & 0x80) === 0;
363
395
 
@@ -405,7 +437,7 @@ function decrypt_quic_packet(array, readKey, readIv, readHp, dcid, largestPn) {
405
437
 
406
438
  } else {
407
439
  // Short Header
408
- var dcidLen = dcid.length;
440
+ var dcidLen = ownCid.length;
409
441
  pnOffset = 1 + dcidLen;
410
442
 
411
443
  pnLength = remove_header_protection(array, pnOffset, readHp, true);
package/src/h3.js CHANGED
@@ -13,10 +13,12 @@
13
13
  */
14
14
 
15
15
  import {
16
+ DEBUG,
16
17
  Emitter,
17
18
  concatUint8Arrays,
18
19
  writeVarInt,
19
- readVarInt
20
+ readVarInt,
21
+ wt_stream_header
20
22
  } from './utils.js';
21
23
 
22
24
 
@@ -156,26 +158,30 @@ function huffDecode(buf) {
156
158
  // QPACK parse/build helpers
157
159
  // ============================================================
158
160
 
159
- function decodeQpackVarInt(buf, prefixBits, pos) {
161
+ // Read a QPACK/HPACK prefixed integer at buf[pos]. Returns { value, next } or
162
+ // null if the buffer ends mid-integer (truncated). Used by both the header
163
+ // block parser and the encoder-instruction parser.
164
+ function readPrefixedInt(buf, prefixBits, pos) {
165
+ if (pos >= buf.length) return null;
160
166
  var max = (1 << prefixBits) - 1;
161
167
  var value = buf[pos] & max;
162
168
  pos++;
163
169
  if (value < max) return { value: value, next: pos };
164
170
  var m = 0;
165
- while (true) {
171
+ while (pos < buf.length) {
166
172
  var byte = buf[pos++];
167
173
  value += (byte & 0x7f) << m;
168
- if ((byte & 0x80) === 0) break;
174
+ if ((byte & 0x80) === 0) return { value: value, next: pos };
169
175
  m += 7;
170
176
  }
171
- return { value: value, next: pos };
177
+ return null; // truncated mid-integer
172
178
  }
173
179
 
174
180
  function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
175
181
  var pos = 0;
176
- var ric = decodeQpackVarInt(buf, 8, pos); pos = ric.next;
182
+ var ric = readPrefixedInt(buf, 8, pos); if (!ric) return {}; pos = ric.next;
177
183
  var postBase = (buf[pos] & 0x80) !== 0;
178
- var db = decodeQpackVarInt(buf, 7, pos); pos = db.next;
184
+ var db = readPrefixedInt(buf, 7, pos); if (!db) return {}; pos = db.next;
179
185
  var bi = postBase ? ric.value + db.value : ric.value - db.value;
180
186
 
181
187
  var headers = {};
@@ -185,7 +191,7 @@ function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
185
191
  if ((byte & 0x80) === 0x80) {
186
192
  // Indexed
187
193
  var fromStatic = (byte & 0x40) !== 0;
188
- var idx = decodeQpackVarInt(buf, 6, pos); pos = idx.next;
194
+ var idx = readPrefixedInt(buf, 6, pos); if (!idx) break; pos = idx.next;
189
195
  if (fromStatic && idx.value < QPACK_STATIC.length) {
190
196
  headers[QPACK_STATIC[idx.value][0]] = QPACK_STATIC[idx.value][1];
191
197
  } else if (!fromStatic && dynamicTable) {
@@ -198,9 +204,9 @@ function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
198
204
  } else if ((byte & 0xc0) === 0x40) {
199
205
  // Literal with name ref
200
206
  var fromStatic = (byte & 0x10) !== 0;
201
- var nameIdx = decodeQpackVarInt(buf, 4, pos); pos = nameIdx.next;
207
+ var nameIdx = readPrefixedInt(buf, 4, pos); if (!nameIdx) break; pos = nameIdx.next;
202
208
  var valH = (buf[pos] & 0x80) !== 0;
203
- var valLen = decodeQpackVarInt(buf, 7, pos); pos = valLen.next;
209
+ var valLen = readPrefixedInt(buf, 7, pos); if (!valLen) break; pos = valLen.next;
204
210
  var valBytes = buf.slice(pos, pos + valLen.value); pos += valLen.value;
205
211
  var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
206
212
 
@@ -216,11 +222,11 @@ function parseQpackHeaderBlock(buf, dynamicTable, baseIndex) {
216
222
  } else if ((byte & 0xe0) === 0x20) {
217
223
  // Literal with literal name
218
224
  var nameH = (byte & 0x08) !== 0;
219
- var nameLen = decodeQpackVarInt(buf, 3, pos); pos = nameLen.next;
225
+ var nameLen = readPrefixedInt(buf, 3, pos); if (!nameLen) break; pos = nameLen.next;
220
226
  var nameBytes = buf.slice(pos, pos + nameLen.value); pos += nameLen.value;
221
227
  var name = nameH ? huffDecode(nameBytes) : new TextDecoder().decode(nameBytes);
222
228
  var valH = (buf[pos] & 0x80) !== 0;
223
- var valLen = decodeQpackVarInt(buf, 7, pos); pos = valLen.next;
229
+ var valLen = readPrefixedInt(buf, 7, pos); if (!valLen) break; pos = valLen.next;
224
230
  var valBytes = buf.slice(pos, pos + valLen.value); pos += valLen.value;
225
231
  var value = valH ? huffDecode(valBytes) : new TextDecoder().decode(valBytes);
226
232
  headers[name] = value;
@@ -421,22 +427,13 @@ function parseQpackEncoderInstructions(chunks, fromOffset) {
421
427
  var pos = 0;
422
428
  var instructions = [];
423
429
 
430
+ // Thin wrapper over the shared readPrefixedInt that advances the local `pos`
431
+ // and returns the value (or null on truncation), preserving call-site usage.
424
432
  function safeVarInt(prefixBits) {
425
- if (pos >= combined.length) return null;
426
- var max = (1 << prefixBits) - 1;
427
- var value = combined[pos] & max;
428
- var startPos = pos;
429
- pos++;
430
- if (value < max) return value;
431
- var m = 0;
432
- while (pos < combined.length) {
433
- var b = combined[pos++];
434
- value += (b & 0x7f) << m;
435
- if ((b & 0x80) === 0) return value;
436
- m += 7;
437
- }
438
- pos = startPos;
439
- return null;
433
+ var r = readPrefixedInt(combined, prefixBits, pos);
434
+ if (!r) return null;
435
+ pos = r.next;
436
+ return r.value;
440
437
  }
441
438
 
442
439
  while (pos < combined.length) {
@@ -624,8 +621,8 @@ function H3Connection(options) {
624
621
  var encId = context.isServer ? 7 : 6;
625
622
  var decId = context.isServer ? 11 : 10;
626
623
 
627
- console.log('[h3] sendControlStreams ctrl=' + ctrlId + ' enc=' + encId + ' dec=' + decId);
628
- console.log('[h3] SETTINGS hex: ' + Array.from(controlFrame).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' '));
624
+ if (DEBUG) console.log('[h3] sendControlStreams ctrl=' + ctrlId + ' enc=' + encId + ' dec=' + decId);
625
+ if (DEBUG) console.log('[h3] SETTINGS hex: ' + Array.from(controlFrame).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' '));
629
626
 
630
627
  // Send type byte + SETTINGS together as one chunk (some implementations need this)
631
628
  var ctrlData = new Uint8Array(1 + controlFrame.byteLength);
@@ -637,6 +634,17 @@ function H3Connection(options) {
637
634
  }
638
635
 
639
636
 
637
+ // ---- Graceful shutdown: GOAWAY (RFC 9114 §5.2) ----
638
+
639
+ function sendGoaway(id) {
640
+ sendControlStreams(); // idempotent — ensures the control stream + its type byte exist
641
+ var ctrlId = context.isServer ? 3 : 2;
642
+ var frame = buildH3Frames([{ frame_type: 0x07, payload: writeVarInt(id || 0) }]);
643
+ if (DEBUG) console.log('[h3] → GOAWAY id=' + (id || 0));
644
+ quic.sendStream(ctrlId, frame, false);
645
+ }
646
+
647
+
640
648
  // ---- Incoming stream data ----
641
649
 
642
650
  var _claimedStreams = new Set(); // streams owned by WebTransport (skip H3 processing)
@@ -655,7 +663,7 @@ function H3Connection(options) {
655
663
  }
656
664
 
657
665
  if (_claimedStreams.has(sid)) return; // owned by WebTransport client
658
- console.log('[h3] stream ' + streamId + ' data=' + data.byteLength + ' fin=' + fin);
666
+ if (DEBUG) console.log('[h3] stream ' + streamId + ' data=' + data.byteLength + ' fin=' + fin);
659
667
  if (!(streamId in context.streams)) {
660
668
  context.streams[streamId] = { chunks: [], from_offset: 0, type: null, fin_received: false };
661
669
  }
@@ -671,7 +679,7 @@ function H3Connection(options) {
671
679
  // ---- WT stream detection ----
672
680
  if (context.local_webtransport && _wtSessions.size > 0) {
673
681
  var allData = concatUint8Arrays(s.chunks);
674
- console.log('[h3] WT check: stream=' + sid + ' firstByte=0x' + (allData[0] || 0).toString(16) + ' len=' + allData.byteLength + ' sessions=' + Array.from(_wtSessions));
682
+ if (DEBUG) console.log('[h3] WT check: stream=' + sid + ' firstByte=0x' + (allData[0] || 0).toString(16) + ' len=' + allData.byteLength + ' sessions=' + Array.from(_wtSessions));
675
683
  if (allData.byteLength >= 2 && allData[0] === 0x40) {
676
684
  var typeResult = readVarInt(allData, 0);
677
685
  if (typeResult) {
@@ -689,7 +697,7 @@ function H3Connection(options) {
689
697
  _wtStreams[sid] = { sessionId: sessionId, isBidi: !isUni };
690
698
  delete context.streams[streamId];
691
699
 
692
- console.log('[h3] WT stream detected: stream=' + sid + ' session=' + sessionId + ' bidi=' + !isUni + ' dataLen=' + remaining.byteLength);
700
+ if (DEBUG) console.log('[h3] WT stream detected: stream=' + sid + ' session=' + sessionId + ' bidi=' + !isUni + ' dataLen=' + remaining.byteLength);
693
701
  ev.emit('wt_stream', sessionId, sid, remaining, fin, !isUni);
694
702
  if (fin) delete _wtStreams[sid];
695
703
  return;
@@ -739,7 +747,7 @@ function H3Connection(options) {
739
747
 
740
748
  function processControlStream(s) {
741
749
  var ext = extractH3Frames(s.chunks, s.from_offset);
742
- console.log('[h3] control: extracted ' + ext.frames.length + ' H3 frames');
750
+ if (DEBUG) console.log('[h3] control: extracted ' + ext.frames.length + ' H3 frames');
743
751
  if (ext.frames.length === 0) return;
744
752
  s.from_offset = ext.new_from_offset;
745
753
 
@@ -761,7 +769,12 @@ function H3Connection(options) {
761
769
  set_context(updates);
762
770
 
763
771
  } else if (ext.frames[i].frame_type === 0x07) {
764
- // GOAWAY — TODO
772
+ // GOAWAY (RFC 9114 §5.2): the peer will not process requests/pushes
773
+ // with an identifier >= this value. Surface it so consumers (e.g. the
774
+ // agent) can stop sending new requests on this connection.
775
+ var gp = readVarInt(ext.frames[i].payload, 0);
776
+ if (DEBUG) console.log('[h3] ← GOAWAY id=' + (gp ? gp.value : '?'));
777
+ ev.emit('goaway', gp ? gp.value : 0);
765
778
  }
766
779
  }
767
780
  }
@@ -801,7 +814,7 @@ function H3Connection(options) {
801
814
 
802
815
  function processBidiStream(streamId, s) {
803
816
  var ext = extractH3Frames(s.chunks, s.from_offset);
804
- console.log('[h3] bidi stream ' + streamId + ': extracted ' + ext.frames.length + ' H3 frames, types=' + ext.frames.map(function(f){ return '0x' + f.frame_type.toString(16); }).join(','));
817
+ if (DEBUG) console.log('[h3] bidi stream ' + streamId + ': extracted ' + ext.frames.length + ' H3 frames, types=' + ext.frames.map(function(f){ return '0x' + f.frame_type.toString(16); }).join(','));
805
818
 
806
819
  if (ext.frames.length > 0) {
807
820
  s.from_offset = ext.new_from_offset;
@@ -836,12 +849,12 @@ function H3Connection(options) {
836
849
  function sendHeaders(streamId, headers, fin) {
837
850
  var payload = buildQpackHeaderBlock(headers);
838
851
  var frame = buildH3Frames([{ frame_type: 1, payload: payload }]);
839
- console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
852
+ if (DEBUG) console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
840
853
  quic.sendStream(streamId, frame, !!fin);
841
854
  }
842
855
 
843
856
  function sendBody(streamId, data, fin) {
844
- console.log('[h3] sendBody stream=' + streamId + ' data=' + (data ? data.byteLength : 'null') + ' fin=' + fin);
857
+ if (DEBUG) console.log('[h3] sendBody stream=' + streamId + ' data=' + (data ? data.byteLength : 'null') + ' fin=' + fin);
845
858
  if (data === null || data === undefined) {
846
859
  // Just set FIN on the stream (no additional H3 frame needed for empty body)
847
860
  quic.sendStream(streamId, new Uint8Array(0), true);
@@ -864,6 +877,7 @@ function H3Connection(options) {
864
877
  sendHeaders: sendHeaders,
865
878
  sendBody: sendBody,
866
879
  sendControlStreams: sendControlStreams,
880
+ sendGoaway: sendGoaway,
867
881
  claimStream: function (streamId) { _claimedStreams.add(Number(streamId)); },
868
882
 
869
883
  // WebTransport server-side API
@@ -887,12 +901,7 @@ function H3Connection(options) {
887
901
  }
888
902
 
889
903
  // Send WT prefix: VarInt(type) + VarInt(session_id)
890
- var typePrefix = writeVarInt(isBidi ? 0x41 : 0x54);
891
- var sessionPrefix = writeVarInt(sessionId);
892
- var header = new Uint8Array(typePrefix.length + sessionPrefix.length);
893
- header.set(typePrefix, 0);
894
- header.set(sessionPrefix, typePrefix.length);
895
- quic.sendStream(streamId, header, false);
904
+ quic.sendStream(streamId, wt_stream_header(isBidi, sessionId), false);
896
905
 
897
906
  _claimedStreams.add(streamId);
898
907
  _wtStreams[streamId] = { sessionId: sessionId, isBidi: isBidi };