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/README.md +854 -125
- package/index.js +8 -2
- package/package.json +2 -2
- package/src/agent.js +67 -1
- package/src/client.js +40 -6
- package/src/crypto.js +37 -5
- package/src/h3.js +52 -43
- package/src/h3_client.js +103 -79
- package/src/h3_server.js +52 -183
- package/src/quic_connection.js +1397 -123
- package/src/quic_server.js +372 -0
- package/src/quic_socket.js +127 -0
- package/src/server.js +30 -22
- package/src/streams.js +15 -1
- package/src/tls_bridge.js +116 -14
- package/src/transport.js +143 -34
- package/src/utils.js +59 -4
- package/src/webtransport.js +25 -78
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.
|
|
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.
|
|
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)
|
|
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
|
-
//
|
|
222
|
-
if (
|
|
223
|
-
|
|
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
|
|
64
|
-
*
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
171
|
+
while (pos < buf.length) {
|
|
166
172
|
var byte = buf[pos++];
|
|
167
173
|
value += (byte & 0x7f) << m;
|
|
168
|
-
if ((byte & 0x80) === 0)
|
|
174
|
+
if ((byte & 0x80) === 0) return { value: value, next: pos };
|
|
169
175
|
m += 7;
|
|
170
176
|
}
|
|
171
|
-
return
|
|
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 =
|
|
182
|
+
var ric = readPrefixedInt(buf, 8, pos); if (!ric) return {}; pos = ric.next;
|
|
177
183
|
var postBase = (buf[pos] & 0x80) !== 0;
|
|
178
|
-
var db =
|
|
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 =
|
|
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 =
|
|
207
|
+
var nameIdx = readPrefixedInt(buf, 4, pos); if (!nameIdx) break; pos = nameIdx.next;
|
|
202
208
|
var valH = (buf[pos] & 0x80) !== 0;
|
|
203
|
-
var valLen =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|