quico 0.1.3 → 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.
@@ -0,0 +1,2338 @@
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 flat_ranges from 'flat-ranges';
16
+
17
+ import {
18
+ DEBUG,
19
+ Emitter,
20
+ concatUint8Arrays,
21
+ uint8Equal,
22
+ ack_frame_to_ranges,
23
+ ranges_to_ack_frame
24
+ } from './utils.js';
25
+
26
+ import {
27
+ quic_derive_init_secrets,
28
+ quic_derive_from_tls_secrets,
29
+ quic_derive_key_update,
30
+ encrypt_quic_packet,
31
+ decrypt_quic_packet,
32
+ extract_tls_messages_from_chunks
33
+ } from './crypto.js';
34
+
35
+ import {
36
+ parse_quic_datagram,
37
+ encode_quic_frames,
38
+ parse_quic_frames
39
+ } from './transport.js';
40
+
41
+ import { TLSBridge } from './tls_bridge.js';
42
+
43
+
44
+ function QUICConnection(options) {
45
+ if (!(this instanceof QUICConnection)) return new QUICConnection(options);
46
+ options = options || {};
47
+
48
+ var ev = Emitter();
49
+ var tls = null;
50
+
51
+ // ============================================================
52
+ // Context
53
+ // ============================================================
54
+
55
+ var context = {
56
+ isServer: options.isServer !== false,
57
+ state: 'idle',
58
+ handshake_done: false,
59
+ handshake_done_sent: false,
60
+
61
+ // Connection close (RFC 9000 §10.2)
62
+ close_frame: null, // the CONNECTION_CLOSE frame to (re)send while closing
63
+ last_close_echo: 0, // timestamp throttle for closing-state CC echoes
64
+
65
+ version: 1,
66
+ original_dcid: null,
67
+ my_cids: [],
68
+ their_cids: [],
69
+
70
+ // Keys
71
+ initial_read: null,
72
+ initial_write: null,
73
+ handshake_read: null,
74
+ handshake_write: null,
75
+ app_read: null,
76
+ app_write: null,
77
+ app_prev_read: null, // previous read keys (kept during transition)
78
+ key_phase: false, // our current send key_phase
79
+ read_key_phase: false, // expected key_phase on incoming packets
80
+ app_read_secret: null, // current read secret (for deriving next)
81
+ app_write_secret: null, // current write secret (for deriving next)
82
+ cipher_hash: 'sha256', // hash for key derivation
83
+ cipher_suite: null, // TLS cipher suite code (for key length)
84
+
85
+ // Packet numbers — sending
86
+ send_pn: { initial: 0, handshake: 0, app: 0 },
87
+
88
+ // Packet numbers — receiving
89
+ recv_pn_largest: { initial: -1, handshake: -1, app: -1 },
90
+ recv_pn_ranges: { initial: [], handshake: [], app: [] },
91
+
92
+ // CRYPTO
93
+ crypto_chunks: { initial: {}, handshake: {} }, // RECEIVE-side reassembly
94
+ crypto_offset: { initial: 0, handshake: 0 },
95
+ crypto_send_offset: { initial: 0, handshake: 0, app: 0 },
96
+
97
+ // CRYPTO send-side loss recovery (Initial/Handshake spaces).
98
+ // The app stream path retransmits "for free" — stream bytes stay in
99
+ // send_streams and the round-robin re-scans them. CRYPTO is otherwise
100
+ // fire-and-forget (cryptoWrite discards the bytes after one send), and the
101
+ // handshake spaces have no expireInFlight. So we mirror the stream model on
102
+ // a tiny per-space structure (same flat-ranges semantics, no stream id, no
103
+ // FIN): retain sent bytes, track which PN carried which byte-range, expire
104
+ // by time, and re-send missing ranges. A CRYPTO frame is just a STREAM frame
105
+ // of a single id-less, fin-less stream — so the bookkeeping is identical.
106
+ crypto_sent: {
107
+ initial: { buf: [], in_flight: {}, acked: [], backoff: 0 },
108
+ handshake: { buf: [], in_flight: {}, acked: [], backoff: 0 },
109
+ },
110
+ crypto_timer: null,
111
+
112
+ // ACK
113
+ pending_ack: { initial: [], handshake: [], app: [] },
114
+
115
+ // Receiving streams
116
+ recv_streams: {},
117
+
118
+ // Sending streams
119
+ send_streams: {},
120
+
121
+ // In-flight tracking (Phase 1)
122
+ sending_app_pn_in_flight: new Set(),
123
+ sending_app_pn_history: [], // [time_sent, encoded_len, delivered_at_send, delivered_time_at_send]
124
+ delivered: 0, // cumulative app bytes acked — for BBR rate samples
125
+ delivered_time: Date.now(), // wall-clock of the last delivered update (BBR delivery clock)
126
+
127
+ // ── Burst / congestion-control knobs ───────────────────────────────────
128
+ // Three layers (+ a floor):
129
+ // max_* HARD CEILING — the programmer sets these; the CC NEVER exceeds
130
+ // them, whatever the network measurements say. Safety net against
131
+ // a CC bug, a pathological link, or memory blow-up.
132
+ // min_* FLOOR — the CC never shrinks below this. Also prevents the
133
+ // deadlock where a cap < one packet rounds down to 0.
134
+ // init_* STARTING point of the current_* values at each new connection.
135
+ // current_* RUNTIME values the CC rewrites (≈ every RTT). Always clamped to
136
+ // [min_, max_]. The send loop reads min(current_, max_), so the
137
+ // moment Phase 4b starts writing current_* it takes effect with no
138
+ // further wiring.
139
+ //
140
+ // Naming: <when>_limit_<noun>. The qualifiers (when=max/min/init/current, and the
141
+ // word `limit`) all sit up front; the noun (bytes_in_flight / packets_per_sec /
142
+ // packet_payload) stays whole at the end. This keeps `current_limit_bytes_in_flight`
143
+ // (the CC's CEILING) clearly distinct from the live `bytesInFlight` it gates against.
144
+ //
145
+ // What each algorithm owns:
146
+ // current_limit_*_in_flight (cwnd) ← Phase 4b (BBR-lite: ≈ 2·BtlBw·min_rtt)
147
+ // current_limit_*_per_sec (pacing rate) ← Phase 4b (BBR-lite: ≈ BtlBw)
148
+ // current_limit_packet_payload (MTU) ← DPLPMTUD (separate, later)
149
+ // Until those algorithms run, init_* = max_* so behavior is the static default.
150
+ // When 4b lands, lower the in-flight init_* toward IW10 (~10 pkts / ~14 KB)
151
+ // and let the algorithm climb from there.
152
+
153
+ max_packets_per_burst: 20, // fixed cap — not CC-controlled, no current_/init_
154
+
155
+ // packet payload (MTU). current_ is the size actually used; max_ is the ceiling
156
+ // DPLPMTUD must not probe past; init_/floor is QUIC's guaranteed 1200-byte minimum.
157
+ max_limit_packet_payload: 1452, // ceiling (IPv6-safe Ethernet: 1500 − 48 hdr)
158
+ init_limit_packet_payload: 1200, // QUIC guaranteed floor — where we start
159
+ current_limit_packet_payload: 1200, // = init_; DPLPMTUD raises toward max_
160
+
161
+ // in-flight window (cwnd) — bytes is the primary signal; packets kept coherent.
162
+ // init_/current_ start at IW10 (RFC 9002 initial window, ~10 pkts) and BBR-lite
163
+ // climbs from there toward 2·BDP; max_ is the hard ceiling, min_ the floor.
164
+ max_limit_packets_in_flight: 256, // ceiling ≈ 300 KB at 1200 B/pkt
165
+ min_limit_packets_in_flight: 2, // floor (RFC 9002 min cwnd)
166
+ init_limit_packets_in_flight: 10, // IW10
167
+ current_limit_packets_in_flight: 10, // = init_; BBR rewrites at round-end
168
+ max_limit_bytes_in_flight: 300000, // ceiling ~300 KB — coherent with packets ↑
169
+ min_limit_bytes_in_flight: 2400, // floor ~2 packets
170
+ init_limit_bytes_in_flight: 12000, // IW10 (10 × 1200 B)
171
+ current_limit_bytes_in_flight: 12000, // = init_; BBR rewrites at round-end
172
+
173
+ // pacing rate
174
+ max_limit_packets_per_sec: 12000, // ceiling ≈ 14 MB/s
175
+ min_limit_packets_per_sec: 10, // floor — avoid 0/deadlock
176
+ init_limit_packets_per_sec: 12000,
177
+ current_limit_packets_per_sec: 12000, // = init_; BBR rewrites at round-end
178
+ max_limit_bytes_per_sec: 14000000, // ceiling ~14 MB/s — coherent ↑
179
+ min_limit_bytes_per_sec: 12000, // floor ~96 kbps — avoid 0/deadlock
180
+ init_limit_bytes_per_sec: 14000000,
181
+ current_limit_bytes_per_sec: 14000000, // = init_; BBR rewrites at round-end
182
+
183
+ burst_timer: null,
184
+ pacing_tokens: 0, // token-bucket pacer: accumulated send credit (bytes)
185
+ pacing_last_refill: Date.now(),
186
+
187
+ // Pending app packets
188
+ pending_app_packets: [],
189
+
190
+ // Flow control — connection level (RFC 9000 §4.1)
191
+ bytes_sent: 0, // total STREAM bytes sent (raw stat, incl. retransmits)
192
+ // max_data_sent: connection-level FC USAGE — the sum of per-stream
193
+ // high-water marks (highest offset sent on each stream). RFC 9000 §4.1
194
+ // counts usage by highest offset, NOT bytes on the wire: retransmissions
195
+ // re-send offsets that were already inside the allowance, so they must not
196
+ // advance this counter. bytes_sent above (which does include retransmits)
197
+ // previously doubled as the FC counter — under loss that inflated usage
198
+ // until sending stalled with budget the peer had actually granted.
199
+ max_data_sent: 0,
200
+ bytes_received: 0, // total STREAM bytes *received off the wire* (incl. out-of-order)
201
+ remote_max_data: 1048576, // peer's limit on what we can send (default 1MB until parsed)
202
+
203
+ // ── Flow control, receive side (RFC 9000 §4.1) — consumption-based
204
+ // sliding window (the ngtcp2/quiche scheme; replaces the old unbounded
205
+ // ×2 doubling, which never actually limited the peer and grew forever).
206
+ //
207
+ // Three numbers per level:
208
+ // window (W) — fixed size, = the value in our transport params.
209
+ // consumed — bytes DELIVERED IN-ORDER to the application (advances in
210
+ // flushStream), NOT bytes received off the wire. Data can
211
+ // arrive out of order and park in the buffer unconsumed —
212
+ // exactly what the window must bound (memory).
213
+ // advertised — the limit last sent to the peer (local_max_data below /
214
+ // per-stream local_max_stream_data). Slides forward by
215
+ // `advertised = consumed + W` once consumed passes half a
216
+ // window since the last update (hysteresis).
217
+ //
218
+ // USAGE (what the peer is measured against) counts by HIGHEST OFFSET per
219
+ // stream (fc_recv_usage = Σ max_recv_offset), not bytes-on-the-wire —
220
+ // retransmissions must not advance it. Exceeding `advertised` on either
221
+ // level is a protocol violation → CONNECTION_CLOSE FLOW_CONTROL_ERROR
222
+ // (0x03). A peer stalled because we haven't consumed sends DATA_BLOCKED /
223
+ // STREAM_DATA_BLOCKED, answered by re-sending the current advertised —
224
+ // that pairing (see the frame handlers) is what makes a lost window
225
+ // update recoverable.
226
+ local_max_data: 1048576, // ADVERTISED conn limit (starts = window, from transport params)
227
+ local_max_data_window: 1048576, // W — fixed; matches initial_max_data we advertise
228
+ local_max_data_consumed: 0, // in-order bytes delivered to the app (all streams)
229
+ fc_recv_usage: 0, // Σ per-stream max_recv_offset — the peer's usage
230
+
231
+ //
232
+ // Flow control — stream level (RFC 9000 §4.1)
233
+ local_initial_max_stream_data: 262144, // matches transport params; doubled on MAX_STREAM_DATA updates
234
+
235
+ remote_max_streams_bidi: 100,
236
+ remote_max_streams_uni: 3,
237
+
238
+ // ── Per-stream send-side flow control (RFC 9000 §4.1) — the peer's limits
239
+ // on what WE may send per stream. Seeded from the peer's transport params
240
+ // (initial_max_stream_data_*, mapped by stream direction/initiator in
241
+ // initialStreamSendLimit), then raised by MAX_STREAM_DATA frames. The RFC
242
+ // default for an omitted param is 0 — a peer that doesn't grant, grants
243
+ // nothing (in practice params always arrive during the handshake, before
244
+ // any app data can flow).
245
+ peer_initial_max_stream_data_bidi_local: 0,
246
+ peer_initial_max_stream_data_bidi_remote: 0,
247
+ peer_initial_max_stream_data_uni: 0,
248
+ // MAX_STREAM_DATA values keyed by stream id (monotonic). Lives outside the
249
+ // stream object so a frame that arrives BEFORE the stream is created is
250
+ // still honored at creation time. Entries are dropped when the stream is
251
+ // fully acked / stopped.
252
+ remote_max_stream_data_by_sid: {},
253
+
254
+ // FC-blocked signaling (DATA_BLOCKED / STREAM_DATA_BLOCKED, RFC 9000 §4.1).
255
+ // Set by get_stream_chunks when a window clamp actually binds; consumed by
256
+ // maybeSendBlockedFrames after each burst pass. This doubles as our loss
257
+ // recovery for window updates: MAX_DATA / MAX_STREAM_DATA are sent once
258
+ // and never retransmitted, so a lost update would stall the sender forever
259
+ // — the repeated (rate-limited) BLOCKED frame prompts the peer to re-send
260
+ // its current limit.
261
+ fc_blocked_conn: false, // conn-level budget bound this pass
262
+ last_data_blocked_sent: 0, // rate-limit timestamp (connection)
263
+ last_stream_blocked_sent: {}, // sid → rate-limit timestamp (per stream)
264
+
265
+ // RTT estimate (RFC 9002 §5), updated incrementally on every new sample.
266
+ // null = no sample yet. Replaces the old raw rtt_history log: the EWMA below
267
+ // keeps the needed history implicitly, so per-sample storage isn't needed.
268
+ srtt: null,
269
+ rttvar: null,
270
+ min_rtt: null,
271
+ latest_rtt: null,
272
+ max_ack_delay: 25, // ms; seeded from the peer's transport params (max_ack_delay) once parsed
273
+ peer_ack_delay_exponent: 3, // seeded from the peer's transport params; ACK Delay = field × 2^this µs
274
+
275
+ // --- Raw network observations (collected for the future congestion
276
+ // controller; NOT acted on here). Flat on context, like the RTT fields.
277
+ // Counters are cumulative; rates/extents are derived later over a window. ---
278
+ max_rtt: null, // paired with min_rtt — the gap reveals bufferbloat
279
+ latest_delivery_rate: null, // bytes/sec acked in the most recent ACK sample
280
+ max_delivery_rate: null, // peak delivery rate ≈ BtlBw (a CC bandwidth input)
281
+ lost_count: 0, // packets declared lost by expireInFlight (≈ loss)
282
+ reorder_in_count: 0, // app packets that arrived below the highest PN seen (network reordering, peer→us)
283
+
284
+ // --- BBR-lite measurement (Phase 4b, step 1+2 — measure only, no control). ---
285
+ // BBR models the path from two measured quantities and derives the BDP:
286
+ // BDP = BtlBw × RTprop. BtlBw = windowed-MAX delivery rate (≈ bottleneck
287
+ // bandwidth); RTprop = windowed-MIN RTT (≈ propagation, queue-free). Both use
288
+ // windows so a stale peak/min decays: a forever-max would never drop when the
289
+ // link slows, a forever-min would never rise as the true RTT changes.
290
+ bbr_round_count: 0, // round-trip counter; one round ≈ one RTT elapsed
291
+ bbr_round_start_pn: 0, // round completes when an ACK's largest >= this
292
+ bbr_round_start_delivered: 0, // context.delivered at round start (for per-round rate)
293
+ bbr_round_start_time: Date.now(), // wall-clock at round start (for per-round rate)
294
+ bbr_btlbw_samples: [], // [{round, rate}] — per-ACK delivery-rate samples
295
+ bbr_btlbw: null, // windowed-max delivery rate over the last N rounds (≈ BtlBw)
296
+ bbr_min_rtt: null, // windowed-min RTT over ~10s (≈ RTprop)
297
+ bbr_min_rtt_stamp: 0, // when bbr_min_rtt was last (re)set
298
+ bbr_bdp: null, // derived BtlBw × min_rtt (bytes) — the in-flight target
299
+
300
+ // BBR-lite state machine. Startup ramps exponentially (high gain) to discover
301
+ // BtlBw; once it plateaus (no ≥25% growth for 3 rounds) the pipe is full, so
302
+ // Drain removes the queue Startup built, then ProbeBW holds steady at the
303
+ // bottleneck rate. Without Startup, writing cwnd from an under-saturated
304
+ // measurement death-spirals to the floor (the link never gets filled).
305
+ bbr_state: 'startup', // 'startup' | 'drain' | 'probe_bw'
306
+ bbr_full_bw: 0, // highest BtlBw seen — Startup plateau detector
307
+ bbr_full_bw_count: 0, // consecutive rounds without ≥25% BtlBw growth
308
+ bbr_cycle_idx: 0, // ProbeBW pacing-gain cycle position (0..7)
309
+
310
+ // Timers
311
+ idle_timeout: options.idleTimeout || 30000,
312
+ handshake_timeout: options.handshakeTimeout || 10000,
313
+ last_activity: Date.now(),
314
+ // Time of the most recent ACK received from the peer (set only in
315
+ // processAckFrame). Drives the in-flight-expiry backoff: while the peer
316
+ // isn't ACKing us, the retransmit timeout widens so we don't flood a dead
317
+ // path. Distinct from last_activity (which also bumps on send/receive).
318
+ last_ack_time: Date.now(),
319
+ idle_timer: null,
320
+ handshake_timer: null,
321
+
322
+ // Keep-alive: when > 0, send a PING after this many ms of inactivity to
323
+ // keep the connection from idling out (RFC 9000 §10.1.2). Should be < the
324
+ // idle timeout. options.keepAlive: true → idle_timeout/2; a number → ms.
325
+ keep_alive_interval: (function () {
326
+ var k = options.keepAlive;
327
+ if (k === true) return Math.max(1000, Math.floor((options.idleTimeout || 30000) / 2));
328
+ if (typeof k === 'number' && k > 0) return k;
329
+ return 0;
330
+ })(),
331
+ keep_alive_timer: null,
332
+
333
+ SNICallback: options.SNICallback || null,
334
+ hostname: options.hostname || null,
335
+
336
+ // ALPN protocol(s) to advertise in the TLS handshake.
337
+ // Defaults to ['h3'] (HTTP/3). Other QUIC-based protocols set their own,
338
+ // e.g. 'doq' for DNS-over-QUIC (RFC 9250). Accepts a string or array.
339
+ alpn: (function () {
340
+ var a = options.alpn || ['h3'];
341
+ return Array.isArray(a) ? a : [a];
342
+ })(),
343
+ };
344
+
345
+
346
+ // ============================================================
347
+ // set_context
348
+ // ============================================================
349
+
350
+ function set_context(updates) {
351
+ if (!updates || typeof updates !== 'object') return;
352
+ var changed = {};
353
+
354
+ if ('state' in updates && updates.state !== context.state) {
355
+ context.state = updates.state;
356
+ changed.state = true;
357
+ }
358
+ if ('handshake_done' in updates && updates.handshake_done !== context.handshake_done) {
359
+ context.handshake_done = updates.handshake_done;
360
+ changed.handshake_done = true;
361
+ }
362
+ if ('handshake_done_sent' in updates) {
363
+ context.handshake_done_sent = updates.handshake_done_sent;
364
+ }
365
+ if ('original_dcid' in updates && updates.original_dcid !== null && context.original_dcid === null) {
366
+ context.original_dcid = updates.original_dcid;
367
+ changed.original_dcid = true;
368
+ }
369
+ if ('version' in updates && updates.version !== context.version) {
370
+ context.version = updates.version;
371
+ }
372
+ if ('add_their_cid' in updates) {
373
+ var cid = updates.add_their_cid;
374
+ var found = false;
375
+ for (var i = 0; i < context.their_cids.length; i++) {
376
+ if (uint8Equal(cid, context.their_cids[i])) { found = true; break; }
377
+ }
378
+ if (!found) context.their_cids.push(cid);
379
+ }
380
+ if ('initial_read' in updates) { context.initial_read = updates.initial_read; changed.initial_read = true; }
381
+ if ('initial_write' in updates) { context.initial_write = updates.initial_write; changed.initial_write = true; }
382
+ if ('handshake_read' in updates) { context.handshake_read = updates.handshake_read; }
383
+ if ('handshake_write' in updates){ context.handshake_write = updates.handshake_write; }
384
+ if ('app_read' in updates) { context.app_read = updates.app_read; }
385
+ if ('app_write' in updates) { context.app_write = updates.app_write; }
386
+ if ('remote_max_data' in updates && updates.remote_max_data > context.remote_max_data) {
387
+ context.remote_max_data = updates.remote_max_data; changed.remote_max_data = true;
388
+ }
389
+ if ('remote_max_streams_bidi' in updates) { context.remote_max_streams_bidi = updates.remote_max_streams_bidi; }
390
+ if ('remote_max_streams_uni' in updates) { context.remote_max_streams_uni = updates.remote_max_streams_uni; }
391
+ if ('key_phase' in updates && updates.key_phase !== context.key_phase) {
392
+ context.key_phase = updates.key_phase; changed.key_phase = true;
393
+ }
394
+
395
+ // ---- Reactive ----
396
+
397
+ if (changed.initial_read || changed.initial_write) {
398
+ if (context.state === 'idle') { context.state = 'handshaking'; changed.state = true; }
399
+ }
400
+
401
+ if (changed.handshake_done && context.handshake_done === true) {
402
+ if (DEBUG) console.log('[quic] handshake done — flushing ' + context.pending_app_packets.length + ' pending');
403
+ if (context.pending_app_packets.length > 0) {
404
+ var pending = context.pending_app_packets;
405
+ context.pending_app_packets = [];
406
+ for (var i = 0; i < pending.length; i++) {
407
+ processDecryptedPacket('app', pending[i].packet_number, pending[i].plaintext);
408
+ }
409
+ }
410
+ startIdleTimer();
411
+ startKeepAliveTimer();
412
+ }
413
+
414
+ if (changed.state && context.state === 'connected') {
415
+ // 'connect' event is emitted explicitly by TLS handlers (not here)
416
+ // Server emits on appSecrets, Client emits on secureConnect
417
+ }
418
+
419
+ if (changed.state && (context.state === 'draining' || context.state === 'closing')) {
420
+ clearIdleTimer();
421
+ // A draining/closing endpoint must stop retransmitting CRYPTO — the
422
+ // ticker would otherwise keep resending the handshake flight at a peer
423
+ // that already told us the connection is dead (seen live: three Initial
424
+ // retransmits fired after a CONNECTION_CLOSE was processed).
425
+ stopCryptoRetx();
426
+ var _drainTimer = setTimeout(function () {
427
+ if (context.state === 'draining' || context.state === 'closing') { context.state = 'closed'; ev.emit('close'); }
428
+ }, Math.min(3000, context.idle_timeout / 3));
429
+ if (_drainTimer.unref) _drainTimer.unref();
430
+ }
431
+
432
+ if (changed.state && context.state === 'closed') { clearIdleTimer(); ev.emit('close'); }
433
+ if (changed.remote_max_data) { plan_quic_burst(); }
434
+ if (changed.key_phase) {
435
+ // We initiated key update — derive new write keys
436
+ if (context.app_write_secret) {
437
+ var next = quic_derive_key_update(context.app_write_secret, context.cipher_hash, context.cipher_suite);
438
+ if (DEBUG) console.log('[quic] key update initiated — new write keys');
439
+ context.app_write = { key: next.key, iv: next.iv, hp: context.app_write.hp };
440
+ context.app_write_secret = next.secret;
441
+ }
442
+ }
443
+ }
444
+
445
+
446
+ // ============================================================
447
+ // Idle timeout
448
+ // ============================================================
449
+
450
+ function touchActivity() { context.last_activity = Date.now(); }
451
+
452
+ function startIdleTimer() {
453
+ clearIdleTimer();
454
+ if (context.idle_timeout <= 0) return;
455
+ context.idle_timer = setInterval(function () {
456
+ if (Date.now() - context.last_activity >= context.idle_timeout) {
457
+ if (DEBUG) console.log('[quic] idle timeout');
458
+ close(0, 'idle timeout');
459
+ }
460
+ }, Math.max(1000, Math.floor(context.idle_timeout / 4)));
461
+ if (context.idle_timer.unref) context.idle_timer.unref();
462
+ }
463
+
464
+ function startKeepAliveTimer() {
465
+ if (context.keep_alive_interval <= 0) return;
466
+ if (context.keep_alive_timer !== null) return; // idempotent
467
+ context.keep_alive_timer = setInterval(function () {
468
+ if (context.state !== 'connected') return;
469
+ // Only ping when the connection has actually been idle for the interval.
470
+ // Real traffic (sent or received) calls touchActivity(), which resets
471
+ // last_activity and suppresses unnecessary keep-alive PINGs.
472
+ if (Date.now() - context.last_activity >= context.keep_alive_interval) {
473
+ if (DEBUG) console.log('[quic] keep-alive PING');
474
+ sendFrames('app', [{ type: 'ping' }]);
475
+ }
476
+ }, context.keep_alive_interval);
477
+ if (context.keep_alive_timer.unref) context.keep_alive_timer.unref();
478
+ }
479
+
480
+ function clearIdleTimer() {
481
+ if (context.idle_timer !== null) { clearInterval(context.idle_timer); context.idle_timer = null; }
482
+ // Keep-alive shares the connection's liveness lifecycle: stop it wherever
483
+ // the idle timer is stopped (handshake (re)start, draining/closing, closed).
484
+ if (context.keep_alive_timer !== null) { clearInterval(context.keep_alive_timer); context.keep_alive_timer = null; }
485
+ }
486
+
487
+
488
+ // ============================================================
489
+ // TLS Bridge
490
+ // ============================================================
491
+
492
+ function initTLS() {
493
+ // Start handshake timeout — if TLS doesn't complete in time, close
494
+ if (context.handshake_timeout > 0 && !context.handshake_timer) {
495
+ context.handshake_timer = setTimeout(function () {
496
+ if (context.state !== 'connected' && context.state !== 'closed' && context.state !== 'draining' && context.state !== 'closing') {
497
+ if (DEBUG) console.log('[quic] handshake timeout (' + context.handshake_timeout + 'ms)');
498
+ ev.emit('error', new Error('QUIC handshake timeout'));
499
+ close(0x100, 'handshake timeout'); // 0x100 = CRYPTO_ERROR
500
+ }
501
+ }, context.handshake_timeout);
502
+ if (context.handshake_timer.unref) context.handshake_timer.unref();
503
+ }
504
+
505
+ tls = new TLSBridge({
506
+ isServer: context.isServer,
507
+ SNICallback: context.SNICallback,
508
+ originalDcid: context.original_dcid,
509
+ localCid: context.my_cids.length > 0 ? context.my_cids[0] : new Uint8Array(0),
510
+ hostname: context.hostname,
511
+ alpn: context.alpn
512
+ });
513
+
514
+ tls.on('send', function (epoch, data) { cryptoWrite(epoch, data); });
515
+
516
+ // Peer's QUIC transport parameters (parsed from the TLS 0x39 extension). These
517
+ // replace hardcoded defaults: the peer's flow-control limit, ACK-delay scaling,
518
+ // and max ACK delay. Arrives once, during the handshake, before app data flows.
519
+ tls.on('peerTransportParams', function (p) {
520
+ // ack_delay_exponent: clamp to RFC 9000 range [0,20] (guards Math.pow blowup).
521
+ if (typeof p.ack_delay_exponent === 'number') {
522
+ context.peer_ack_delay_exponent = Math.max(0, Math.min(20, p.ack_delay_exponent));
523
+ }
524
+ // max_ack_delay (ms): the peer's stated max delay before it sends an ACK.
525
+ if (typeof p.max_ack_delay === 'number' && p.max_ack_delay >= 0) {
526
+ context.max_ack_delay = Math.min(p.max_ack_delay, 16384); // RFC cap 2^14 ms
527
+ }
528
+ // initial_max_data: the AUTHORITATIVE initial flow-control limit. Set directly
529
+ // (not via the monotonic MAX_DATA path, which only increases) so a peer that
530
+ // grants less than our 1MB default is honored. MAX_DATA frames raise it later.
531
+ if (typeof p.initial_max_data === 'number') {
532
+ context.remote_max_data = p.initial_max_data;
533
+ }
534
+ // Per-stream send limits (RFC 9000 §18.2). The params are named from the
535
+ // PEER's perspective; initialStreamSendLimit() maps them onto our stream
536
+ // ids by direction/initiator when a send-stream is created.
537
+ if (typeof p.initial_max_stream_data_bidi_local === 'number') {
538
+ context.peer_initial_max_stream_data_bidi_local = p.initial_max_stream_data_bidi_local;
539
+ }
540
+ if (typeof p.initial_max_stream_data_bidi_remote === 'number') {
541
+ context.peer_initial_max_stream_data_bidi_remote = p.initial_max_stream_data_bidi_remote;
542
+ }
543
+ if (typeof p.initial_max_stream_data_uni === 'number') {
544
+ context.peer_initial_max_stream_data_uni = p.initial_max_stream_data_uni;
545
+ }
546
+ // Stream-count limits the peer grants us. Enforcement (blocking opens
547
+ // past the cap) is future work, but seed the real values so the guessed
548
+ // defaults (100/3) don't masquerade as negotiated ones.
549
+ if (typeof p.initial_max_streams_bidi === 'number') {
550
+ context.remote_max_streams_bidi = p.initial_max_streams_bidi;
551
+ }
552
+ if (typeof p.initial_max_streams_uni === 'number') {
553
+ context.remote_max_streams_uni = p.initial_max_streams_uni;
554
+ }
555
+ // Defensive: raise the limit of any stream opened before the params
556
+ // arrived (normally none — params land during the handshake).
557
+ for (var rsid in context.send_streams) {
558
+ var rst = context.send_streams[rsid];
559
+ var seeded = initialStreamSendLimit(Number(rsid));
560
+ if (seeded > rst.remote_max_stream_data) rst.remote_max_stream_data = seeded;
561
+ }
562
+ plan_quic_burst(); // budgets changed
563
+ if (DEBUG) console.log('[quic] peer params: ack_delay_exp=' + context.peer_ack_delay_exponent +
564
+ ' max_ack_delay=' + context.max_ack_delay + ' remote_max_data=' + context.remote_max_data +
565
+ ' msd_bidi_local=' + context.peer_initial_max_stream_data_bidi_local +
566
+ ' msd_bidi_remote=' + context.peer_initial_max_stream_data_bidi_remote +
567
+ ' msd_uni=' + context.peer_initial_max_stream_data_uni);
568
+ });
569
+
570
+ tls.on('handshakeSecrets', function (secrets) {
571
+ set_context({
572
+ handshake_read: quic_derive_from_tls_secrets(secrets.remote, secrets.hash, secrets.cipher),
573
+ handshake_write: quic_derive_from_tls_secrets(secrets.local, secrets.hash, secrets.cipher)
574
+ });
575
+ });
576
+
577
+ tls.on('appSecrets', function (secrets) {
578
+ // Handshake complete — clear timeout
579
+ if (context.handshake_timer) {
580
+ clearTimeout(context.handshake_timer);
581
+ context.handshake_timer = null;
582
+ }
583
+
584
+ // Store secrets for future key updates (RFC 9001 §6)
585
+ context.app_read_secret = secrets.remote;
586
+ context.app_write_secret = secrets.local;
587
+ context.cipher_hash = secrets.hash;
588
+ context.cipher_suite = secrets.cipher;
589
+
590
+ set_context({
591
+ app_read: quic_derive_from_tls_secrets(secrets.remote, secrets.hash, secrets.cipher),
592
+ app_write: quic_derive_from_tls_secrets(secrets.local, secrets.hash, secrets.cipher),
593
+ state: 'connected',
594
+ handshake_done: true
595
+ });
596
+
597
+ // NOTE: do NOT stop crypto recovery here. Reaching appSecrets means OUR
598
+ // side finished, but the peer hasn't necessarily received our flight yet
599
+ // (the server hits this the moment it sends Finished, before the client
600
+ // confirms). The crypto ticker self-terminates once every CRYPTO byte is
601
+ // acked (cryptoHasInFlight() === false); close() force-stops it otherwise.
602
+
603
+ // Server: emit connect now (Finished already sent)
604
+ // Client: wait for secureConnect (after client Finished is generated)
605
+ if (context.isServer) {
606
+ ev.emit('connect');
607
+ }
608
+ });
609
+
610
+ tls.on('secureConnect', function () {
611
+ // Client: Finished has been generated and queued.
612
+ // Flush handshake first, then emit connect so 1-RTT goes after Finished.
613
+ if (!context.isServer) {
614
+ plan_quic_burst(); // flush any pending handshake data (Finished)
615
+ ev.emit('connect');
616
+ }
617
+ });
618
+
619
+ // Note: QUIC handles key updates via key_phase bit, not TLS KeyUpdate message.
620
+ // RFC 9001 §6: "endpoints MUST treat the receipt of a TLS KeyUpdate message
621
+ // as a connection error of type 0x010a" — but we silently ignore it.
622
+ }
623
+
624
+
625
+ // ============================================================
626
+ // Incoming packets
627
+ // ============================================================
628
+
629
+ function feedDatagram(from_ip, from_port, data) {
630
+ if (DEBUG) console.log('[quic] datagram from ' + from_ip + ':' + from_port + ' len=' + data.length);
631
+ feedPackets(from_ip, from_port, parse_quic_datagram(data));
632
+ }
633
+
634
+ // Feed already-parsed packets. Used by the server, which parses each UDP
635
+ // datagram once (to route it to a connection) and then hands the parsed
636
+ // packets here directly, avoiding a redundant second parse.
637
+ function feedPackets(from_ip, from_port, packets) {
638
+ if (context.state === 'closed' || context.state === 'draining') return;
639
+ if (context.state === 'closing') {
640
+ // RFC 9000 §10.2.1: while closing, don't process packets normally; instead
641
+ // re-send CONNECTION_CLOSE in response (rate-limited) so the peer learns
642
+ // we've closed even if the first CC was lost.
643
+ if (Date.now() - context.last_close_echo >= 200) sendConnectionClose();
644
+ return;
645
+ }
646
+ touchActivity();
647
+ for (var i = 0; i < packets.length; i++) {
648
+ if (packets[i] !== null) {
649
+ // Safety net: a single malformed packet must never crash the process or
650
+ // tear down the connection. The parsers are bounds-checked, but this
651
+ // also guards decrypt / frame-handler edge cases. Drop the bad packet
652
+ // (logged under DEBUG) and keep processing the rest of the datagram.
653
+ try {
654
+ feedPacket(packets[i]);
655
+ } catch (e) {
656
+ if (DEBUG) console.log('[quic] dropped packet — processing error: ' + (e && e.message));
657
+ }
658
+ }
659
+ }
660
+ }
661
+
662
+ function feedPacket(pkt) {
663
+ if (context.state === 'draining' || context.state === 'closed') return;
664
+
665
+ if (pkt.version && pkt.version !== context.version) set_context({ version: pkt.version });
666
+ if (pkt.dcid && pkt.dcid.byteLength > 0) set_context({ original_dcid: pkt.dcid });
667
+ if (pkt.scid && pkt.scid.byteLength > 0) set_context({ add_their_cid: pkt.scid });
668
+
669
+ var space = pkt.type === 'initial' ? 'initial'
670
+ : pkt.type === 'handshake' ? 'handshake'
671
+ : pkt.type === '1rtt' ? 'app' : null;
672
+ if (!space) return;
673
+
674
+ if (space === 'initial' && !context.initial_read && context.original_dcid) {
675
+ set_context({
676
+ initial_read: quic_derive_init_secrets(context.original_dcid, context.version, 'read'),
677
+ initial_write: quic_derive_init_secrets(context.original_dcid, context.version, 'write')
678
+ });
679
+ }
680
+
681
+ var readKeys = space === 'initial' ? context.initial_read
682
+ : space === 'handshake' ? context.handshake_read : context.app_read;
683
+ if (!readKeys) return;
684
+
685
+ // Connection ID by which *we* are addressed. Its length is needed to locate
686
+ // the packet number in short (1-RTT) headers, which carry no DCID-length
687
+ // field — the receiver must already know its own CID length. For a server
688
+ // this is the SCID it chose (which it adopts as original_dcid); for a client
689
+ // it's its own SCID (my_cids[0]). Long headers carry the DCID length on the
690
+ // wire, so decrypt ignores this value for Initial/Handshake packets.
691
+ var recvCid = context.isServer
692
+ ? context.original_dcid
693
+ : (context.my_cids.length > 0 ? context.my_cids[0] : context.original_dcid);
694
+
695
+ var decrypted = decrypt_quic_packet(
696
+ pkt.raw, readKeys.key, readKeys.iv, readKeys.hp,
697
+ recvCid, context.recv_pn_largest[space]
698
+ );
699
+
700
+ // Key Update: if app decrypt fails, try with derived next keys
701
+ if ((!decrypted || !decrypted.plaintext) && space === 'app' && context.app_read_secret) {
702
+ var next = quic_derive_key_update(context.app_read_secret, context.cipher_hash, context.cipher_suite);
703
+ decrypted = decrypt_quic_packet(
704
+ pkt.raw, next.key, next.iv, readKeys.hp, // HP doesn't change
705
+ recvCid, context.recv_pn_largest[space]
706
+ );
707
+
708
+ if (decrypted && decrypted.plaintext && decrypted.plaintext.byteLength > 0) {
709
+ // Key update confirmed — install new read keys
710
+ if (DEBUG) console.log('[quic] key update detected — installing new read keys');
711
+ context.app_prev_read = context.app_read;
712
+ context.app_read = { key: next.key, iv: next.iv, hp: readKeys.hp };
713
+ context.app_read_secret = next.secret;
714
+ context.read_key_phase = !context.read_key_phase;
715
+ }
716
+ }
717
+
718
+ // Also try prev keys for reordered packets from before key update
719
+ if ((!decrypted || !decrypted.plaintext) && space === 'app' && context.app_prev_read) {
720
+ decrypted = decrypt_quic_packet(
721
+ pkt.raw, context.app_prev_read.key, context.app_prev_read.iv, context.app_prev_read.hp,
722
+ recvCid, context.recv_pn_largest[space]
723
+ );
724
+ }
725
+
726
+ if (!decrypted || !decrypted.plaintext || decrypted.plaintext.byteLength === 0) {
727
+ if (DEBUG) console.log('[quic] decrypt failed: ' + space + ' raw_len=' + pkt.raw.byteLength + ' first20=' + Array.from(pkt.raw.slice(0, 20)).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' ') + ' recv_cid_len=' + (recvCid ? recvCid.byteLength : 'null') + ' has_keys=' + !!readKeys + ' largest_pn=' + context.recv_pn_largest[space]);
728
+ return;
729
+ }
730
+
731
+ if (DEBUG) console.log('[quic] decrypted ' + space + ' pn=' + decrypted.packet_number + ' len=' + decrypted.plaintext.byteLength);
732
+
733
+ var pn = decrypted.packet_number;
734
+ var ranges = context.recv_pn_ranges[space];
735
+ var isNew = true;
736
+ for (var ri = 0; ri < ranges.length; ri += 2) {
737
+ if (pn >= ranges[ri] && pn < ranges[ri + 1]) { isNew = false; break; }
738
+ }
739
+ if (isNew) {
740
+ flat_ranges.add(ranges, [pn, pn + 1]);
741
+ if (pn > context.recv_pn_largest[space]) {
742
+ context.recv_pn_largest[space] = pn;
743
+ } else if (space === 'app') {
744
+ // New packet, but below the highest PN already seen → it arrived out of
745
+ // order. Pure network reordering (the peer sends PNs strictly increasing).
746
+ context.reorder_in_count++;
747
+ }
748
+ }
749
+
750
+ if (DEBUG) console.log('[quic] pn=' + pn + ' isNew=' + isNew);
751
+ if (!isNew) return;
752
+
753
+ if (space === 'app' && !context.handshake_done) {
754
+ context.pending_app_packets.push(decrypted);
755
+ return;
756
+ }
757
+
758
+ processDecryptedPacket(space, pn, decrypted.plaintext);
759
+ }
760
+
761
+
762
+ // ============================================================
763
+ // Frame processing
764
+ // ============================================================
765
+
766
+ function processDecryptedPacket(space, packetNumber, plaintext) {
767
+ var frames = parse_quic_frames(plaintext);
768
+ if (DEBUG) console.log('[quic] frames: ' + space + ' pn=' + packetNumber + ' [' + frames.map(function(f){ return f.type; }).join(',') + ']');
769
+ var ackEliciting = false;
770
+
771
+ if (context.isServer && space === 'app' && !context.handshake_done_sent) {
772
+ set_context({ handshake_done_sent: true });
773
+ sendFrames('app', [{ type: 'handshake_done' }]);
774
+ }
775
+
776
+ for (var i = 0; i < frames.length; i++) {
777
+ var frame = frames[i];
778
+
779
+ if (frame.type === 'crypto') {
780
+ ackEliciting = true;
781
+ processCryptoFrame(space, frame.offset, frame.data);
782
+ } else if (frame.type === 'stream') {
783
+ ackEliciting = true;
784
+ processStreamFrame(frame);
785
+ } else if (frame.type === 'ack') {
786
+ processAckFrame(space, frame);
787
+ } else if (frame.type === 'ping') {
788
+ ackEliciting = true;
789
+ } else if (frame.type === 'handshake_done') {
790
+ ackEliciting = true;
791
+ } else if (frame.type === 'path_challenge') {
792
+ ackEliciting = true;
793
+ sendFrames(space, [{ type: 'path_response', data: frame.data }]);
794
+ } else if (frame.type === 'new_connection_id') {
795
+ ackEliciting = true;
796
+ } else if (frame.type === 'connection_close') {
797
+ if (DEBUG) console.log('[quic] CONNECTION_CLOSE error=0x' + (frame.error || 0).toString(16) + ' frame_type=0x' + (frame.frameType || 0).toString(16) + ' reason="' + (frame.reason || '') + '"');
798
+ set_context({ state: 'draining' });
799
+ return;
800
+ } else if (frame.type === 'stop_sending') {
801
+ // Peer says: stop sending on this stream
802
+ ackEliciting = true;
803
+ if (frame.id in context.send_streams) {
804
+ if (DEBUG) console.log('[quic] STOP_SENDING stream=' + frame.id + ' error=' + frame.error);
805
+ delete context.send_streams[frame.id];
806
+ delete context.remote_max_stream_data_by_sid[frame.id];
807
+ delete context.last_stream_blocked_sent[frame.id];
808
+ }
809
+ } else if (frame.type === 'reset_stream') {
810
+ // Peer cancelled their stream
811
+ ackEliciting = true;
812
+ if (frame.id in context.recv_streams) {
813
+ if (DEBUG) console.log('[quic] RESET_STREAM stream=' + frame.id + ' error=' + frame.error);
814
+ delete context.recv_streams[frame.id];
815
+ }
816
+ } else if (frame.type === 'max_data') {
817
+ ackEliciting = true;
818
+ set_context({ remote_max_data: frame.max });
819
+ } else if (frame.type === 'max_streams_bidi') {
820
+ ackEliciting = true;
821
+ set_context({ remote_max_streams_bidi: frame.max });
822
+ } else if (frame.type === 'max_streams_uni') {
823
+ ackEliciting = true;
824
+ set_context({ remote_max_streams_uni: frame.max });
825
+ } else if (frame.type === 'max_stream_data') {
826
+ ackEliciting = true;
827
+ // The peer raised our send allowance on one stream (previously the
828
+ // value was dropped on the floor). Track it monotonically in the
829
+ // sid-keyed map (so a frame arriving before the stream exists is
830
+ // honored at creation) and on the live stream, then reschedule — this
831
+ // may have just unblocked a sender stalled on the old limit.
832
+ var msdPrev = context.remote_max_stream_data_by_sid[frame.id] || 0;
833
+ if (frame.max > msdPrev) context.remote_max_stream_data_by_sid[frame.id] = frame.max;
834
+ var msdStream = context.send_streams[frame.id];
835
+ if (msdStream && frame.max > msdStream.remote_max_stream_data) {
836
+ msdStream.remote_max_stream_data = frame.max;
837
+ plan_quic_burst();
838
+ }
839
+ } else if (frame.type === 'data_blocked') {
840
+ // The peer is stalled on our connection window. Our MAX_DATA may have
841
+ // been lost (window updates are sent once, unrecovered) — re-send the
842
+ // current limit. Harmless if it wasn't lost: the peer's limit is
843
+ // monotonic, a duplicate is ignored.
844
+ ackEliciting = true;
845
+ sendFrames('app', [{ type: 'max_data', max: context.local_max_data }]);
846
+ } else if (frame.type === 'stream_data_blocked') {
847
+ // Same, per stream. If we never received data on this stream (all of
848
+ // it lost), no recv_stream exists yet — answer with the initial limit
849
+ // we advertised in our transport params.
850
+ ackEliciting = true;
851
+ var sdbStream = context.recv_streams[frame.id];
852
+ var sdbLimit = sdbStream ? sdbStream.local_max_stream_data
853
+ : context.local_initial_max_stream_data;
854
+ sendFrames('app', [{ type: 'max_stream_data', id: Number(frame.id), max: sdbLimit }]);
855
+ } else if (frame.type === 'datagram') {
856
+ // DATAGRAM frames are ack-eliciting (RFC 9221 §5.2). Without this, a
857
+ // peer sending only datagrams (a classic WebTransport flow) would get
858
+ // no ACKs from us and read the silence as total loss.
859
+ ackEliciting = true;
860
+ ev.emit('datagram', frame.data);
861
+ }
862
+ }
863
+
864
+ if (ackEliciting) {
865
+ flat_ranges.add(context.pending_ack[space], [packetNumber, packetNumber + 1]);
866
+ var ackFrame = ranges_to_ack_frame(context.pending_ack[space], null, 0);
867
+ if (ackFrame) {
868
+ sendFrames(space, [ackFrame]);
869
+ if (space !== 'app') context.pending_ack[space] = [];
870
+ }
871
+ }
872
+ }
873
+
874
+
875
+ // ============================================================
876
+ // CRYPTO → TLS
877
+ // ============================================================
878
+
879
+ function processCryptoFrame(space, offset, data) {
880
+ if (space !== 'initial' && space !== 'handshake') return;
881
+ if (DEBUG) console.log('[quic] CRYPTO: space=' + space + ' offset=' + offset + ' len=' + data.length);
882
+
883
+ var chunks = context.crypto_chunks[space];
884
+ var fromOffset = context.crypto_offset[space];
885
+ if (!(offset in chunks) || chunks[offset].byteLength < data.byteLength) chunks[offset] = data;
886
+
887
+ var result = extract_tls_messages_from_chunks(chunks, fromOffset);
888
+ if (DEBUG) console.log('[quic] TLS messages: ' + (result ? result.tls_messages.length : 'none'));
889
+ if (!result) return;
890
+
891
+ context.crypto_offset[space] = result.new_from_offset;
892
+ if (!tls) { if (DEBUG) console.log('[quic] initializing TLS bridge'); initTLS(); }
893
+
894
+ for (var i = 0; i < result.tls_messages.length; i++) {
895
+ var msg = result.tls_messages[i];
896
+ var msgType = msg[0];
897
+ if (DEBUG) console.log('[quic] → TLS msg #' + i + ' type=0x' + msgType.toString(16) + ' len=' + msg.length);
898
+
899
+ // Detect HelloRetryRequest (type 0x02 with special random)
900
+ if (msgType === 0x02 && msg.length >= 38) {
901
+ var hrrRandom = [0xCF,0x21,0xAD,0x74,0xE5,0x9A,0x61,0x11,0xBE,0x1D,0x8C,0x02,0x1E,0x65,0xB8,0x91,0xC2,0xA2,0x11,0x16,0x7A,0xBB,0x8C,0x5E,0x07,0x9E,0x09,0xE2,0xC8,0xA8,0x33,0x9C];
902
+ var isHRR = true;
903
+ for (var j = 0; j < 32; j++) { if (msg[6 + j] !== hrrRandom[j]) { isHRR = false; break; } }
904
+ if (isHRR && DEBUG) console.log('[quic] ⚠️ HelloRetryRequest detected! LemonTLS needs HRR support.');
905
+ }
906
+
907
+ tls.feedMessage(msg);
908
+ }
909
+ }
910
+
911
+
912
+ // ============================================================
913
+ // Stream receive
914
+ // ============================================================
915
+
916
+ function processStreamFrame(frame) {
917
+ var sid = frame.id;
918
+ if (!(sid in context.recv_streams)) {
919
+ context.recv_streams[sid] = { chunks: {}, ranges: [], total_size: 0, flushed_to: 0,
920
+ max_recv_offset: 0, // highest offset seen — the peer's FC usage on this stream
921
+ local_max_stream_data: context.local_initial_max_stream_data };
922
+ }
923
+ var stream = context.recv_streams[sid];
924
+
925
+ // ── FC usage + enforcement (RFC 9000 §4.1) ──────────────────────────────
926
+ // Usage advances only when the HIGHEST offset advances — a retransmission
927
+ // re-delivers offsets already counted and must not be charged (the old
928
+ // bytes_received counter charged every arriving byte, so under loss we
929
+ // advertised new windows early). Exceeding what we advertised, on either
930
+ // level, is the peer's protocol violation → FLOW_CONTROL_ERROR (0x03).
931
+ var newHigh = frame.offset + frame.data.length;
932
+ if (newHigh > stream.max_recv_offset) {
933
+ if (newHigh > stream.local_max_stream_data) {
934
+ if (DEBUG) console.log('[quic] FC violation: stream ' + sid + ' offset ' + newHigh + ' > advertised ' + stream.local_max_stream_data);
935
+ close(0x03, 'stream flow control exceeded');
936
+ return;
937
+ }
938
+ var fcDelta = newHigh - stream.max_recv_offset;
939
+ if (context.fc_recv_usage + fcDelta > context.local_max_data) {
940
+ if (DEBUG) console.log('[quic] FC violation: connection usage ' + (context.fc_recv_usage + fcDelta) + ' > advertised ' + context.local_max_data);
941
+ close(0x03, 'connection flow control exceeded');
942
+ return;
943
+ }
944
+ stream.max_recv_offset = newHigh;
945
+ context.fc_recv_usage += fcDelta;
946
+ }
947
+
948
+ var alreadyHave = false;
949
+ for (var ri = 0; ri < stream.ranges.length; ri += 2) {
950
+ if (frame.offset >= stream.ranges[ri] && frame.offset + frame.data.length <= stream.ranges[ri + 1]) {
951
+ alreadyHave = true; break;
952
+ }
953
+ }
954
+ if (!alreadyHave) {
955
+ flat_ranges.add(stream.ranges, [frame.offset, frame.offset + frame.data.length]);
956
+ if (!(frame.offset in stream.chunks) || stream.chunks[frame.offset].byteLength < frame.data.byteLength) {
957
+ stream.chunks[frame.offset] = frame.data;
958
+ }
959
+ // Raw stat only — FC usage is max_recv_offset above, not this.
960
+ context.bytes_received += frame.data.byteLength;
961
+ }
962
+ if (frame.fin && stream.total_size === 0) stream.total_size = frame.offset + frame.data.length;
963
+ flushStream(sid);
964
+ }
965
+
966
+ // (checkLocalFlowControl — the old unbounded ×2 doubling — was replaced by
967
+ // the consumption-based sliding window inside flushStream above.)
968
+
969
+ function flushStream(sid) {
970
+ var stream = context.recv_streams[sid];
971
+ if (!stream) return;
972
+ var parts = [];
973
+ var offset = stream.flushed_to;
974
+ // Walk the in-order prefix. Chunks are keyed by their start offset, but
975
+ // retransmission re-slices byte ranges with boundaries that need not match
976
+ // the original transmission's (the per-burst budget differs between
977
+ // passes). So a chunk can START BEFORE the cursor and extend past it —
978
+ // an exact-key walk (`offset in chunks`) stalls forever on such a chunk
979
+ // even though every byte is present (observed live: ranges said
980
+ // [0,262144] complete while flushed_to sat at 31846, mid-chunk of the
981
+ // entry keyed 31747). When the exact key misses, scan for a covering
982
+ // chunk and consume its tail; drop fully-stale chunks (entirely behind
983
+ // the cursor — overlap leftovers) along the way.
984
+ while (true) {
985
+ var chunk = stream.chunks[offset];
986
+ var chunkStart = offset;
987
+ if (!chunk) {
988
+ var keys = Object.keys(stream.chunks);
989
+ for (var ki = 0; ki < keys.length; ki++) {
990
+ var ks = Number(keys[ki]);
991
+ var c = stream.chunks[ks];
992
+ var kEnd = ks + c.byteLength;
993
+ if (kEnd <= offset) { delete stream.chunks[ks]; continue; } // stale overlap leftover
994
+ if (ks < offset && kEnd > offset) { chunk = c; chunkStart = ks; break; }
995
+ }
996
+ if (!chunk) break; // genuine gap — wait for more data
997
+ }
998
+ delete stream.chunks[chunkStart];
999
+ var skip = offset - chunkStart; // 0 on the exact-key path
1000
+ parts.push(skip > 0 ? chunk.subarray(skip) : chunk);
1001
+ offset = chunkStart + chunk.byteLength;
1002
+ }
1003
+ if (parts.length === 0) return;
1004
+ var delivered = offset - stream.flushed_to; // in-order bytes handed to the app now
1005
+ stream.flushed_to = offset;
1006
+ var data = parts.length === 1 ? parts[0] : concatUint8Arrays(parts);
1007
+ var fin = (stream.total_size > 0 && offset >= stream.total_size);
1008
+ ev.emit('stream', Number(sid), data, fin);
1009
+ if (fin) setTimeout(function () { delete context.recv_streams[sid]; }, 100);
1010
+
1011
+ // ── Sliding window updates (consumption-based; see the context header) ──
1012
+ // Both levels use the same rule: once consumption has advanced at least
1013
+ // half a window past the last advertisement, advertise consumed + W.
1014
+ // Monotonic by construction; a duplicate/lower advert is ignored by the
1015
+ // peer, and re-sends on DATA_BLOCKED reuse these same fields.
1016
+
1017
+ // Connection level: consumed = in-order bytes delivered across all streams.
1018
+ context.local_max_data_consumed += delivered;
1019
+ var W = context.local_max_data_window;
1020
+ if (context.local_max_data_consumed + W - context.local_max_data >= W / 2) {
1021
+ context.local_max_data = context.local_max_data_consumed + W;
1022
+ sendFrames('app', [{ type: 'max_data', max: context.local_max_data }]);
1023
+ }
1024
+
1025
+ // Stream level: consumed = flushed_to. No update after FIN — the stream
1026
+ // is done, its final usage stays counted at the connection level.
1027
+ if (!fin) {
1028
+ var Ws = context.local_initial_max_stream_data;
1029
+ if (stream.flushed_to + Ws - stream.local_max_stream_data >= Ws / 2) {
1030
+ stream.local_max_stream_data = stream.flushed_to + Ws;
1031
+ sendFrames('app', [{ type: 'max_stream_data', id: Number(sid), max: stream.local_max_stream_data }]);
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+
1037
+ // Update the smoothed RTT estimate from a new sample (RFC 9002 §5.3).
1038
+ // latest_rtt = now − time_sent(largest_acked); ack_delay_ms is the peer's
1039
+ // reported ACK delay. min_rtt uses the raw sample; srtt/rttvar use the
1040
+ // ack_delay-adjusted sample. Recursive EWMA: each sample folds into
1041
+ // srtt/rttvar immediately, so no per-sample history is retained.
1042
+ function updateRtt(latest_rtt, ack_delay_ms) {
1043
+ context.latest_rtt = latest_rtt;
1044
+
1045
+ // min_rtt tracks the raw minimum (before any ack_delay adjustment).
1046
+ context.min_rtt = (context.min_rtt === null)
1047
+ ? latest_rtt
1048
+ : Math.min(context.min_rtt, latest_rtt);
1049
+
1050
+ // max_rtt tracks the raw maximum; max_rtt − min_rtt ≈ queueing/bufferbloat.
1051
+ context.max_rtt = (context.max_rtt === null)
1052
+ ? latest_rtt
1053
+ : Math.max(context.max_rtt, latest_rtt);
1054
+
1055
+ // BBR RTprop: windowed-min RTT over ~10s. Unlike the forever-min above, it
1056
+ // expires so it can rise if the true path RTT changes. It is refreshed by a
1057
+ // lower sample, or replaced when the 10s window lapses (ProbeRTT later forces
1058
+ // a queue-free sample so this stays honest under a persistent standing queue).
1059
+ var BBR_MIN_RTT_WINDOW = 10000; // ms
1060
+ var now_rtt = Date.now();
1061
+ if (context.bbr_min_rtt === null ||
1062
+ latest_rtt <= context.bbr_min_rtt ||
1063
+ now_rtt - context.bbr_min_rtt_stamp > BBR_MIN_RTT_WINDOW) {
1064
+ context.bbr_min_rtt = latest_rtt;
1065
+ context.bbr_min_rtt_stamp = now_rtt;
1066
+ }
1067
+
1068
+ // Subtract ACK delay, but cap it at max_ack_delay and never let it pull the
1069
+ // sample below min_rtt (RFC 9002 §5.3).
1070
+ var ack_delay = Math.min(ack_delay_ms, context.max_ack_delay);
1071
+ var adjusted = latest_rtt;
1072
+ if (latest_rtt >= context.min_rtt + ack_delay) adjusted = latest_rtt - ack_delay;
1073
+
1074
+ if (context.srtt === null) {
1075
+ context.srtt = adjusted;
1076
+ context.rttvar = adjusted / 2;
1077
+ } else {
1078
+ context.rttvar = 0.75 * context.rttvar + 0.25 * Math.abs(context.srtt - adjusted);
1079
+ context.srtt = 0.875 * context.srtt + 0.125 * adjusted;
1080
+ }
1081
+ if (DEBUG) console.log('[quic] rtt: sample=' + latest_rtt + 'ms srtt=' + Math.round(context.srtt) + ' rttvar=' + Math.round(context.rttvar) + ' min=' + context.min_rtt);
1082
+ }
1083
+
1084
+
1085
+ // ============================================================
1086
+ // ACK processing (Phase 1)
1087
+ // ============================================================
1088
+
1089
+ // Total app stream bytes currently in flight (unacked). Used by BBR's Drain
1090
+ // state to know when the queue built during Startup has been emptied.
1091
+ function appBytesInFlight() {
1092
+ var total = 0;
1093
+ for (var sid in context.send_streams) {
1094
+ var st = context.send_streams[sid];
1095
+ if (!st.in_flight_ranges) continue;
1096
+ for (var pn in st.in_flight_ranges) {
1097
+ if (pn === '_burst') continue;
1098
+ total += st.in_flight_ranges[pn][1] - st.in_flight_ranges[pn][0];
1099
+ }
1100
+ }
1101
+ return total;
1102
+ }
1103
+
1104
+ function processAckFrame(space, frame) {
1105
+ var ackedRanges = ack_frame_to_ranges(frame);
1106
+ if (!ackedRanges || ackedRanges.length === 0) return;
1107
+
1108
+ // The peer acknowledged something → it's responsive in the return
1109
+ // direction. Resets the in-flight-expiry backoff (any space counts).
1110
+ // Capture the previous ACK time first — it's the interval for delivery_rate.
1111
+ var ackNow = Date.now();
1112
+ var prevAckTime = context.last_ack_time;
1113
+ context.last_ack_time = ackNow;
1114
+
1115
+ // Initial/Handshake: an ACK confirms crypto byte-ranges. Move them in_flight
1116
+ // → acked and reset that space's resend backoff (the peer is responding).
1117
+ if (space === 'initial' || space === 'handshake') {
1118
+ var cs = context.crypto_sent[space];
1119
+ for (var cpn in cs.in_flight) {
1120
+ var p = Number(cpn), hit = false;
1121
+ for (var cri = 0; cri < ackedRanges.length; cri += 2) {
1122
+ if (p >= ackedRanges[cri] && p <= ackedRanges[cri + 1]) { hit = true; break; }
1123
+ }
1124
+ if (hit) { flat_ranges.add(cs.acked, cs.in_flight[cpn].range); delete cs.in_flight[cpn]; }
1125
+ }
1126
+ cs.backoff = 0;
1127
+ return;
1128
+ }
1129
+
1130
+ if (space === 'app') {
1131
+ // RTT measurement
1132
+ if ('largest' in frame && 'delay' in frame) {
1133
+ var largest_pn = frame.largest;
1134
+ // Only measure from the largest *newly* acked, ack-eliciting packet
1135
+ // (RFC 9002 §5.1). sending_app_pn_in_flight holds data-carrying PNs —
1136
+ // the closest signal until sent_packets tracks ack_eliciting per packet
1137
+ // (so PING-only packets are not yet used as RTT samples).
1138
+ if (context.sending_app_pn_in_flight.has(largest_pn)) {
1139
+ var now = Date.now();
1140
+ // ACK Delay is microseconds scaled by 2^ack_delay_exponent; use the peer's
1141
+ // value (parsed from its transport params; defaults to 3 until then).
1142
+ var ack_delay_ms = Math.round((frame.delay * Math.pow(2, context.peer_ack_delay_exponent)) / 1000);
1143
+ var pn_index = largest_pn - (context.send_pn.app - context.sending_app_pn_history.length);
1144
+ if (pn_index >= 0 && pn_index < context.sending_app_pn_history.length) {
1145
+ var latest_rtt = now - context.sending_app_pn_history[pn_index][0];
1146
+ if (latest_rtt > 0) updateRtt(latest_rtt, ack_delay_ms);
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ // Mark in-flight as acked. Collect the acked PNs first, then mutate —
1152
+ // deleting from a Set while iterating it with for-of is fragile.
1153
+ var ackedPns = [];
1154
+ for (var pn of context.sending_app_pn_in_flight) {
1155
+ for (var ri = 0; ri < ackedRanges.length; ri += 2) {
1156
+ if (pn >= ackedRanges[ri] && pn <= ackedRanges[ri + 1]) { ackedPns.push(pn); break; }
1157
+ }
1158
+ }
1159
+ var newlyAckedBytes = 0; // goodput bytes confirmed by this ACK (for delivery_rate)
1160
+ var oldestAckedPn = null; // smallest newly-acked PN → longest delivery interval
1161
+ for (var ai = 0; ai < ackedPns.length; ai++) {
1162
+ var apn = ackedPns[ai];
1163
+ if (oldestAckedPn === null || apn < oldestAckedPn) oldestAckedPn = apn;
1164
+ context.sending_app_pn_in_flight.delete(apn);
1165
+ for (var sid in context.send_streams) {
1166
+ var st = context.send_streams[sid];
1167
+ if (st.in_flight_ranges && apn in st.in_flight_ranges) {
1168
+ newlyAckedBytes += st.in_flight_ranges[apn][1] - st.in_flight_ranges[apn][0];
1169
+ flat_ranges.add(st.acked_ranges, st.in_flight_ranges[apn]);
1170
+ delete st.in_flight_ranges[apn];
1171
+ if (st.total_size > 0 && st.acked_ranges.length === 2 &&
1172
+ st.acked_ranges[0] === 0 && st.acked_ranges[1] >= st.total_size) {
1173
+ delete context.send_streams[sid];
1174
+ delete context.remote_max_stream_data_by_sid[sid];
1175
+ delete context.last_stream_blocked_sent[sid];
1176
+ }
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ // Cumulative delivered advances by this ACK's goodput; the delivery clock
1182
+ // is the wall-time of this update.
1183
+ context.delivered += newlyAckedBytes;
1184
+
1185
+ // latest_delivery_rate / max_delivery_rate: per-ACK observations (kept for
1186
+ // visibility). NOTE: these are NOT fed to the BtlBw filter — per-ACK samples
1187
+ // spike under ACK aggregation and the windowed-max locks onto the spike
1188
+ // (the 20→39 Mbps over-read). BtlBw is sampled once per round instead (below).
1189
+ if (oldestAckedPn !== null && context.sending_app_pn_history.length > 0) {
1190
+ var rs_idx = oldestAckedPn - (context.send_pn.app - context.sending_app_pn_history.length);
1191
+ if (rs_idx >= 0 && rs_idx < context.sending_app_pn_history.length) {
1192
+ var deliveredAtSend = context.sending_app_pn_history[rs_idx][2];
1193
+ var deliveredTimeAtSend = context.sending_app_pn_history[rs_idx][3];
1194
+ var interval = ackNow - deliveredTimeAtSend;
1195
+ var deltaDelivered = context.delivered - deliveredAtSend;
1196
+ if (interval >= 1 && deltaDelivered > 0) {
1197
+ context.latest_delivery_rate = (deltaDelivered * 1000) / interval;
1198
+ if (context.max_delivery_rate === null ||
1199
+ context.latest_delivery_rate > context.max_delivery_rate) {
1200
+ context.max_delivery_rate = context.latest_delivery_rate;
1201
+ }
1202
+ }
1203
+ }
1204
+ }
1205
+ context.delivered_time = ackNow;
1206
+
1207
+ // BBR round tracking: a round (~1 RTT) completes when this ACK acknowledges
1208
+ // a packet sent at/after the round boundary. We then recompute the windowed
1209
+ // BtlBw (max delivery rate over the last N rounds) and the derived BDP. This
1210
+ // is the per-RTT cadence at which Phase 4b step 3 will write current_limit_*.
1211
+ if ('largest' in frame && frame.largest >= context.bbr_round_start_pn) {
1212
+ // Per-round rate sample: total bytes delivered during the round over the
1213
+ // round's duration (≈ 1 RTT). Averaging across the whole round washes out
1214
+ // ACK-aggregation spikes that corrupt per-ACK samples.
1215
+ var roundDur = ackNow - context.bbr_round_start_time;
1216
+ var roundDelivered = context.delivered - context.bbr_round_start_delivered;
1217
+ if (roundDur >= 1 && roundDelivered > 0) {
1218
+ context.bbr_btlbw_samples.push({ round: context.bbr_round_count, rate: (roundDelivered * 1000) / roundDur });
1219
+ }
1220
+
1221
+ context.bbr_round_count++;
1222
+ context.bbr_round_start_pn = context.send_pn.app; // next PN must be acked for the next round
1223
+ context.bbr_round_start_delivered = context.delivered;
1224
+ context.bbr_round_start_time = ackNow;
1225
+
1226
+ var BBR_BTLBW_WINDOW = 10; // rounds
1227
+ var cutoff = context.bbr_round_count - BBR_BTLBW_WINDOW;
1228
+ var kept = [], maxRate = null;
1229
+ for (var bi = 0; bi < context.bbr_btlbw_samples.length; bi++) {
1230
+ var smp = context.bbr_btlbw_samples[bi];
1231
+ if (smp.round >= cutoff) {
1232
+ kept.push(smp);
1233
+ if (maxRate === null || smp.rate > maxRate) maxRate = smp.rate;
1234
+ }
1235
+ }
1236
+ context.bbr_btlbw_samples = kept;
1237
+ context.bbr_btlbw = maxRate;
1238
+
1239
+ // Derived in-flight target. Uses bbr_min_rtt (RTprop, windowed) in seconds.
1240
+ if (context.bbr_btlbw !== null && context.bbr_min_rtt !== null) {
1241
+ // BDP = BtlBw × RTprop. On near-zero-RTT paths (localhost, LAN) RTprop
1242
+ // collapses toward 0, so BDP → 0 and cwnd would clamp to the min floor
1243
+ // (≈2 packets) — starving throughput on the fastest links. Floor RTprop at
1244
+ // a few ms for this calc: it only affects sub-floor RTTs (where the link is
1245
+ // fast and can absorb the extra window) and never binds on normal links.
1246
+ // (A tick-adaptive floor was tried and reverted — see the pacer note.)
1247
+ var BBR_RTPROP_FLOOR_MS = 5;
1248
+ var rttForBdp = Math.max(context.bbr_min_rtt, BBR_RTPROP_FLOOR_MS);
1249
+ context.bbr_bdp = context.bbr_btlbw * (rttForBdp / 1000);
1250
+
1251
+ // ── State machine → (pacing_gain, cwnd_gain) ──────────────────────
1252
+ var STARTUP_GAIN = 2.89; // ≈ 2/ln2 — exponential ramp
1253
+ var pacing_gain, cwnd_gain;
1254
+ if (context.bbr_state === 'startup') {
1255
+ pacing_gain = STARTUP_GAIN; cwnd_gain = STARTUP_GAIN;
1256
+ // Plateau detector: BtlBw must grow ≥25% per round to stay in Startup.
1257
+ if (context.bbr_btlbw >= context.bbr_full_bw * 1.25) {
1258
+ context.bbr_full_bw = context.bbr_btlbw;
1259
+ context.bbr_full_bw_count = 0;
1260
+ } else if (++context.bbr_full_bw_count >= 3) {
1261
+ context.bbr_state = 'drain'; // pipe is full
1262
+ }
1263
+ } else if (context.bbr_state === 'drain') {
1264
+ pacing_gain = 1 / STARTUP_GAIN; cwnd_gain = STARTUP_GAIN; // drain the queue
1265
+ if (appBytesInFlight() <= context.bbr_bdp) context.bbr_state = 'probe_bw';
1266
+ } else { // probe_bw — cruise at the bottleneck rate while gently probing.
1267
+ // pacing_gain cycles 1.25 (probe for more bw) → 0.75 (drain the queue
1268
+ // that 1.25 just built) → 1.0×6 (cruise). The 0.75 phase is what keeps
1269
+ // the standing queue near-empty: without it, cwnd=2·BDP lets ~1 BDP of
1270
+ // queue sit permanently (≈ the bufferbloat we measured). One phase per round.
1271
+ var PROBE_BW_CYCLE = [1.25, 0.75, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1272
+ pacing_gain = PROBE_BW_CYCLE[context.bbr_cycle_idx];
1273
+ // cwnd_gain bounds the standing queue directly: in JS userland the pacer
1274
+ // isn't precise enough to hold in-flight at exactly BDP (kernel BBR uses
1275
+ // cwnd_gain 2), so a tighter ceiling is what actually limits bufferbloat.
1276
+ // 1.25 → at most ~0.25·BDP of queue, while leaving room for the 1.25 probe.
1277
+ cwnd_gain = 1.25;
1278
+ context.bbr_cycle_idx = (context.bbr_cycle_idx + 1) % PROBE_BW_CYCLE.length;
1279
+ }
1280
+
1281
+ // ── Write the controls, clamped to [min,max] (the hard safety net). ──
1282
+ var clamp = function (v, lo, hi) { return Math.max(lo, Math.min(hi, v)); };
1283
+
1284
+ var targetInflight = clamp(cwnd_gain * context.bbr_bdp,
1285
+ context.min_limit_bytes_in_flight, context.max_limit_bytes_in_flight);
1286
+ context.current_limit_bytes_in_flight = targetInflight;
1287
+ context.current_limit_packets_in_flight = clamp(
1288
+ Math.round(targetInflight / context.current_limit_packet_payload),
1289
+ context.min_limit_packets_in_flight, context.max_limit_packets_in_flight);
1290
+
1291
+ var targetRate = clamp(pacing_gain * context.bbr_btlbw,
1292
+ context.min_limit_bytes_per_sec, context.max_limit_bytes_per_sec);
1293
+ context.current_limit_bytes_per_sec = targetRate;
1294
+ context.current_limit_packets_per_sec = clamp(
1295
+ Math.round(targetRate / context.current_limit_packet_payload),
1296
+ 1, context.max_limit_packets_per_sec);
1297
+ }
1298
+ if (DEBUG && context.bbr_bdp !== null) {
1299
+ console.log('[bbr] round=' + context.bbr_round_count + ' ' + context.bbr_state +
1300
+ ' BtlBw=' + (context.bbr_btlbw * 8 / 1e6).toFixed(2) + 'Mbps' +
1301
+ ' RTprop=' + context.bbr_min_rtt + 'ms' +
1302
+ ' BDP=' + Math.round(context.bbr_bdp) + 'B' +
1303
+ ' → cwnd=' + Math.round(context.current_limit_bytes_in_flight) + 'B' +
1304
+ ' pace=' + (context.current_limit_bytes_per_sec * 8 / 1e6).toFixed(2) + 'Mbps');
1305
+ }
1306
+ }
1307
+
1308
+ // ACK freed capacity — try to send more
1309
+ plan_quic_burst();
1310
+ }
1311
+ }
1312
+
1313
+
1314
+ // ============================================================
1315
+ // Sending
1316
+ // ============================================================
1317
+
1318
+ function cryptoWrite(epoch, data) {
1319
+ var space = epoch === 'initial' ? 'initial' : epoch === 'handshake' ? 'handshake' : 'app';
1320
+ var offset = context.crypto_send_offset[space];
1321
+ context.crypto_send_offset[space] += data.byteLength;
1322
+ // Retain the bytes so a lost CRYPTO frame can be re-sent (app/1-RTT crypto
1323
+ // never loses — it rides the app stream-recovery path and isn't tracked here).
1324
+ if (space === 'initial' || space === 'handshake') {
1325
+ context.crypto_sent[space].buf.push({ off: offset, data: data });
1326
+ }
1327
+ sendFrames(space, [{ type: 'crypto', offset: offset, data: data }]);
1328
+ if (space === 'initial' || space === 'handshake') scheduleCryptoRetx();
1329
+ }
1330
+
1331
+ // Assemble the bytes for crypto byte-range [from,to) of `space` from retained
1332
+ // fragments (contiguous, appended in order by cryptoWrite).
1333
+ function cryptoSlice(space, from, to) {
1334
+ var out = new Uint8Array(to - from);
1335
+ var frags = context.crypto_sent[space].buf;
1336
+ for (var i = 0; i < frags.length; i++) {
1337
+ var fOff = frags[i].off, fEnd = fOff + frags[i].data.byteLength;
1338
+ var lo = Math.max(from, fOff), hi = Math.min(to, fEnd);
1339
+ if (lo < hi) out.set(frags[i].data.subarray(lo - fOff, hi - fOff), lo - from);
1340
+ }
1341
+ return out;
1342
+ }
1343
+
1344
+ // Time-based loss recovery for the Initial/Handshake CRYPTO streams — the
1345
+ // analog of expireInFlight for the app path. No RTT sample exists during the
1346
+ // handshake, so the timeout starts at kInitialRtt (333ms) and backs off ×2 per
1347
+ // unacked round (reset by an ACK in processAckFrame).
1348
+ function cryptoTimeout(space) {
1349
+ var base = (context.srtt === null) ? 333 : context.srtt + Math.max(4 * context.rttvar, 1);
1350
+ var bo = Math.min(context.crypto_sent[space].backoff, 6); // cap ×64
1351
+ return base * Math.pow(2, bo);
1352
+ }
1353
+
1354
+ // Re-send any crypto byte-range that is neither acked nor currently in-flight.
1355
+ // Mirrors get_stream_chunks: missing = invert(acked ∪ in_flight, 0, total).
1356
+ function resendMissingCrypto(space) {
1357
+ var cs = context.crypto_sent[space];
1358
+ var total = context.crypto_send_offset[space];
1359
+ if (total === 0) return false;
1360
+
1361
+ var known = cs.acked.slice();
1362
+ for (var pn in cs.in_flight) flat_ranges.add(known, cs.in_flight[pn].range);
1363
+ var missing = flat_ranges.invert(known, 0, total);
1364
+ if (!missing || missing.length === 0) return false;
1365
+
1366
+ var MAX = Math.max(256, context.current_limit_packet_payload - 64); // leave header room
1367
+ var sentAny = false;
1368
+ for (var i = 0; i < missing.length; i += 2) {
1369
+ var from = missing[i], to = missing[i + 1];
1370
+ while (from < to) {
1371
+ var end = Math.min(from + MAX, to);
1372
+ sendFrames(space, [{ type: 'crypto', offset: from, data: cryptoSlice(space, from, end) }]);
1373
+ sentAny = true;
1374
+ from = end;
1375
+ }
1376
+ }
1377
+ return sentAny;
1378
+ }
1379
+
1380
+ // Expire in-flight crypto whose timeout elapsed (range returns to "missing").
1381
+ function expireCryptoInFlight() {
1382
+ var now = Date.now(), spaces = ['initial', 'handshake'], expired = false;
1383
+ for (var s = 0; s < spaces.length; s++) {
1384
+ var space = spaces[s], cs = context.crypto_sent[space], timeout = cryptoTimeout(space);
1385
+ for (var pn in cs.in_flight) {
1386
+ if (now - cs.in_flight[pn].time_sent >= timeout) { delete cs.in_flight[pn]; expired = true; }
1387
+ }
1388
+ }
1389
+ return expired;
1390
+ }
1391
+
1392
+ function cryptoHasInFlight() {
1393
+ return Object.keys(context.crypto_sent.initial.in_flight).length > 0 ||
1394
+ Object.keys(context.crypto_sent.handshake.in_flight).length > 0;
1395
+ }
1396
+
1397
+ // Self-terminating ticker: keeps firing while any CRYPTO byte is unacked
1398
+ // (the analog of plan_quic_burst's hasInFlight reschedule — needed because the
1399
+ // first flight can be lost with no ACK to trigger anything). It is driven by
1400
+ // acked-state, NOT local handshake_done: the server reaches handshake_done the
1401
+ // moment it sends Finished, but must keep resending until the client acks.
1402
+ function cryptoTick() {
1403
+ context.crypto_timer = null;
1404
+ if (expireCryptoInFlight()) {
1405
+ if (resendMissingCrypto('initial')) context.crypto_sent.initial.backoff++;
1406
+ if (resendMissingCrypto('handshake')) context.crypto_sent.handshake.backoff++;
1407
+ }
1408
+ if (cryptoHasInFlight()) scheduleCryptoRetx();
1409
+ }
1410
+
1411
+ function scheduleCryptoRetx() {
1412
+ if (context.crypto_timer !== null) return; // idempotent
1413
+ var iv = Math.min(cryptoTimeout('initial'), cryptoTimeout('handshake'));
1414
+ context.crypto_timer = setTimeout(cryptoTick, iv);
1415
+ if (context.crypto_timer.unref) context.crypto_timer.unref();
1416
+ }
1417
+
1418
+ function stopCryptoRetx() {
1419
+ if (context.crypto_timer !== null) { clearTimeout(context.crypto_timer); context.crypto_timer = null; }
1420
+ context.crypto_sent.initial = { buf: [], in_flight: {}, acked: [], backoff: 0 };
1421
+ context.crypto_sent.handshake = { buf: [], in_flight: {}, acked: [], backoff: 0 };
1422
+ }
1423
+
1424
+ function sendFrames(space, frameList) {
1425
+ // RFC 9000 §10.2: a draining endpoint MUST NOT send packets at all; a
1426
+ // closing endpoint may only (re)send its CONNECTION_CLOSE. Belt-and-
1427
+ // suspenders with the timer cleanup — any stray timer (crypto retx, burst,
1428
+ // keep-alive) that fires after the state change dies here instead of
1429
+ // emitting packets at a dead connection.
1430
+ if (context.state === 'draining' || context.state === 'closed') return;
1431
+ if (context.state === 'closing' &&
1432
+ !(frameList.length === 1 && frameList[0].type === 'connection_close')) return;
1433
+
1434
+ var writeKeys = space === 'initial' ? context.initial_write
1435
+ : space === 'handshake' ? context.handshake_write : context.app_write;
1436
+ if (!writeKeys) { if (DEBUG) console.log('[quic] sendFrames(' + space + ') — no keys'); return; }
1437
+
1438
+ // Read the next packet number but DON'T commit it yet — only advance
1439
+ // send_pn once the packet is successfully encrypted and emitted (below).
1440
+ // A failed encryption must not consume a PN: a gap would desync the
1441
+ // pn → sending_app_pn_history index math (and RTT). See LOSS_PTO_PLAN §5.
1442
+ var pn = context.send_pn[space];
1443
+ var packetType = space === 'initial' ? 'initial' : space === 'handshake' ? 'handshake' : '1rtt';
1444
+
1445
+ var dcid, scid;
1446
+ if (packetType === '1rtt') {
1447
+ dcid = context.their_cids.length > 0 ? context.their_cids[0] : new Uint8Array(0);
1448
+ scid = new Uint8Array(0);
1449
+ } else if (context.isServer) {
1450
+ dcid = context.their_cids.length > 0 ? context.their_cids[0] : new Uint8Array(0);
1451
+ scid = context.original_dcid || new Uint8Array(0);
1452
+ } else {
1453
+ // Client: Initial uses original_dcid (for key derivation),
1454
+ // Handshake uses server's SCID (RFC 9000 §7.2)
1455
+ if (space === 'initial' || context.their_cids.length === 0) {
1456
+ dcid = context.original_dcid || new Uint8Array(0);
1457
+ } else {
1458
+ dcid = context.their_cids[0];
1459
+ }
1460
+ scid = context.my_cids.length > 0 ? context.my_cids[0] : new Uint8Array(0);
1461
+ }
1462
+
1463
+ var encoded = encode_quic_frames(frameList);
1464
+
1465
+ // RFC 9000 §14.1: Initial datagrams must be >= 1200 bytes
1466
+ if (space === 'initial') {
1467
+ var overhead = 1 + 4 + 1 + dcid.byteLength + 1 + scid.byteLength + 1 + 2 + 1 + 16;
1468
+ var minPayload = 1200 - overhead;
1469
+ if (encoded.length < minPayload) {
1470
+ encoded = concatUint8Arrays([encoded, new Uint8Array(minPayload - encoded.length)]);
1471
+ }
1472
+ }
1473
+
1474
+ var encrypted = encrypt_quic_packet(
1475
+ packetType, encoded, writeKeys.key, writeKeys.iv, writeKeys.hp, pn, dcid, scid, null,
1476
+ space === 'app' ? context.key_phase : false
1477
+ );
1478
+
1479
+ if (encrypted) {
1480
+ // Commit the packet number now that the packet is real and about to go
1481
+ // out on the wire — atomic with the bookkeeping below, so send_pn and
1482
+ // sending_app_pn_history always advance together (no phantom PN).
1483
+ context.send_pn[space] = pn + 1;
1484
+ var fnames = frameList.map(function(f){ return f.type + (f.type === 'ack' ? '(lg=' + f.largest + ')' : ''); }).join(',');
1485
+ if (DEBUG) console.log('[quic] → ' + packetType + ' pn=' + pn + ' frames=[' + fnames + '] len=' + encrypted.length);
1486
+ touchActivity();
1487
+
1488
+ if (space === 'app') {
1489
+ context.sending_app_pn_history.push([Date.now(), encoded.length, context.delivered, context.delivered_time]);
1490
+ var has_data = false;
1491
+ for (var i = 0; i < frameList.length; i++) {
1492
+ if (frameList[i].type === 'stream' || frameList[i].type === 'crypto') { has_data = true; break; }
1493
+ }
1494
+ if (has_data) context.sending_app_pn_in_flight.add(pn);
1495
+
1496
+ for (var i = 0; i < frameList.length; i++) {
1497
+ if (frameList[i].type === 'stream') {
1498
+ var sid = frameList[i].id;
1499
+ var dataLen = frameList[i].data ? frameList[i].data.byteLength : 0;
1500
+
1501
+ // Track connection-level bytes sent
1502
+ context.bytes_sent += dataLen;
1503
+
1504
+ if (sid in context.send_streams) {
1505
+ if (!context.send_streams[sid].in_flight_ranges) context.send_streams[sid].in_flight_ranges = {};
1506
+ var from = frameList[i].offset;
1507
+ var to = from + (frameList[i].data ? frameList[i].data.byteLength : 0);
1508
+ if (!context.send_streams[sid].in_flight_ranges[pn]) {
1509
+ context.send_streams[sid].in_flight_ranges[pn] = [from, to];
1510
+ } else {
1511
+ flat_ranges.add(context.send_streams[sid].in_flight_ranges[pn], [from, to]);
1512
+ }
1513
+ }
1514
+ }
1515
+ }
1516
+ }
1517
+
1518
+ // Initial/Handshake: record which byte-range of the crypto stream this PN
1519
+ // carried, so a lost packet's range can be re-sent (see crypto_sent).
1520
+ if (space === 'initial' || space === 'handshake') {
1521
+ for (var ci = 0; ci < frameList.length; ci++) {
1522
+ if (frameList[ci].type === 'crypto') {
1523
+ var cFrom = frameList[ci].offset;
1524
+ var cTo = cFrom + (frameList[ci].data ? frameList[ci].data.byteLength : 0);
1525
+ if (cTo > cFrom) context.crypto_sent[space].in_flight[pn] = { range: [cFrom, cTo], time_sent: Date.now() };
1526
+ }
1527
+ }
1528
+ }
1529
+
1530
+ ev.emit('packet', encrypted);
1531
+ }
1532
+ }
1533
+
1534
+
1535
+ // ============================================================
1536
+ // Stream send + Burst scheduler (Phase 2)
1537
+ // Adapted from old quic_socket.js, improved with set_context
1538
+ // ============================================================
1539
+
1540
+ /**
1541
+ * Initial send-limit for a stream we write to, per the peer's transport
1542
+ * params. Which param applies depends on the stream's direction and who
1543
+ * initiated it — the params are named from the PEER's perspective
1544
+ * (RFC 9000 §18.2):
1545
+ * uni stream we send on → initial_max_stream_data_uni
1546
+ * bidi stream WE initiated → ..._bidi_remote ("remote" from the peer's view)
1547
+ * bidi stream THEY initiated → ..._bidi_local
1548
+ * A MAX_STREAM_DATA that arrived before the stream existed is honored via
1549
+ * remote_max_stream_data_by_sid (monotonic max wins).
1550
+ */
1551
+ function initialStreamSendLimit(sid) {
1552
+ var isUni = (sid & 0x2) === 0x2; // bit 1: uni vs bidi
1553
+ var clientInitiated = (sid & 0x1) === 0x0; // bit 0: initiator
1554
+ var weInitiated = context.isServer ? !clientInitiated : clientInitiated;
1555
+ var base;
1556
+ if (isUni) base = context.peer_initial_max_stream_data_uni;
1557
+ else if (weInitiated) base = context.peer_initial_max_stream_data_bidi_remote;
1558
+ else base = context.peer_initial_max_stream_data_bidi_local;
1559
+ var pending = context.remote_max_stream_data_by_sid[sid] || 0;
1560
+ return Math.max(base, pending);
1561
+ }
1562
+
1563
+ /**
1564
+ * Buffer data for a stream. Does NOT send immediately.
1565
+ * Calls plan_quic_burst() to schedule sending.
1566
+ */
1567
+ function set_sending_stream(streamId, options) {
1568
+ if (!(streamId in context.send_streams)) {
1569
+ context.send_streams[streamId] = {
1570
+ pending_data: null, pending_offset_start: 0,
1571
+ write_offset: 0, send_offset: 0, total_size: 0,
1572
+ fin_sent: false, acked_ranges: [], in_flight_ranges: {},
1573
+ // Send-side FC (RFC 9000 §4.1): the peer's absolute offset cap for
1574
+ // this stream, and our high-water mark (highest offset ever yielded).
1575
+ // New data = bytes extending max_sent_offset; only those consume the
1576
+ // connection-level budget. See get_stream_chunks.
1577
+ remote_max_stream_data: initialStreamSendLimit(streamId),
1578
+ max_sent_offset: 0,
1579
+ fc_blocked: false
1580
+ };
1581
+ }
1582
+
1583
+ var stream = context.send_streams[streamId];
1584
+
1585
+ if (typeof options === 'object' && 'add_chunk' in options) {
1586
+ if (options.add_chunk.data === null || options.add_chunk.data === undefined) {
1587
+ if (stream.total_size === 0) {
1588
+ stream.total_size = stream.write_offset;
1589
+ if (DEBUG) console.log('[quic] stream ' + streamId + ' FIN via null data, total_size=' + stream.total_size);
1590
+ }
1591
+ } else {
1592
+ var chunk = options.add_chunk.data;
1593
+ if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
1594
+ var start = stream.write_offset;
1595
+ stream.write_offset += chunk.byteLength;
1596
+ if (DEBUG) console.log('[quic] stream ' + streamId + ' add_chunk len=' + chunk.byteLength + ' write_offset=' + stream.write_offset + ' fin=' + !!options.add_chunk.fin);
1597
+
1598
+ if (stream.pending_data === null) {
1599
+ stream.pending_data = chunk;
1600
+ stream.pending_offset_start = start;
1601
+ } else {
1602
+ var old = stream.pending_data, old_off = stream.pending_offset_start;
1603
+ var ns = Math.min(old_off, start);
1604
+ var ne = Math.max(old_off + old.length, start + chunk.length);
1605
+ var merged = new Uint8Array(ne - ns);
1606
+ merged.set(old, old_off - ns);
1607
+ merged.set(chunk, start - ns);
1608
+ stream.pending_data = merged;
1609
+ stream.pending_offset_start = ns;
1610
+ }
1611
+ if (options.add_chunk.fin) {
1612
+ stream.total_size = stream.write_offset;
1613
+ if (DEBUG) console.log('[quic] stream ' + streamId + ' FIN via add_chunk.fin, total_size=' + stream.total_size);
1614
+ }
1615
+ }
1616
+ }
1617
+
1618
+ // Don't send directly — let burst scheduler handle it
1619
+ plan_quic_burst();
1620
+ }
1621
+
1622
+ function sendStream(streamId, data, fin) {
1623
+ set_sending_stream(streamId, { add_chunk: { data: data, fin: fin } });
1624
+ }
1625
+
1626
+ function sendDatagram(data) {
1627
+ if (context.state !== 'connected') return false;
1628
+ if (typeof data === 'string') data = new TextEncoder().encode(data);
1629
+ // A DATAGRAM frame must fit within a single packet — datagrams are never
1630
+ // fragmented (RFC 9221 §3). Reject payloads that exceed our per-packet
1631
+ // budget instead of emitting an oversized packet. Overhead is the 1-byte
1632
+ // 0x30 DATAGRAM frame type.
1633
+ if (data.byteLength > context.current_limit_packet_payload - 1) {
1634
+ if (DEBUG) console.log('[quic] sendDatagram: payload too large (' + data.byteLength + ' > ' + (context.current_limit_packet_payload - 1) + ')');
1635
+ return false;
1636
+ }
1637
+ sendFrames('app', [{ type: 'datagram', data: data }]);
1638
+ return true;
1639
+ }
1640
+
1641
+ // Largest datagram payload (in bytes) that can be sent right now in a single
1642
+ // packet, or 0 if datagrams can't be sent yet (connection not 'connected').
1643
+ // This is a local single-packet estimate; QUICO does not currently parse the
1644
+ // peer's max_datagram_frame_size transport parameter, so it does not reflect
1645
+ // a negotiated peer limit.
1646
+ function maxDatagramSize() {
1647
+ if (context.state !== 'connected') return 0;
1648
+ return Math.max(0, context.current_limit_packet_payload - 1);
1649
+ }
1650
+
1651
+
1652
+ /**
1653
+ * plan_quic_burst — calculates how many packets we can send,
1654
+ * calls execute_quic_burst, schedules next burst if needed.
1655
+ */
1656
+ // Expire packets that have been in flight longer than the RTT-derived timeout
1657
+ // — the round-robin sender's equivalent of loss detection. A timed-out range
1658
+ // is deleted from in_flight_ranges, so the next pass sees it as "missing" and
1659
+ // resends it under a new packet number. Time-based ONLY (reordering must not
1660
+ // trigger a resend). A global backoff keyed off last_ack_time widens the
1661
+ // timeout while the peer isn't ACKing, so a dead path isn't flooded.
1662
+ function expireInFlight() {
1663
+ var now = Date.now();
1664
+ var base = (context.srtt === null) ? 333 : context.srtt + Math.max(4 * context.rttvar, 1);
1665
+
1666
+ // Global backoff: while no ACK has arrived, widen the timeout geometrically.
1667
+ // Resets the instant an ACK updates last_ack_time.
1668
+ var sinceAck = now - context.last_ack_time;
1669
+ var mult = 1;
1670
+ while (sinceAck > base * mult * 2 && mult < 1024) mult *= 2;
1671
+ var timeout = base * mult;
1672
+
1673
+ // PN of history index 0 (the array is a contiguous suffix of recent PNs).
1674
+ var pnBase = context.send_pn.app - context.sending_app_pn_history.length;
1675
+
1676
+ for (var sid in context.send_streams) {
1677
+ var st = context.send_streams[sid];
1678
+ if (!st.in_flight_ranges) continue;
1679
+ for (var pn in st.in_flight_ranges) {
1680
+ if (pn === '_burst') continue; // temporary within-burst sentinel
1681
+ var pnum = Number(pn);
1682
+ var idx = pnum - pnBase;
1683
+ var expired;
1684
+ if (idx < 0 || idx >= context.sending_app_pn_history.length) {
1685
+ expired = true; // no send-time on record → assume expired (fail-safe)
1686
+ } else {
1687
+ expired = (now - context.sending_app_pn_history[idx][0] >= timeout);
1688
+ }
1689
+ if (expired) {
1690
+ delete st.in_flight_ranges[pn]; // → becomes "missing" again, resent next pass
1691
+ // Count the loss once per packet: the Set holds each PN once, so the
1692
+ // first stream to expire it returns true; later same-PN deletes (a
1693
+ // multi-stream packet) return false and don't double-count.
1694
+ if (context.sending_app_pn_in_flight.delete(pnum)) context.lost_count++;
1695
+ }
1696
+ }
1697
+ }
1698
+ }
1699
+
1700
+
1701
+ // Consume the fc_blocked flags set by get_stream_chunks and, rate-limited,
1702
+ // tell the peer we're stalled on its window (RFC 9000 §4.1 SHOULD). Beyond
1703
+ // being polite, this is the loss-recovery path for window updates: the
1704
+ // receiver answers a BLOCKED frame by re-sending its current MAX_DATA /
1705
+ // MAX_STREAM_DATA, so a single lost update can no longer deadlock the
1706
+ // sender. Returns true if any flag was set this pass (used to keep the
1707
+ // scheduler ticking while blocked with nothing in flight).
1708
+ var FC_BLOCKED_RESEND_MS = 500;
1709
+ function maybeSendBlockedFrames() {
1710
+ var now = Date.now();
1711
+ var wasBlocked = false;
1712
+ if (context.fc_blocked_conn) {
1713
+ context.fc_blocked_conn = false;
1714
+ wasBlocked = true;
1715
+ if (now - context.last_data_blocked_sent >= FC_BLOCKED_RESEND_MS) {
1716
+ context.last_data_blocked_sent = now;
1717
+ sendFrames('app', [{ type: 'data_blocked', limit: context.remote_max_data }]);
1718
+ }
1719
+ }
1720
+ for (var sid in context.send_streams) {
1721
+ var st = context.send_streams[sid];
1722
+ if (!st.fc_blocked) continue;
1723
+ st.fc_blocked = false;
1724
+ wasBlocked = true;
1725
+ if (now - (context.last_stream_blocked_sent[sid] || 0) >= FC_BLOCKED_RESEND_MS) {
1726
+ context.last_stream_blocked_sent[sid] = now;
1727
+ sendFrames('app', [{ type: 'stream_data_blocked', id: Number(sid), limit: st.remote_max_stream_data }]);
1728
+ }
1729
+ }
1730
+ return wasBlocked;
1731
+ }
1732
+
1733
+
1734
+ function plan_quic_burst() {
1735
+ if (!context.app_write) return;
1736
+ if (context.state !== 'connected') return;
1737
+
1738
+ // Cancel pending timer
1739
+ if (context.burst_timer !== null) {
1740
+ clearTimeout(context.burst_timer);
1741
+ clearImmediate(context.burst_timer);
1742
+ context.burst_timer = null;
1743
+ }
1744
+
1745
+ // Time out anything that's been in flight too long, so it resurfaces as
1746
+ // "missing" below and gets resent. (Runs before inflight/hasData are read.)
1747
+ expireInFlight();
1748
+
1749
+ var now = Date.now();
1750
+ var oneSecAgo = now - 1000;
1751
+
1752
+ // Count bytes/packets sent in last second
1753
+ var bytesSentLastSec = 0;
1754
+ var packetsSentLastSec = 0;
1755
+ for (var i = 0; i < context.sending_app_pn_history.length; i++) {
1756
+ if (context.sending_app_pn_history[i][0] > oneSecAgo) {
1757
+ bytesSentLastSec += context.sending_app_pn_history[i][1];
1758
+ packetsSentLastSec++;
1759
+ }
1760
+ }
1761
+
1762
+ // Prune old history (older than 2 seconds)
1763
+ var twoSecAgo = now - 2000;
1764
+ while (context.sending_app_pn_history.length > 0 && context.sending_app_pn_history[0][0] < twoSecAgo) {
1765
+ context.sending_app_pn_history.shift();
1766
+ }
1767
+
1768
+ // Effective limits. For rate: min(current_, max_). For in-flight: clamp
1769
+ // current_ to [min_, max_] — the min_ floor is the min-cwnd that keeps the
1770
+ // connection alive (a cap below one packet would otherwise round to 0 and
1771
+ // deadlock); max_ is the absolute ceiling and wins any min_>max_ misconfig.
1772
+ var effBytesPerSec = Math.min(context.current_limit_bytes_per_sec, context.max_limit_bytes_per_sec);
1773
+ var effPacketsPerSec = Math.min(context.current_limit_packets_per_sec, context.max_limit_packets_per_sec);
1774
+ var effPktsInFlight = Math.min(context.max_limit_packets_in_flight, Math.max(context.min_limit_packets_in_flight, context.current_limit_packets_in_flight));
1775
+ var effBytesInFlight = Math.min(context.max_limit_bytes_in_flight, Math.max(context.min_limit_bytes_in_flight, context.current_limit_bytes_in_flight));
1776
+
1777
+ // Rate limits
1778
+ var bytesRemaining = effBytesPerSec - bytesSentLastSec;
1779
+ var packetsRemaining = effPacketsPerSec - packetsSentLastSec;
1780
+ if (bytesRemaining < 0) bytesRemaining = 0;
1781
+ if (packetsRemaining < 0) packetsRemaining = 0;
1782
+
1783
+ // In-flight limits (packets)
1784
+ var inflightCount = context.sending_app_pn_in_flight.size;
1785
+ var inflightRoom = effPktsInFlight - inflightCount;
1786
+ if (inflightRoom < 0) inflightRoom = 0;
1787
+
1788
+ // In-flight limits (bytes). Sum the application bytes currently in flight
1789
+ // (the spans still tracked in each stream's in_flight_ranges), then turn the
1790
+ // remaining byte budget into a packet count. Divide by the packet payload
1791
+ // (not the 35-byte min used for the rate limit): this is a HARD ceiling, so
1792
+ // we under-estimate how many packets fit and never overshoot the cap.
1793
+ var bytesInFlight = 0;
1794
+ for (var sidB in context.send_streams) {
1795
+ var stB = context.send_streams[sidB];
1796
+ if (!stB.in_flight_ranges) continue;
1797
+ for (var pnB in stB.in_flight_ranges) {
1798
+ if (pnB === '_burst') continue;
1799
+ bytesInFlight += stB.in_flight_ranges[pnB][1] - stB.in_flight_ranges[pnB][0];
1800
+ }
1801
+ }
1802
+ var bytesInFlightRoom = effBytesInFlight - bytesInFlight;
1803
+ if (bytesInFlightRoom < 0) bytesInFlightRoom = 0;
1804
+ var inflightBytesPackets = Math.floor(bytesInFlightRoom / context.current_limit_packet_payload);
1805
+
1806
+ // Calculate burst size
1807
+ var burstCount = Math.min(
1808
+ context.max_packets_per_burst,
1809
+ packetsRemaining,
1810
+ inflightRoom,
1811
+ inflightBytesPackets, // bytes-in-flight cap
1812
+ Math.floor(bytesRemaining / Math.max(1, 35)) // min packet ~35 bytes
1813
+ );
1814
+ if (burstCount < 0) burstCount = 0;
1815
+
1816
+ // Token-bucket pacer. Credit accrues continuously at the pacing rate; a burst
1817
+ // can spend up to PACE_BURST_MS worth of accrued tokens. This paces the long-run
1818
+ // rate to current_limit_bytes_per_sec while tolerating setTimeout jitter (a late
1819
+ // tick just accrues more tokens to catch up), and the small cap bounds how much
1820
+ // can enter the bottleneck at once = the max standing queue. (A zero-burst gate
1821
+ // starved throughput on JS timers; an unpaced ACK-clock parked ~1 BDP of queue.)
1822
+ //
1823
+ // KNOWN CEILING (documented, deliberate): on coarse-timer platforms
1824
+ // (Windows setTimeout ≈ 15ms) the 5ms cap discards most of a late tick's
1825
+ // accrual, capping throughput near cap/tick (~6 Mbps observed). Widening
1826
+ // the cap/bursts to cover a measured tick was tried and REVERTED: bigger
1827
+ // bursts overflow the small default UDP socket buffers (esp. same-process
1828
+ // loopback on Windows) → silent local drops → the loss-recovery machinery
1829
+ // turns a lossless link into a crawling one. Raising this ceiling properly
1830
+ // needs socket buffer sizing (setRecvBufferSize/setSendBufferSize at the
1831
+ // dgram layer) validated on a measurable rig — CC-calibration work, not a
1832
+ // constant to guess at. Stability beats throughput until then.
1833
+ var PACE_BURST_MS = 5;
1834
+ var rateBps = context.current_limit_bytes_per_sec;
1835
+ var nowR = Date.now();
1836
+ context.pacing_tokens += (nowR - context.pacing_last_refill) / 1000 * rateBps;
1837
+ context.pacing_last_refill = nowR;
1838
+ var tokenCap = Math.max(2 * context.current_limit_packet_payload, rateBps * (PACE_BURST_MS / 1000));
1839
+ if (context.pacing_tokens > tokenCap) context.pacing_tokens = tokenCap;
1840
+ var tokenPackets = Math.floor(context.pacing_tokens / context.current_limit_packet_payload);
1841
+ if (burstCount > tokenPackets) burstCount = tokenPackets;
1842
+
1843
+ // Check if there's anything to send
1844
+ var hasData = false;
1845
+ for (var sid in context.send_streams) {
1846
+ var st = context.send_streams[sid];
1847
+ if (!st.pending_data || st.pending_data.byteLength === 0) continue;
1848
+ var total = st.total_size > 0 ? st.total_size : st.write_offset;
1849
+ var known = st.acked_ranges.slice();
1850
+ if (st.in_flight_ranges) {
1851
+ for (var pn in st.in_flight_ranges) flat_ranges.add(known, st.in_flight_ranges[pn]);
1852
+ }
1853
+ var missing = flat_ranges.invert(known, 0, total);
1854
+ if (missing.length > 0) { hasData = true; break; }
1855
+ // Also check: all data sent but FIN not yet sent?
1856
+ if (st.total_size > 0 && !st.fin_sent && missing.length === 0) { hasData = true; break; }
1857
+ }
1858
+ // Also check pending ACK
1859
+ var hasPendingAck = context.pending_ack.app.length > 0;
1860
+
1861
+ // Any unacked data still in flight? Keep the scheduler alive so
1862
+ // expireInFlight can time it out and resend even when nothing is
1863
+ // immediately sendable — otherwise a lost range would stall here forever.
1864
+ var hasInFlight = false;
1865
+ for (var sidF in context.send_streams) {
1866
+ var stF = context.send_streams[sidF];
1867
+ if (!stF.in_flight_ranges) continue;
1868
+ for (var pnF in stF.in_flight_ranges) {
1869
+ if (pnF !== '_burst') { hasInFlight = true; break; }
1870
+ }
1871
+ if (hasInFlight) break;
1872
+ }
1873
+
1874
+ if (!hasData && !hasPendingAck && !hasInFlight) return;
1875
+
1876
+ // Paced out: have data + cwnd room, but not enough tokens yet for a packet.
1877
+ var pacedOut = hasData && burstCount === 0 && tokenPackets < 1 &&
1878
+ context.sending_app_pn_in_flight.size < effPktsInFlight;
1879
+
1880
+ // Execute burst
1881
+ var sent = 0;
1882
+ if (burstCount > 0) {
1883
+ sent = execute_quic_burst(burstCount); // returns packets ACTUALLY sent
1884
+ // Charge tokens for what went out — not for the burstCount ceiling.
1885
+ // Charging the ceiling overbilled short bursts (e.g. a 2-packet stream
1886
+ // tail billed as 64) and starved the pacer for a full tick afterwards.
1887
+ if (sent > 0) context.pacing_tokens -= sent * context.current_limit_packet_payload;
1888
+ }
1889
+
1890
+ // FC-blocked signaling — consume flags set inside the burst just executed.
1891
+ var fcBlocked = maybeSendBlockedFrames();
1892
+
1893
+ // Schedule next burst if needed
1894
+ if (hasData && (sent || pacedOut)) {
1895
+ // Wait roughly until one more packet's worth of tokens has accrued.
1896
+ var delayMs = (rateBps > 0) ? (context.current_limit_packet_payload * 1000 / rateBps) : 1;
1897
+ if (delayMs < 1) delayMs = 1; // setTimeout floor
1898
+ if (delayMs > 100) delayMs = 100; // don't stall on a pathologically low rate
1899
+ context.burst_timer = setTimeout(function () {
1900
+ context.burst_timer = null;
1901
+ plan_quic_burst();
1902
+ }, delayMs);
1903
+ if (context.burst_timer.unref) context.burst_timer.unref();
1904
+ } else if (burstCount === 0 && hasData) {
1905
+ // Blocked by cwnd / rate window (not pacing) — wait for capacity / ACKs.
1906
+ var waitMs = (packetsRemaining <= 0 || bytesRemaining < 35) ? 50 : 10;
1907
+ context.burst_timer = setTimeout(function () {
1908
+ context.burst_timer = null;
1909
+ plan_quic_burst();
1910
+ }, waitMs);
1911
+ if (context.burst_timer.unref) context.burst_timer.unref();
1912
+ } else if (hasInFlight) {
1913
+ // Nothing to send right now, but data is still in flight and unacked.
1914
+ // Keep a light timer ticking so expireInFlight (top of each pass) can
1915
+ // time it out and resend. Self-terminating once everything is acked.
1916
+ context.burst_timer = setTimeout(function () {
1917
+ context.burst_timer = null;
1918
+ plan_quic_burst();
1919
+ }, 20);
1920
+ if (context.burst_timer.unref) context.burst_timer.unref();
1921
+ } else if (fcBlocked && hasData) {
1922
+ // FC-blocked with nothing in flight: without this the scheduler would go
1923
+ // idle here and the (rate-limited) BLOCKED signal above would fire only
1924
+ // once — and a once-sent frame can be lost too. Keep ticking until the
1925
+ // peer's window update arrives and unblocks hasData → sent.
1926
+ context.burst_timer = setTimeout(function () {
1927
+ context.burst_timer = null;
1928
+ plan_quic_burst();
1929
+ }, 250);
1930
+ if (context.burst_timer.unref) context.burst_timer.unref();
1931
+ }
1932
+ }
1933
+
1934
+
1935
+ /**
1936
+ * execute_quic_burst — fill up to packet_count packets.
1937
+ * Round-robin across active streams.
1938
+ * Returns the number of packets actually sent (0 if none) — the pacer
1939
+ * charges tokens per REAL packet, not per the packetCount ceiling.
1940
+ */
1941
+ function execute_quic_burst(packetCount) {
1942
+ var MAX_PAYLOAD = context.current_limit_packet_payload;
1943
+ var OVERHEAD = 24; // STREAM frame header overhead estimate
1944
+ var sentCount = 0;
1945
+
1946
+ function getActiveIds() {
1947
+ var ids = [];
1948
+ for (var sid in context.send_streams) {
1949
+ var st = context.send_streams[sid];
1950
+ if (!st.pending_data || st.pending_data.byteLength === 0) continue;
1951
+ var total = st.total_size > 0 ? st.total_size : st.write_offset;
1952
+ // Build known_sent = acked + in_flight
1953
+ var known = st.acked_ranges.slice();
1954
+ if (st.in_flight_ranges) {
1955
+ for (var pn in st.in_flight_ranges) flat_ranges.add(known, st.in_flight_ranges[pn]);
1956
+ }
1957
+ var missing = flat_ranges.invert(known, 0, total);
1958
+ if (missing.length > 0) ids.push(Number(sid));
1959
+ }
1960
+ return ids;
1961
+ }
1962
+
1963
+ var activeIds = getActiveIds();
1964
+ var burstYielded = {}; // track ranges yielded per-stream within this burst
1965
+
1966
+ for (var p = 0; p < packetCount; p++) {
1967
+ var frames = [];
1968
+ var used = 0;
1969
+
1970
+ // (1) ACK in first packet if pending
1971
+ if (p === 0 && context.pending_ack.app.length > 0) {
1972
+ var ackFrame = ranges_to_ack_frame(context.pending_ack.app, null, 0);
1973
+ if (ackFrame) {
1974
+ var ackEncoded = encode_quic_frames([ackFrame]);
1975
+ if (ackEncoded.byteLength <= MAX_PAYLOAD) {
1976
+ frames.push(ackFrame);
1977
+ used += ackEncoded.byteLength;
1978
+ context.pending_ack.app = [];
1979
+ }
1980
+ }
1981
+ }
1982
+
1983
+ // (2) Round-robin STREAM data across active streams
1984
+ if (activeIds.length > 0 && used < MAX_PAYLOAD) {
1985
+ var progress = true;
1986
+ while (used < MAX_PAYLOAD && progress) {
1987
+ progress = false;
1988
+
1989
+ for (var i = 0; i < activeIds.length; i++) {
1990
+ if (used >= MAX_PAYLOAD) break;
1991
+
1992
+ var sid = activeIds[i];
1993
+ var st = context.send_streams[sid];
1994
+ if (!st || !st.pending_data) continue;
1995
+
1996
+ var budget = Math.max(0, MAX_PAYLOAD - used - OVERHEAD);
1997
+ if (budget <= 0) break;
1998
+
1999
+ var chunks = get_stream_chunks(sid, budget);
2000
+ if (!chunks || chunks.length === 0) continue;
2001
+
2002
+ for (var c = 0; c < chunks.length; c++) {
2003
+ var ch = chunks[c];
2004
+ var fin = (st.total_size > 0 && ch.offset + ch.data.byteLength >= st.total_size);
2005
+ frames.push({ type: 'stream', id: sid, offset: ch.offset, fin: fin, data: ch.data });
2006
+ used += ch.data.byteLength + OVERHEAD;
2007
+ progress = true;
2008
+
2009
+ // Immediately track as yielded so next round-robin iteration won't resend
2010
+ if (!burstYielded[sid]) burstYielded[sid] = [];
2011
+ flat_ranges.add(burstYielded[sid], [ch.offset, ch.offset + ch.data.byteLength]);
2012
+ // Also add to in_flight with temp key so get_stream_chunks sees it
2013
+ if (!st.in_flight_ranges) st.in_flight_ranges = {};
2014
+ if (!st.in_flight_ranges['_burst']) st.in_flight_ranges['_burst'] = [];
2015
+ flat_ranges.add(st.in_flight_ranges['_burst'], [ch.offset, ch.offset + ch.data.byteLength]);
2016
+ }
2017
+ }
2018
+ }
2019
+ }
2020
+
2021
+ // (3) FIN-only frames: if all data sent/in-flight but FIN not yet sent
2022
+ for (var sid in context.send_streams) {
2023
+ var st = context.send_streams[sid];
2024
+ if (!st || st.total_size <= 0 || st.fin_sent) continue;
2025
+ // Check if all bytes are covered (acked + in_flight)
2026
+ var known = st.acked_ranges.slice();
2027
+ if (st.in_flight_ranges) {
2028
+ for (var pn in st.in_flight_ranges) flat_ranges.add(known, st.in_flight_ranges[pn]);
2029
+ }
2030
+ var missing = flat_ranges.invert(known, 0, st.total_size);
2031
+ if (DEBUG) console.log('[quic] FIN-only check stream=' + sid + ' total_size=' + st.total_size + ' fin_sent=' + st.fin_sent + ' missing=' + missing.length + ' known=' + JSON.stringify(known));
2032
+ if (missing.length === 0) {
2033
+ if (DEBUG) console.log('[quic] → sending FIN-only for stream ' + sid);
2034
+ // All data sent — send FIN-only STREAM frame
2035
+ frames.push({ type: 'stream', id: Number(sid), offset: st.total_size, fin: true, data: new Uint8Array(0) });
2036
+ st.fin_sent = true;
2037
+ }
2038
+ }
2039
+
2040
+ // Send packet if it has content beyond just ACK-only
2041
+ if (frames.length > 0) {
2042
+ sendFrames('app', frames);
2043
+ sentCount++;
2044
+ // Clean up temp _burst in_flight keys (real PN keys now recorded by sendFrames)
2045
+ for (var sid in burstYielded) {
2046
+ var st = context.send_streams[sid];
2047
+ if (st && st.in_flight_ranges && st.in_flight_ranges['_burst']) {
2048
+ delete st.in_flight_ranges['_burst'];
2049
+ }
2050
+ }
2051
+ } else {
2052
+ break; // nothing to fill further packets
2053
+ }
2054
+
2055
+ // Refresh active list
2056
+ activeIds = getActiveIds();
2057
+ if (activeIds.length === 0) break;
2058
+ }
2059
+
2060
+ return sentCount;
2061
+ }
2062
+
2063
+
2064
+ /**
2065
+ * get_stream_chunks — find what bytes are missing (not acked) and
2066
+ * extract up to maxBytes from pending_data.
2067
+ * Uses flat_ranges.invert(acked_ranges) — the heart of retransmit.
2068
+ * Adapted from old get_stream_chunks_to_send().
2069
+ *
2070
+ * Strategy: first forward from send_offset (new data),
2071
+ * then fill gaps before send_offset (retransmit).
2072
+ * pending_data is NEVER deleted — only when stream is fully acked.
2073
+ */
2074
+ function get_stream_chunks(streamId, maxBytes) {
2075
+ var stream = context.send_streams[streamId];
2076
+ if (!stream || !stream.pending_data || stream.pending_data.byteLength === 0) return [];
2077
+
2078
+ // ── Flow control, send side (RFC 9000 §4.1) — two levels, one rule ──────
2079
+ // Both limits cap the HIGHEST OFFSET we may send, so they bind only NEW
2080
+ // data (bytes extending max_sent_offset). Retransmissions re-send offsets
2081
+ // that were already inside the allowance when first sent — they consume no
2082
+ // budget and MUST NOT be blocked. (The previous connection-only check
2083
+ // gated ALL bytes on remote_max_data − bytes_sent: under loss at the edge
2084
+ // of an exhausted window, the lost range itself was FC-blocked from being
2085
+ // repaired — a deadlock the peer can't always resolve, since the data it
2086
+ // would raise the window for is exactly the data that never arrived.)
2087
+ // streamLimit — absolute offset cap for THIS stream (seeded from the
2088
+ // peer's initial_max_stream_data_*, raised by
2089
+ // MAX_STREAM_DATA). Monotonic, so it never binds on a
2090
+ // retransmit of previously-allowed offsets.
2091
+ // connRemaining — connection-level budget for new bytes: the peer's
2092
+ // max_data minus the sum of all streams' high-water
2093
+ // marks (context.max_data_sent). Decremented locally as
2094
+ // this call yields new bytes; committed via
2095
+ // max_data_sent at yield time (charging a chunk that a
2096
+ // later encrypt-failure drops only wastes budget —
2097
+ // conservative — never overspends it).
2098
+ var streamLimit = stream.remote_max_stream_data;
2099
+ var connRemaining = context.remote_max_data - context.max_data_sent;
2100
+
2101
+ var data = stream.pending_data;
2102
+ var baseOffset = stream.pending_offset_start;
2103
+ var totalSize = stream.total_size > 0 ? stream.total_size : stream.write_offset;
2104
+
2105
+ // Build "known sent" = acked + in_flight (don't resend what's already in transit)
2106
+ var knownSent = stream.acked_ranges.slice(); // copy
2107
+ if (stream.in_flight_ranges) {
2108
+ for (var pn in stream.in_flight_ranges) {
2109
+ flat_ranges.add(knownSent, stream.in_flight_ranges[pn]);
2110
+ }
2111
+ }
2112
+
2113
+ // What truly needs sending? = invert of knownSent over [0, totalSize)
2114
+ var missing = flat_ranges.invert(knownSent, 0, totalSize);
2115
+ if (!missing || missing.length === 0) return [];
2116
+
2117
+ var sendOffset = stream.send_offset;
2118
+ var chunks = [];
2119
+ var used = 0;
2120
+
2121
+ // Phase 1: forward from send_offset (new data, priority)
2122
+ for (var i = 0; i < missing.length; i += 2) {
2123
+ if (used >= maxBytes) break;
2124
+ var mFrom = missing[i];
2125
+ var mTo = missing[i + 1];
2126
+
2127
+ // Only ranges at or after send_offset
2128
+ if (mTo <= sendOffset) continue;
2129
+ var from = Math.max(mFrom, sendOffset);
2130
+ var to = mTo;
2131
+
2132
+ // Clamp to budget
2133
+ var len = Math.min(to - from, maxBytes - used);
2134
+ if (len <= 0) continue;
2135
+
2136
+ // Clamp to buffer bounds
2137
+ var relStart = from - baseOffset;
2138
+ var relEnd = relStart + len;
2139
+ if (relStart < 0 || relStart >= data.byteLength) continue;
2140
+ if (relEnd > data.byteLength) { relEnd = data.byteLength; len = relEnd - relStart; }
2141
+ if (len <= 0) continue;
2142
+
2143
+ // FC clamp 1: never extend past the peer's per-stream offset cap.
2144
+ if (from + len > streamLimit) {
2145
+ stream.fc_blocked = true; // the cap bound — signal STREAM_DATA_BLOCKED
2146
+ if (from >= streamLimit) continue;
2147
+ len = streamLimit - from; relEnd = relStart + len;
2148
+ }
2149
+
2150
+ // FC clamp 2: charge the connection budget for NEW bytes only (the part
2151
+ // of this chunk above the stream's high-water mark).
2152
+ var newStart = Math.max(from, stream.max_sent_offset);
2153
+ var newBytes = Math.max(0, (from + len) - newStart);
2154
+ if (newBytes > connRemaining) {
2155
+ context.fc_blocked_conn = true; // the budget bound — signal DATA_BLOCKED
2156
+ len -= (newBytes - connRemaining);
2157
+ relEnd = relStart + len;
2158
+ newBytes = connRemaining;
2159
+ }
2160
+ if (len <= 0) continue;
2161
+
2162
+ chunks.push({ offset: from, data: data.slice(relStart, relEnd) });
2163
+ used += len;
2164
+
2165
+ // Commit FC usage at yield time (see header note).
2166
+ if (newBytes > 0) {
2167
+ stream.max_sent_offset = from + len;
2168
+ context.max_data_sent += newBytes;
2169
+ connRemaining -= newBytes;
2170
+ }
2171
+
2172
+ // Advance send_offset
2173
+ if (from + len > stream.send_offset) {
2174
+ stream.send_offset = from + len;
2175
+ }
2176
+ }
2177
+
2178
+ // Phase 2: fill gaps before send_offset (retransmit lost data)
2179
+ // First update knownSent with what Phase 1 just yielded
2180
+ if (chunks.length > 0) {
2181
+ for (var ci = 0; ci < chunks.length; ci++) {
2182
+ flat_ranges.add(knownSent, [chunks[ci].offset, chunks[ci].offset + chunks[ci].data.byteLength]);
2183
+ }
2184
+ missing = flat_ranges.invert(knownSent, 0, totalSize);
2185
+ }
2186
+ if (used < maxBytes) {
2187
+ for (var i = 0; i < missing.length; i += 2) {
2188
+ if (used >= maxBytes) break;
2189
+ var mFrom = missing[i];
2190
+ var mTo = missing[i + 1];
2191
+
2192
+ // Only ranges before send_offset
2193
+ if (mFrom >= sendOffset) break;
2194
+ var to = Math.min(mTo, sendOffset);
2195
+
2196
+ var len = Math.min(to - mFrom, maxBytes - used);
2197
+ if (len <= 0) continue;
2198
+
2199
+ var relStart = mFrom - baseOffset;
2200
+ var relEnd = relStart + len;
2201
+ if (relStart < 0 || relStart >= data.byteLength) continue;
2202
+ if (relEnd > data.byteLength) { relEnd = data.byteLength; len = relEnd - relStart; }
2203
+ if (len <= 0) continue;
2204
+
2205
+ // FC fail-safe: retransmits sit below the high-water mark, which was
2206
+ // itself capped when first sent, and limits are monotonic — so these
2207
+ // clamps should never bind here. Applied anyway so the invariant
2208
+ // "nothing ever leaves above streamLimit / beyond the conn budget"
2209
+ // holds unconditionally, whatever the scheduler does around us.
2210
+ if (mFrom >= streamLimit) continue;
2211
+ if (mFrom + len > streamLimit) { len = streamLimit - mFrom; relEnd = relStart + len; }
2212
+ var newStart2 = Math.max(mFrom, stream.max_sent_offset);
2213
+ var newBytes2 = Math.max(0, (mFrom + len) - newStart2);
2214
+ if (newBytes2 > connRemaining) {
2215
+ len -= (newBytes2 - connRemaining);
2216
+ relEnd = relStart + len;
2217
+ newBytes2 = connRemaining;
2218
+ }
2219
+ if (len <= 0) continue;
2220
+
2221
+ chunks.push({ offset: mFrom, data: data.slice(relStart, relEnd) });
2222
+ used += len;
2223
+
2224
+ if (newBytes2 > 0) {
2225
+ stream.max_sent_offset = mFrom + len;
2226
+ context.max_data_sent += newBytes2;
2227
+ connRemaining -= newBytes2;
2228
+ }
2229
+ }
2230
+ }
2231
+
2232
+ // Trim front of pending_data if beginning is fully acked (optimization)
2233
+ if (stream.acked_ranges.length >= 2 && stream.acked_ranges[0] === 0 && stream.acked_ranges[1] > baseOffset) {
2234
+ var trimTo = stream.acked_ranges[1];
2235
+ var trimBytes = trimTo - baseOffset;
2236
+ if (trimBytes > 0 && trimBytes < data.byteLength) {
2237
+ stream.pending_data = data.slice(trimBytes);
2238
+ stream.pending_offset_start = trimTo;
2239
+ }
2240
+ }
2241
+
2242
+ return chunks;
2243
+ }
2244
+
2245
+
2246
+ // ============================================================
2247
+ // Close
2248
+ // ============================================================
2249
+
2250
+ function sendConnectionClose() {
2251
+ if (!context.close_frame) return;
2252
+ sendFrames('app', [context.close_frame]);
2253
+ context.last_close_echo = Date.now();
2254
+ }
2255
+
2256
+ function close(errorCode, reason) {
2257
+ if (context.state === 'closed' || context.state === 'draining' || context.state === 'closing') return;
2258
+ if (context.handshake_timer) { clearTimeout(context.handshake_timer); context.handshake_timer = null; }
2259
+ stopCryptoRetx();
2260
+ context.close_frame = {
2261
+ type: 'connection_close', application: false,
2262
+ error: errorCode || 0, frameType: 0, reason: reason || ''
2263
+ };
2264
+ sendConnectionClose();
2265
+ // RFC 9000 §10.2.1: the endpoint that *sends* CONNECTION_CLOSE enters the
2266
+ // closing state — it re-echoes the CC (rate-limited) in response to incoming
2267
+ // packets, rather than going silent. The peer, which *receives* the CC,
2268
+ // enters draining (handled where connection_close frames are processed).
2269
+ set_context({ state: 'closing' });
2270
+ }
2271
+
2272
+
2273
+ // ============================================================
2274
+ // Client connect (Phase 7)
2275
+ // ============================================================
2276
+
2277
+ function connect() {
2278
+ if (context.isServer) return; // server waits for feedPacket, doesn't connect
2279
+
2280
+ // Generate random DCID (8 bytes)
2281
+ var dcid = new Uint8Array(8);
2282
+ for (var i = 0; i < 8; i++) dcid[i] = Math.floor(Math.random() * 256);
2283
+
2284
+ // Generate random SCID (8 bytes)
2285
+ var scid = new Uint8Array(8);
2286
+ for (var i = 0; i < 8; i++) scid[i] = Math.floor(Math.random() * 256);
2287
+
2288
+ context.original_dcid = dcid;
2289
+ context.my_cids.push(scid);
2290
+
2291
+ // Client initial keys: SWAPPED from server
2292
+ // Client writes with "client in" keys (direction='read' in our function)
2293
+ // Client reads with "server in" keys (direction='write' in our function)
2294
+ set_context({
2295
+ initial_write: quic_derive_init_secrets(dcid, context.version, 'read'),
2296
+ initial_read: quic_derive_init_secrets(dcid, context.version, 'write')
2297
+ });
2298
+
2299
+ // Initialize TLS (client mode)
2300
+ if (DEBUG) console.log('[quic] client connecting, dcid=' + Array.from(dcid).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(''));
2301
+ initTLS();
2302
+
2303
+ // Trigger ClientHello by feeding empty message
2304
+ tls.feedMessage(new Uint8Array(0));
2305
+ }
2306
+
2307
+
2308
+ // ============================================================
2309
+ // Public API
2310
+ // ============================================================
2311
+
2312
+ var api = {
2313
+ context: context,
2314
+ on: function (name, fn) { ev.on(name, fn); },
2315
+ off: function (name, fn) { ev.off(name, fn); },
2316
+ feedDatagram: feedDatagram,
2317
+ feedPackets: feedPackets,
2318
+ sendStream: sendStream,
2319
+ sendDatagram: sendDatagram,
2320
+ maxDatagramSize: maxDatagramSize,
2321
+ close: close,
2322
+ connect: connect,
2323
+ set_context: set_context,
2324
+ get state() { return context.state; }
2325
+ };
2326
+
2327
+ // Copy api onto `this`. Use descriptors (not `this[k] = api[k]`) so that
2328
+ // accessor properties like `get state()` are preserved as live getters
2329
+ // instead of being frozen to their construction-time value.
2330
+ for (var k in api) {
2331
+ if (Object.prototype.hasOwnProperty.call(api, k)) {
2332
+ Object.defineProperty(this, k, Object.getOwnPropertyDescriptor(api, k));
2333
+ }
2334
+ }
2335
+ return this;
2336
+ }
2337
+
2338
+ export { QUICConnection };