quico 0.1.1 → 0.2.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.
@@ -0,0 +1,708 @@
1
+ /*
2
+ * quico: HTTP/3 and QUIC implementation for Node.js
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * This file is part of the open-source project hosted at:
12
+ * https://github.com/colocohen/quico
13
+ */
14
+
15
+ import { Duplex, Readable, Writable } from 'node:stream';
16
+ import { EventEmitter } from 'node:events';
17
+ import dgram from 'node:dgram';
18
+ import dns from 'node:dns';
19
+ import { QUICConnection } from './quic_connection.js';
20
+ import { H3Connection } from './h3.js';
21
+ import { writeVarInt, readVarInt } from './utils.js';
22
+
23
+
24
+ // ============================================================
25
+ // WebTransportBidiStream — Duplex + WHATWG .readable/.writable
26
+ //
27
+ // Node.js style:
28
+ // stream.write(data);
29
+ // stream.on('data', chunk => { ... });
30
+ // stream.pipe(dest);
31
+ //
32
+ // WHATWG style:
33
+ // const writer = stream.writable.getWriter();
34
+ // const reader = stream.readable.getReader();
35
+ // ============================================================
36
+
37
+ class WebTransportBidiStream extends Duplex {
38
+
39
+ constructor(options) {
40
+ super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
41
+
42
+ this._streamId = options.streamId;
43
+ this._quic = options.quic;
44
+ this._sessionId = options.sessionId;
45
+ this._prefixSent = options.prefixSent || false;
46
+
47
+ // Lazy WHATWG wrappers
48
+ this._whatwgReadable = null;
49
+ this._whatwgWritable = null;
50
+ }
51
+
52
+ // ---- WHATWG getters ----
53
+
54
+ get readable() {
55
+ if (!this._whatwgReadable) {
56
+ var self = this;
57
+ this._whatwgReadable = new ReadableStream({
58
+ start(controller) {
59
+ self.on('data', function (chunk) {
60
+ controller.enqueue(chunk);
61
+ });
62
+ self.on('end', function () {
63
+ controller.close();
64
+ });
65
+ self.on('error', function (err) {
66
+ controller.error(err);
67
+ });
68
+ }
69
+ });
70
+ }
71
+ return this._whatwgReadable;
72
+ }
73
+
74
+ get writable() {
75
+ if (!this._whatwgWritable) {
76
+ var self = this;
77
+ this._whatwgWritable = new WritableStream({
78
+ write(chunk) {
79
+ return new Promise(function (resolve, reject) {
80
+ self.write(chunk, function (err) { err ? reject(err) : resolve(); });
81
+ });
82
+ },
83
+ close() {
84
+ return new Promise(function (resolve) { self.end(resolve); });
85
+ }
86
+ });
87
+ }
88
+ return this._whatwgWritable;
89
+ }
90
+
91
+ // ---- Node.js Duplex implementation ----
92
+
93
+ _read(_size) { /* data pushed externally via _pushData */ }
94
+
95
+ _write(chunk, encoding, callback) {
96
+ if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
97
+ if (Buffer.isBuffer(chunk)) {
98
+ chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
99
+ }
100
+
101
+ // First write on this stream: prepend VarInt(0x41) + VarInt(session_id)
102
+ if (!this._prefixSent) {
103
+ this._prefixSent = true;
104
+ var typePrefix = writeVarInt(0x41); // WEBTRANSPORT_STREAM bidi type
105
+ var sessionPrefix = writeVarInt(this._sessionId);
106
+ var combined = new Uint8Array(typePrefix.length + sessionPrefix.length + chunk.byteLength);
107
+ combined.set(typePrefix, 0);
108
+ combined.set(sessionPrefix, typePrefix.length);
109
+ combined.set(chunk, typePrefix.length + sessionPrefix.length);
110
+ this._quic.sendStream(this._streamId, combined, false);
111
+ } else {
112
+ this._quic.sendStream(this._streamId, chunk, false);
113
+ }
114
+ callback();
115
+ }
116
+
117
+ _final(callback) {
118
+ // Send FIN (with type + session_id prefix if nothing was written yet)
119
+ if (!this._prefixSent) {
120
+ this._prefixSent = true;
121
+ var typePrefix = writeVarInt(0x41);
122
+ var sessionPrefix = writeVarInt(this._sessionId);
123
+ var header = new Uint8Array(typePrefix.length + sessionPrefix.length);
124
+ header.set(typePrefix, 0);
125
+ header.set(sessionPrefix, typePrefix.length);
126
+ this._quic.sendStream(this._streamId, header, true);
127
+ } else {
128
+ this._quic.sendStream(this._streamId, new Uint8Array(0), true);
129
+ }
130
+ callback();
131
+ }
132
+
133
+ /**
134
+ * Called by WebTransport when data arrives for this stream.
135
+ */
136
+ _pushData(chunk) {
137
+ if (Buffer.isBuffer(chunk)) this.push(chunk);
138
+ else if (chunk instanceof Uint8Array) this.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
139
+ else this.push(chunk);
140
+ }
141
+
142
+ _endData() {
143
+ this.push(null);
144
+ }
145
+ }
146
+
147
+
148
+ // ============================================================
149
+ // WebTransportUniStream — Writable (outgoing) or Readable (incoming)
150
+ // ============================================================
151
+
152
+ class WebTransportUniWriteStream extends Writable {
153
+
154
+ constructor(options) {
155
+ super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
156
+ this._streamId = options.streamId;
157
+ this._quic = options.quic;
158
+ this._sessionId = options.sessionId;
159
+ this._headerSent = false;
160
+ }
161
+
162
+ _write(chunk, encoding, callback) {
163
+ if (typeof chunk === 'string') chunk = Buffer.from(chunk, encoding);
164
+ if (Buffer.isBuffer(chunk)) {
165
+ chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
166
+ }
167
+
168
+ if (!this._headerSent) {
169
+ this._headerSent = true;
170
+ // Uni stream: VarInt(0x54) (WEBTRANSPORT_STREAM) + VarInt(session_id) + data
171
+ var typePrefix = writeVarInt(0x54);
172
+ var sessionPrefix = writeVarInt(this._sessionId);
173
+ var header = new Uint8Array(typePrefix.length + sessionPrefix.length + chunk.byteLength);
174
+ header.set(typePrefix, 0);
175
+ header.set(sessionPrefix, typePrefix.length);
176
+ header.set(chunk, typePrefix.length + sessionPrefix.length);
177
+ this._quic.sendStream(this._streamId, header, false);
178
+ } else {
179
+ this._quic.sendStream(this._streamId, chunk, false);
180
+ }
181
+ callback();
182
+ }
183
+
184
+ _final(callback) {
185
+ if (!this._headerSent) {
186
+ this._headerSent = true;
187
+ var typePrefix = writeVarInt(0x54);
188
+ var sessionPrefix = writeVarInt(this._sessionId);
189
+ var header = new Uint8Array(typePrefix.length + sessionPrefix.length);
190
+ header.set(typePrefix, 0);
191
+ header.set(sessionPrefix, typePrefix.length);
192
+ this._quic.sendStream(this._streamId, header, true);
193
+ } else {
194
+ this._quic.sendStream(this._streamId, new Uint8Array(0), true);
195
+ }
196
+ callback();
197
+ }
198
+
199
+ // WHATWG getter
200
+ get writable() {
201
+ if (!this._whatwgWritable) {
202
+ var self = this;
203
+ this._whatwgWritable = new WritableStream({
204
+ write(chunk) {
205
+ return new Promise(function (resolve, reject) {
206
+ self.write(chunk, function (err) { err ? reject(err) : resolve(); });
207
+ });
208
+ },
209
+ close() {
210
+ return new Promise(function (resolve) { self.end(resolve); });
211
+ }
212
+ });
213
+ }
214
+ return this._whatwgWritable;
215
+ }
216
+ }
217
+
218
+
219
+ class WebTransportUniReadStream extends Readable {
220
+
221
+ constructor(options) {
222
+ super({ highWaterMark: (options && options.highWaterMark) || 64 * 1024 });
223
+ this._streamId = options.streamId;
224
+ }
225
+
226
+ _read(_size) { /* data pushed externally */ }
227
+
228
+ _pushData(chunk) {
229
+ if (Buffer.isBuffer(chunk)) this.push(chunk);
230
+ else if (chunk instanceof Uint8Array) this.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
231
+ else this.push(chunk);
232
+ }
233
+
234
+ _endData() { this.push(null); }
235
+
236
+ // WHATWG getter
237
+ get readable() {
238
+ if (!this._whatwgReadable) {
239
+ var self = this;
240
+ this._whatwgReadable = new ReadableStream({
241
+ start(controller) {
242
+ self.on('data', function (chunk) { controller.enqueue(chunk); });
243
+ self.on('end', function () { controller.close(); });
244
+ self.on('error', function (err) { controller.error(err); });
245
+ }
246
+ });
247
+ }
248
+ return this._whatwgReadable;
249
+ }
250
+ }
251
+
252
+
253
+ // ============================================================
254
+ // WebTransport — Client
255
+ //
256
+ // Usage:
257
+ // var wt = new WebTransport('https://server:4433/path');
258
+ // await wt.ready;
259
+ //
260
+ // // Bidi stream (Duplex)
261
+ // var stream = await wt.createBidirectionalStream();
262
+ // stream.write('hello');
263
+ // stream.on('data', chunk => console.log(chunk));
264
+ //
265
+ // // Also works as WHATWG:
266
+ // var writer = stream.writable.getWriter();
267
+ // var reader = stream.readable.getReader();
268
+ //
269
+ // // Uni stream (outgoing only)
270
+ // var uni = await wt.createUnidirectionalStream();
271
+ // uni.write('one-way data');
272
+ // uni.end();
273
+ //
274
+ // // Datagrams
275
+ // wt.sendDatagram(Buffer.from('ping'));
276
+ // wt.on('datagram', data => console.log('pong:', data));
277
+ //
278
+ // // Incoming streams
279
+ // wt.on('stream', stream => { ... }); // bidi from server
280
+ // wt.on('unidirectionalStream', stream => { ... }); // uni from server
281
+ //
282
+ // wt.close();
283
+ // ============================================================
284
+
285
+ class WebTransport extends EventEmitter {
286
+
287
+ constructor(url, options) {
288
+ super();
289
+
290
+ // Parse URL
291
+ var parsed = new URL(url);
292
+ this._hostname = parsed.hostname;
293
+ this._port = parseInt(parsed.port) || 443;
294
+ this._path = parsed.pathname + (parsed.search || '');
295
+ this._options = options || {};
296
+
297
+ // State
298
+ this._state = 'connecting'; // connecting → connected → closed
299
+ this._sessionId = null; // stream ID of the CONNECT request
300
+ this._quic = null;
301
+ this._h3 = null;
302
+ this._udpSocket = null;
303
+
304
+ // Stream tracking
305
+ this._streams = {}; // streamId → WebTransportBidiStream | ReadStream
306
+ this._claimedStreams = new Set(); // stream IDs owned by WT (not H3)
307
+
308
+ // Client-initiated stream ID counters
309
+ // Bidi: 0 used for CONNECT, so WT bidi starts at 4, 8, 12...
310
+ // Uni: 2, 6, 10 used for H3 control/qpack, so WT uni starts at 14, 18, 22...
311
+ this._nextBidiStreamId = 4;
312
+ this._nextUniStreamId = 14;
313
+
314
+ // Incoming stream prefix buffers (for parsing session_id)
315
+ this._incomingBuffers = {};
316
+
317
+ // Promises (browser-compatible API)
318
+ var self = this;
319
+ this.ready = new Promise(function (resolve, reject) {
320
+ self._readyResolve = resolve;
321
+ self._readyReject = reject;
322
+ });
323
+
324
+ this.closed = new Promise(function (resolve) {
325
+ self._closedResolve = resolve;
326
+ });
327
+
328
+ // Start connection
329
+ this._connect();
330
+ }
331
+
332
+
333
+ // ---- Connection setup ----
334
+
335
+ _connect() {
336
+ var host = this._hostname;
337
+ var self = this;
338
+
339
+ if (/^[\d.]+$/.test(host) || host.indexOf(':') >= 0 || host === 'localhost') {
340
+ var ip = (host === 'localhost') ? '127.0.0.1' : host;
341
+ this._startQuic(ip);
342
+ } else {
343
+ dns.lookup(host, function (err, address) {
344
+ if (err) { self._readyReject(err); self.emit('error', err); return; }
345
+ self._startQuic(address);
346
+ });
347
+ }
348
+ }
349
+
350
+ _startQuic(remoteIp) {
351
+ var self = this;
352
+ var remotePort = this._port;
353
+ var isIPv6 = remoteIp.indexOf(':') >= 0;
354
+
355
+ this._udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
356
+
357
+ this._udpSocket.on('message', function (msg, rinfo) {
358
+ if (self._quic) self._quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
359
+ });
360
+
361
+ this._udpSocket.on('error', function (err) {
362
+ if (self._state === 'connecting') self._readyReject(err);
363
+ self.emit('error', err);
364
+ });
365
+
366
+ this._udpSocket.bind(0, function () {
367
+ self._quic = new QUICConnection({
368
+ isServer: false,
369
+ hostname: self._hostname
370
+ });
371
+
372
+ self._quic.on('packet', function (data) {
373
+ self._udpSocket.send(data, remotePort, remoteIp);
374
+ });
375
+
376
+ self._quic.on('connect', function () {
377
+ self._h3 = new H3Connection({ quicConnection: self._quic, isServer: false, enableWebTransport: true });
378
+ self._setupH3Handlers();
379
+ self._sendConnect();
380
+ });
381
+
382
+ self._quic.on('close', function () {
383
+ self._state = 'closed';
384
+ self._closedResolve();
385
+ self.emit('close');
386
+ if (self._udpSocket) { try { self._udpSocket.close(); } catch (e) {} }
387
+ });
388
+
389
+ self._quic.connect();
390
+ });
391
+ }
392
+
393
+
394
+ // ---- H3 + QUIC event handlers ----
395
+
396
+ _setupH3Handlers() {
397
+ var self = this;
398
+
399
+ // CONNECT response headers
400
+ this._h3.on('http_headers', function (streamId, headers) {
401
+ if (streamId === self._sessionId) {
402
+ var status = parseInt(headers[':status']) || 0;
403
+ if (status === 200) {
404
+ self._state = 'connected';
405
+ self._readyResolve();
406
+ self.emit('ready');
407
+ } else {
408
+ var err = new Error('WebTransport CONNECT failed: HTTP ' + status);
409
+ self._readyReject(err);
410
+ self.emit('error', err);
411
+ }
412
+ }
413
+ });
414
+
415
+ // Raw QUIC stream data — intercept WT streams before H3 processes them
416
+ // We register on QUIC directly. H3 will also receive these events,
417
+ // but we call h3.claimStream() for WT streams so H3 skips them.
418
+ this._quic.on('stream', function (streamId, data, fin) {
419
+ // Skip streams owned by H3 (CONNECT stream, control streams)
420
+ if (streamId === self._sessionId) return; // H3 handles CONNECT response
421
+ if (!self._isWebTransportStream(streamId)) return;
422
+
423
+ self._handleStreamData(streamId, data, fin);
424
+ });
425
+
426
+ // Datagrams — QUIC DATAGRAM payload = VarInt(quarter_stream_id) + app data (RFC 9297)
427
+ this._quic.on('datagram', function (_contextId, rawData) {
428
+ if (!rawData || rawData.byteLength === 0) return;
429
+
430
+ // Always parse VarInt quarter_stream_id from payload
431
+ var result = readVarInt(rawData, 0);
432
+ if (!result) return;
433
+ var qsid = result.value;
434
+ var payload = rawData.slice(result.byteLength);
435
+
436
+ var expectedQsid = Math.floor(self._sessionId / 4);
437
+ if (self._sessionId !== null && qsid === expectedQsid) {
438
+ self.emit('datagram', Buffer.from(payload));
439
+ }
440
+ });
441
+ }
442
+
443
+ _isWebTransportStream(streamId) {
444
+ // Already claimed
445
+ if (this._claimedStreams.has(streamId)) return true;
446
+
447
+ // Server-initiated bidi: 1, 5, 9, 13... (could be WT)
448
+ // Server-initiated uni: 3, 7, 11, 15... (11+ could be WT, 3/7/11 are H3)
449
+ // Client-initiated bidi: 4+ are WT (0 is CONNECT)
450
+ // Client-initiated uni: 14+ are WT (2/6/10 are H3)
451
+ var type = streamId & 0x3;
452
+
453
+ if (type === 0x0) {
454
+ // Client bidi — stream 0 is CONNECT, 4+ are WT
455
+ return streamId >= 4;
456
+ } else if (type === 0x1) {
457
+ // Server bidi — all are potentially WT
458
+ return true;
459
+ } else if (type === 0x2) {
460
+ // Client uni — 2/6/10 are H3, 14+ are WT
461
+ return streamId >= 14;
462
+ } else {
463
+ // Server uni — 3/7/11 are H3, 15+ are potentially WT
464
+ return streamId >= 15;
465
+ }
466
+ }
467
+
468
+ _handleStreamData(streamId, data, fin) {
469
+ // Already have a stream object?
470
+ if (streamId in this._streams) {
471
+ var stream = this._streams[streamId];
472
+ if (data.byteLength > 0) stream._pushData(data);
473
+ if (fin) { stream._endData(); delete this._streams[streamId]; }
474
+ return;
475
+ }
476
+
477
+ // New incoming stream — need to parse session_id prefix
478
+ if (!(streamId in this._incomingBuffers)) {
479
+ this._incomingBuffers[streamId] = { chunks: [], totalLen: 0 };
480
+ }
481
+
482
+ var buf = this._incomingBuffers[streamId];
483
+ buf.chunks.push(data);
484
+ buf.totalLen += data.byteLength;
485
+
486
+ // Try to parse prefix
487
+ var combined = buf.totalLen === data.byteLength ? data : this._combineChunks(buf.chunks, buf.totalLen);
488
+ var isBidi = (streamId & 0x2) === 0;
489
+ var offset = 0;
490
+
491
+ // Parse stream type as VarInt (0x41=bidi WT, 0x54=uni WT)
492
+ var typeResult = readVarInt(combined, 0);
493
+ if (!typeResult) { if (fin) delete this._incomingBuffers[streamId]; return; }
494
+ var streamType = typeResult.value;
495
+ offset = typeResult.byteLength;
496
+
497
+ if (!isBidi) {
498
+ if (streamType !== 0x54) {
499
+ // Not a WT uni stream — let H3 handle it
500
+ delete this._incomingBuffers[streamId];
501
+ return;
502
+ }
503
+ } else {
504
+ if (streamType !== 0x41) {
505
+ delete this._incomingBuffers[streamId];
506
+ return;
507
+ }
508
+ }
509
+
510
+ // Parse VarInt session_id
511
+ if (offset >= combined.byteLength) { if (fin) delete this._incomingBuffers[streamId]; return; }
512
+ var result = readVarInt(combined, offset);
513
+ if (!result) { if (fin) delete this._incomingBuffers[streamId]; return; }
514
+
515
+ var sessionId = result.value;
516
+ var afterPrefix = offset + result.byteLength;
517
+
518
+ // Verify it matches our session
519
+ if (sessionId !== this._sessionId) {
520
+ delete this._incomingBuffers[streamId];
521
+ return;
522
+ }
523
+
524
+ // Claim stream — tell H3 to skip it
525
+ this._claimedStreams.add(streamId);
526
+ if (this._h3 && this._h3.claimStream) this._h3.claimStream(streamId);
527
+ delete this._incomingBuffers[streamId];
528
+
529
+ // Create stream object
530
+ var remaining = combined.byteLength > afterPrefix
531
+ ? combined.slice(afterPrefix)
532
+ : new Uint8Array(0);
533
+
534
+ if (isBidi) {
535
+ var stream = new WebTransportBidiStream({
536
+ streamId: streamId,
537
+ quic: this._quic,
538
+ sessionId: this._sessionId,
539
+ prefixSent: false // server-initiated, prefix not needed for writes back
540
+ });
541
+ this._streams[streamId] = stream;
542
+ if (remaining.byteLength > 0) stream._pushData(remaining);
543
+ if (fin) { stream._endData(); delete this._streams[streamId]; }
544
+ this.emit('stream', stream);
545
+
546
+ } else {
547
+ var stream = new WebTransportUniReadStream({ streamId: streamId });
548
+ this._streams[streamId] = stream;
549
+ if (remaining.byteLength > 0) stream._pushData(remaining);
550
+ if (fin) { stream._endData(); delete this._streams[streamId]; }
551
+ this.emit('unidirectionalStream', stream);
552
+ }
553
+ }
554
+
555
+ _combineChunks(chunks, totalLen) {
556
+ if (chunks.length === 1) return chunks[0];
557
+ var out = new Uint8Array(totalLen);
558
+ var off = 0;
559
+ for (var i = 0; i < chunks.length; i++) {
560
+ out.set(chunks[i], off);
561
+ off += chunks[i].byteLength;
562
+ }
563
+ return out;
564
+ }
565
+
566
+
567
+ // ---- Send CONNECT ----
568
+
569
+ _sendConnect() {
570
+ // Send H3 control streams (SETTINGS)
571
+ this._h3.sendControlStreams();
572
+
573
+ // CONNECT uses stream 0 (first client bidi)
574
+ this._sessionId = 0;
575
+
576
+ // Send Extended CONNECT — no FIN (session stays open)
577
+ var headers = {
578
+ ':method': 'CONNECT',
579
+ ':protocol': 'webtransport',
580
+ ':scheme': 'https',
581
+ ':authority': this._hostname + (this._port !== 443 ? ':' + this._port : ''),
582
+ ':path': this._path
583
+ };
584
+
585
+ this._h3.sendHeaders(this._sessionId, headers, false);
586
+ }
587
+
588
+
589
+ // ============================================================
590
+ // Public API
591
+ // ============================================================
592
+
593
+ /**
594
+ * Create a bidirectional stream.
595
+ * Returns a Duplex stream (Node.js) with .readable/.writable (WHATWG).
596
+ */
597
+ createBidirectionalStream() {
598
+ var self = this;
599
+ return new Promise(function (resolve, reject) {
600
+ if (self._state !== 'connected') {
601
+ return reject(new Error('WebTransport is not connected'));
602
+ }
603
+
604
+ var streamId = self._nextBidiStreamId;
605
+ self._nextBidiStreamId += 4;
606
+
607
+ // Claim this stream so H3 doesn't process it
608
+ self._claimedStreams.add(streamId);
609
+ if (self._h3 && self._h3.claimStream) self._h3.claimStream(streamId);
610
+
611
+ var stream = new WebTransportBidiStream({
612
+ streamId: streamId,
613
+ quic: self._quic,
614
+ sessionId: self._sessionId,
615
+ prefixSent: false
616
+ });
617
+
618
+ self._streams[streamId] = stream;
619
+
620
+ // Listen for incoming data on this bidi stream
621
+ // (already handled by _handleStreamData via QUIC stream event)
622
+
623
+ resolve(stream);
624
+ });
625
+ }
626
+
627
+ /**
628
+ * Create a unidirectional stream (outgoing only).
629
+ * Returns a Writable stream (Node.js) with .writable (WHATWG).
630
+ */
631
+ createUnidirectionalStream() {
632
+ var self = this;
633
+ return new Promise(function (resolve, reject) {
634
+ if (self._state !== 'connected') {
635
+ return reject(new Error('WebTransport is not connected'));
636
+ }
637
+
638
+ var streamId = self._nextUniStreamId;
639
+ self._nextUniStreamId += 4;
640
+
641
+ // Claim this stream
642
+ self._claimedStreams.add(streamId);
643
+ if (self._h3 && self._h3.claimStream) self._h3.claimStream(streamId);
644
+
645
+ var stream = new WebTransportUniWriteStream({
646
+ streamId: streamId,
647
+ quic: self._quic,
648
+ sessionId: self._sessionId
649
+ });
650
+
651
+ resolve(stream);
652
+ });
653
+ }
654
+
655
+ /**
656
+ * Send an unreliable datagram.
657
+ */
658
+ sendDatagram(data) {
659
+ if (this._state !== 'connected') throw new Error('WebTransport is not connected');
660
+ if (typeof data === 'string') data = Buffer.from(data);
661
+ if (Buffer.isBuffer(data)) data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
662
+
663
+ // Quarter stream ID as context ID (RFC 9297)
664
+ var contextId = Math.floor(this._sessionId / 4);
665
+
666
+ // Build datagram: VarInt(contextId) + payload
667
+ var contextPrefix = writeVarInt(contextId);
668
+ var frame = new Uint8Array(contextPrefix.length + data.byteLength);
669
+ frame.set(contextPrefix, 0);
670
+ frame.set(data, contextPrefix.length);
671
+
672
+ // Send via QUIC DATAGRAM frame
673
+ if (this._quic && this._quic.sendDatagram) {
674
+ this._quic.sendDatagram(frame);
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Close the WebTransport session.
680
+ */
681
+ close(info) {
682
+ if (this._state === 'closed') return;
683
+ this._state = 'closed';
684
+
685
+ // Send CLOSE_WEBTRANSPORT_SESSION on CONNECT stream (optional)
686
+ // Then close QUIC connection
687
+ if (this._quic) {
688
+ this._quic.close(0, (info && info.reason) || 'WebTransport closed');
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Current state.
694
+ */
695
+ get state() {
696
+ return this._state;
697
+ }
698
+
699
+ /**
700
+ * URL this session connected to.
701
+ */
702
+ get url() {
703
+ return 'https://' + this._hostname + ':' + this._port + this._path;
704
+ }
705
+ }
706
+
707
+
708
+ export { WebTransport, WebTransportBidiStream, WebTransportUniWriteStream, WebTransportUniReadStream };