quico 0.2.0 → 0.3.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 +5 -2
- package/package.json +2 -2
- package/src/agent.js +9 -1
- package/src/client.js +6 -1
- package/src/crypto.js +20 -5
- package/src/h3.js +52 -43
- package/src/h3_client.js +93 -78
- package/src/h3_server.js +52 -183
- package/src/quic_connection.js +1221 -121
- package/src/quic_server.js +314 -0
- package/src/quic_socket.js +76 -0
- package/src/server.js +30 -22
- package/src/tls_bridge.js +74 -12
- package/src/transport.js +50 -34
- package/src/utils.js +59 -4
- package/src/webtransport.js +25 -78
package/index.js
CHANGED
|
@@ -18,6 +18,7 @@ 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';
|
|
21
22
|
|
|
22
23
|
export {
|
|
23
24
|
createServer,
|
|
@@ -29,7 +30,8 @@ export {
|
|
|
29
30
|
ServerResponse,
|
|
30
31
|
ClientRequest,
|
|
31
32
|
createSecureContext,
|
|
32
|
-
WebTransport
|
|
33
|
+
WebTransport,
|
|
34
|
+
createQuicServer
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
export default {
|
|
@@ -42,5 +44,6 @@ export default {
|
|
|
42
44
|
ServerResponse,
|
|
43
45
|
ClientRequest,
|
|
44
46
|
createSecureContext,
|
|
45
|
-
WebTransport
|
|
47
|
+
WebTransport,
|
|
48
|
+
createQuicServer
|
|
46
49
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quico",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.4.1"
|
|
82
82
|
}
|
|
83
83
|
}
|
package/src/agent.js
CHANGED
|
@@ -158,7 +158,7 @@ class Agent extends EventEmitter {
|
|
|
158
158
|
var conn = this._h3Pool[key];
|
|
159
159
|
if (!conn) return null;
|
|
160
160
|
var state = conn.quic ? conn.quic.state : 'closed';
|
|
161
|
-
if (state === 'closed' || state === 'draining') {
|
|
161
|
+
if (state === 'closed' || state === 'draining' || state === 'closing') {
|
|
162
162
|
delete this._h3Pool[key];
|
|
163
163
|
return null;
|
|
164
164
|
}
|
|
@@ -173,6 +173,14 @@ class Agent extends EventEmitter {
|
|
|
173
173
|
if (this._h3Pool[key] === conn) delete this._h3Pool[key];
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
|
+
// RFC 9114 §5.2: once the peer sends GOAWAY it won't accept new requests, so
|
|
177
|
+
// stop reusing this connection for new ones. In-flight requests keep running
|
|
178
|
+
// on it; they just won't be joined by new requests (which open a fresh conn).
|
|
179
|
+
if (conn.h3 && typeof conn.h3.on === 'function') {
|
|
180
|
+
conn.h3.on('goaway', () => {
|
|
181
|
+
if (this._h3Pool[key] === conn) delete this._h3Pool[key];
|
|
182
|
+
});
|
|
183
|
+
}
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
|
package/src/client.js
CHANGED
|
@@ -209,8 +209,13 @@ function request(url, options, callback) {
|
|
|
209
209
|
headers: Object.assign({}, headers)
|
|
210
210
|
};
|
|
211
211
|
|
|
212
|
-
// Check Agent for existing QUIC connection (multiplexing)
|
|
212
|
+
// Check Agent for existing QUIC connection (multiplexing).
|
|
213
|
+
// With an agent, the connection's lifecycle is owned by the pool
|
|
214
|
+
// (sweep / idle timer / agent.destroy), so signal h3_client not to
|
|
215
|
+
// auto-close it when this request finishes — otherwise the first
|
|
216
|
+
// request would tear down a connection still pooled for reuse.
|
|
213
217
|
if (agent) {
|
|
218
|
+
h3Opts._managed = true;
|
|
214
219
|
var existing = agent.getH3Connection(hostname, port);
|
|
215
220
|
if (existing) h3Opts._connection = existing;
|
|
216
221
|
}
|
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,8 +364,13 @@ 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
|
|
|
361
376
|
var firstByte = array[0];
|
|
@@ -405,7 +420,7 @@ function decrypt_quic_packet(array, readKey, readIv, readHp, dcid, largestPn) {
|
|
|
405
420
|
|
|
406
421
|
} else {
|
|
407
422
|
// Short Header
|
|
408
|
-
var dcidLen =
|
|
423
|
+
var dcidLen = ownCid.length;
|
|
409
424
|
pnOffset = 1 + dcidLen;
|
|
410
425
|
|
|
411
426
|
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 };
|
package/src/h3_client.js
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* https://github.com/colocohen/quico
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import dgram from 'node:dgram';
|
|
16
15
|
import dns from 'node:dns';
|
|
17
|
-
import {
|
|
16
|
+
import { DEBUG } from './utils.js';
|
|
18
17
|
import { H3Connection } from './h3.js';
|
|
18
|
+
import { createQuicClientSocket } from './quic_socket.js';
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
// ============================================================
|
|
@@ -38,11 +38,16 @@ function request(options, callback) {
|
|
|
38
38
|
var path = options.path || '/';
|
|
39
39
|
var method = (options.method || 'GET').toUpperCase();
|
|
40
40
|
var headers = options.headers || {};
|
|
41
|
-
var rejectUnauthorized = options.rejectUnauthorized !== false;
|
|
42
41
|
|
|
43
42
|
// Connection reuse: pass _connection = { quic, h3, udpSocket, nextStreamId }
|
|
44
43
|
var existingConn = options._connection || null;
|
|
45
|
-
|
|
44
|
+
// Auto-close the QUIC connection when this request finishes ONLY when we own
|
|
45
|
+
// it — i.e. a fresh, standalone connection. Reused connections (existingConn)
|
|
46
|
+
// and connections whose lifecycle is managed by an external pool
|
|
47
|
+
// (options._managed, set by the unified client/agent) are left open; closing
|
|
48
|
+
// them here would tear down a connection still pooled for reuse. Managed
|
|
49
|
+
// connections are reaped by the agent (sweep/destroy) and the QUIC idle timer.
|
|
50
|
+
var autoClose = !existingConn && !options._managed;
|
|
46
51
|
|
|
47
52
|
// ---- Client Request object ----
|
|
48
53
|
var reqBody = [];
|
|
@@ -104,9 +109,12 @@ function request(options, callback) {
|
|
|
104
109
|
|
|
105
110
|
// ---- Resolve hostname and connect (or reuse existing) ----
|
|
106
111
|
if (existingConn) {
|
|
107
|
-
// Reuse existing connection — allocate stream ID and send when ready
|
|
112
|
+
// Reuse existing connection — allocate stream ID and send when ready.
|
|
113
|
+
// The shared dispatch listeners were installed when the connection was
|
|
114
|
+
// first created; installSharedHandlers() is idempotent.
|
|
108
115
|
streamId = allocateStreamId(existingConn);
|
|
109
|
-
|
|
116
|
+
installSharedHandlers(existingConn);
|
|
117
|
+
setupResponseHandlers(existingConn);
|
|
110
118
|
if (reqFinished) sendRequest();
|
|
111
119
|
} else {
|
|
112
120
|
resolveAndConnect(hostname, port);
|
|
@@ -132,60 +140,40 @@ function request(options, callback) {
|
|
|
132
140
|
|
|
133
141
|
|
|
134
142
|
function startConnection(remoteIp, remotePort) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
143
|
+
udpSocket = createQuicClientSocket({
|
|
144
|
+
remoteIp: remoteIp,
|
|
145
|
+
remotePort: remotePort,
|
|
146
|
+
hostname: hostname,
|
|
138
147
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
udpSocket.on('error', function (err) {
|
|
144
|
-
emit(reqListeners, 'error', err);
|
|
145
|
-
});
|
|
148
|
+
onError: function (err) {
|
|
149
|
+
emit(reqListeners, 'error', err);
|
|
150
|
+
},
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
isServer: false,
|
|
152
|
-
SNICallback: null,
|
|
153
|
-
hostname: hostname
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// Send packets via UDP
|
|
157
|
-
quic.on('packet', function (data) {
|
|
158
|
-
udpSocket.send(data, remotePort, remoteIp, function (err) {
|
|
159
|
-
if (err) emit(reqListeners, 'error', err);
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// QUIC connected → set up H3
|
|
164
|
-
quic.on('connect', function () {
|
|
165
|
-
console.log('[client] QUIC connected');
|
|
152
|
+
onConnect: function (q, s) {
|
|
153
|
+
quic = q;
|
|
154
|
+
udpSocket = s;
|
|
155
|
+
if (DEBUG) console.log('[client] QUIC connected');
|
|
166
156
|
|
|
167
157
|
h3 = new H3Connection({ quicConnection: quic, isServer: false });
|
|
168
158
|
connected = true;
|
|
169
159
|
|
|
170
160
|
// Build connection object for reuse
|
|
171
161
|
var conn = { quic: quic, h3: h3, udpSocket: udpSocket, _nextStreamId: 0 };
|
|
162
|
+
|
|
163
|
+
// Install the shared per-connection dispatch listeners ONCE, then
|
|
164
|
+
// register this request in the connection's stream-handler map.
|
|
165
|
+
installSharedHandlers(conn);
|
|
172
166
|
streamId = allocateStreamId(conn);
|
|
173
167
|
|
|
174
168
|
// Store on clientReq so caller (unified client) can reuse
|
|
175
169
|
clientReq._connection = conn;
|
|
176
170
|
|
|
177
|
-
setupResponseHandlers();
|
|
171
|
+
setupResponseHandlers(conn);
|
|
178
172
|
|
|
179
173
|
// Send request if body is ready
|
|
180
174
|
if (reqFinished) sendRequest();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
quic.on('close', function () {
|
|
184
|
-
if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// Start QUIC handshake
|
|
188
|
-
quic.connect();
|
|
175
|
+
}
|
|
176
|
+
// onClose: the helper closes the UDP socket; nothing extra needed here.
|
|
189
177
|
});
|
|
190
178
|
}
|
|
191
179
|
|
|
@@ -202,51 +190,78 @@ function request(options, callback) {
|
|
|
202
190
|
|
|
203
191
|
|
|
204
192
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
193
|
+
* Install the three HTTP/3 event listeners ONCE per connection. They dispatch
|
|
194
|
+
* each event to the right request via the connection's stream-handler map
|
|
195
|
+
* (keyed by stream id), so multiplexing many requests over a single
|
|
196
|
+
* connection does NOT pile up listeners on the H3 emitter (previously every
|
|
197
|
+
* request added another set of listeners that were never removed).
|
|
198
|
+
* Idempotent per connection.
|
|
207
199
|
*/
|
|
208
|
-
function
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// Fire callback with response
|
|
217
|
-
if (typeof callback === 'function') {
|
|
218
|
-
callback(clientRes);
|
|
219
|
-
callback = null; // only once
|
|
220
|
-
}
|
|
221
|
-
emit(reqListeners, 'response', clientRes);
|
|
200
|
+
function installSharedHandlers(conn) {
|
|
201
|
+
if (conn._streamHandlers) return; // already installed for this connection
|
|
202
|
+
conn._streamHandlers = {};
|
|
203
|
+
var h3c = conn.h3;
|
|
204
|
+
|
|
205
|
+
h3c.on('http_headers', function (sid, hdrs) {
|
|
206
|
+
var h = conn._streamHandlers[sid];
|
|
207
|
+
if (h) h.onHeaders(hdrs);
|
|
222
208
|
});
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
209
|
+
h3c.on('http_body', function (sid, data) {
|
|
210
|
+
var h = conn._streamHandlers[sid];
|
|
211
|
+
if (h) h.onBody(data);
|
|
212
|
+
});
|
|
213
|
+
h3c.on('http_end', function (sid) {
|
|
214
|
+
var h = conn._streamHandlers[sid];
|
|
215
|
+
if (!h) return;
|
|
216
|
+
delete conn._streamHandlers[sid]; // free per-stream state before dispatch
|
|
217
|
+
h.onEnd();
|
|
228
218
|
});
|
|
219
|
+
}
|
|
220
|
+
|
|
229
221
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Register this request's response handlers in the connection's stream map,
|
|
224
|
+
* keyed by its streamId. Replaces per-request h3.on(...) registration —
|
|
225
|
+
* routing is now by map key, so no per-listener streamId check is needed.
|
|
226
|
+
*/
|
|
227
|
+
function setupResponseHandlers(conn) {
|
|
228
|
+
conn._streamHandlers[streamId] = {
|
|
229
|
+
onHeaders: function (hdrs) {
|
|
230
|
+
clientRes.headers = hdrs;
|
|
231
|
+
clientRes.statusCode = parseInt(hdrs[':status']) || 0;
|
|
232
|
+
clientRes._headersReceived = true;
|
|
233
|
+
|
|
234
|
+
// Fire callback with response (once)
|
|
235
|
+
if (typeof callback === 'function') {
|
|
236
|
+
callback(clientRes);
|
|
237
|
+
callback = null;
|
|
238
|
+
}
|
|
239
|
+
emit(reqListeners, 'response', clientRes);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
onBody: function (data) {
|
|
243
|
+
emit(clientRes, 'data', data);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
onEnd: function () {
|
|
247
|
+
emit(clientRes, 'end');
|
|
248
|
+
|
|
249
|
+
// Auto-close only if we own the connection (see `autoClose` above).
|
|
250
|
+
if (autoClose) {
|
|
251
|
+
setTimeout(function () {
|
|
252
|
+
if (quic) quic.close(0, 'done');
|
|
253
|
+
if (udpSocket) { try { udpSocket.close(); } catch (e) {} }
|
|
254
|
+
}, 100);
|
|
255
|
+
}
|
|
241
256
|
}
|
|
242
|
-
}
|
|
257
|
+
};
|
|
243
258
|
}
|
|
244
259
|
|
|
245
260
|
|
|
246
261
|
function sendRequest() {
|
|
247
262
|
if (!h3 || !connected) return;
|
|
248
263
|
|
|
249
|
-
console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
|
|
264
|
+
if (DEBUG) console.log('[client] sendRequest method=' + method + ' path=' + path + ' stream=' + streamId);
|
|
250
265
|
|
|
251
266
|
// MUST send control streams (SETTINGS) before any request
|
|
252
267
|
h3.sendControlStreams();
|