tunnelfetch 1.0.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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. package/types/warmup.d.ts +45 -0
@@ -0,0 +1,429 @@
1
+ // RFC 6265 cookie jar, DOM-free.
2
+ //
3
+ // Two discipline points shape this file:
4
+ //
5
+ // 1. Rejection is silent BY SPECIFICATION. RFC 6265 says a UA that dislikes a Set-Cookie
6
+ // (domain mismatch, Secure over http, bare-TLD domain) ignores it and moves on — erroring
7
+ // would let any response header abort the fetch. Silent is not invisible, though: every
8
+ // ignored cookie increments `jar.rejected`, because "why is my session cookie missing"
9
+ // is otherwise undebuggable.
10
+ //
11
+ // 2. The clock is injected. On the target runtime Date.now() is frozen during synchronous
12
+ // execution and only advances across I/O, so expiry logic that calls Date.now() directly
13
+ // is untestable there and subtly wrong in production. Everything time-shaped goes through
14
+ // `this._now()`.
15
+
16
+ /**
17
+ * Parse a cookie date per RFC 6265 s5.1.1 — NOT Date.parse. Date.parse accepts formats the
18
+ * RFC rejects, rejects formats the RFC accepts (two-digit years, odd delimiters), and differs
19
+ * between engines; cookies from 1990s-era servers still use every shape the RFC grandfathers.
20
+ *
21
+ * The algorithm: split into tokens on "delimiters", then find — in any order, first match per
22
+ * category wins — a time (hh:mm:ss), a day (1-2 digits), a month (3-letter name), and a year
23
+ * (2-4 digits). Each token may carry trailing non-digit junk after the match.
24
+ *
25
+ * @param {string} s
26
+ * @returns {number|null} epoch milliseconds UTC, or null if the string is not a cookie date.
27
+ */
28
+ export function parseCookieDate(s) {
29
+ // Delimiter set per the RFC: %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E.
30
+ const tokens = s.split(/[\x09\x20-\x2f\x3b-\x40\x5b-\x60\x7b-\x7e]+/).filter((t) => t !== '');
31
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
32
+ let time = null;
33
+ let day = null;
34
+ let month = null;
35
+ let year = null;
36
+ for (const tok of tokens) {
37
+ // Order of checks mirrors the RFC: time, day, month, year; a token is consumed by the
38
+ // first category it matches AND that is still unset.
39
+ if (time === null) {
40
+ const m = /^(\d{1,2}):(\d{1,2}):(\d{1,2})(?:\D.*)?$/.exec(tok);
41
+ if (m) {
42
+ time = [Number(m[1]), Number(m[2]), Number(m[3])];
43
+ continue;
44
+ }
45
+ }
46
+ if (day === null) {
47
+ const m = /^(\d{1,2})(?:\D.*)?$/.exec(tok);
48
+ if (m) {
49
+ day = Number(m[1]);
50
+ continue;
51
+ }
52
+ }
53
+ if (month === null) {
54
+ const idx = MONTHS.indexOf(tok.slice(0, 3).toLowerCase());
55
+ if (idx >= 0) {
56
+ month = idx;
57
+ continue;
58
+ }
59
+ }
60
+ if (year === null) {
61
+ const m = /^(\d{2,4})(?:\D.*)?$/.exec(tok);
62
+ if (m) {
63
+ year = Number(m[1]);
64
+ continue;
65
+ }
66
+ }
67
+ }
68
+ if (time === null || day === null || month === null || year === null) return null;
69
+ // Two-digit pivot: 70-99 are 19xx, 00-69 are 20xx. Three-digit years are not a thing the
70
+ // RFC recognises; a "year" of 100-1600 falls to the <1601 floor below.
71
+ if (year >= 70 && year <= 99) year += 1900;
72
+ else if (year >= 0 && year <= 69) year += 2000;
73
+ const [hh, mm, ss] = time;
74
+ if (day < 1 || day > 31 || year < 1601 || hh > 23 || mm > 59 || ss > 59) return null;
75
+ const ms = Date.UTC(year, month, day, hh, mm, ss);
76
+ // Date.UTC rolls over out-of-range days (Feb 31 -> Mar 3); the RFC treats those as simply
77
+ // "some date", and rollover is what every UA ships, so we accept the rolled value.
78
+ return ms;
79
+ }
80
+
81
+ /**
82
+ * RFC 6265 s5.1.4: the default path is the request path up to (not including) its last '/'.
83
+ * @param {string} requestPath
84
+ * @returns {string}
85
+ */
86
+ export function defaultPath(requestPath) {
87
+ if (!requestPath || requestPath[0] !== '/') return '/';
88
+ const cut = requestPath.lastIndexOf('/');
89
+ return cut === 0 ? '/' : requestPath.slice(0, cut);
90
+ }
91
+
92
+ /**
93
+ * RFC 6265 s5.1.4 path-match.
94
+ * @param {string} requestPath
95
+ * @param {string} cookiePath
96
+ * @returns {boolean}
97
+ */
98
+ export function pathMatches(requestPath, cookiePath) {
99
+ if (requestPath === cookiePath) return true;
100
+ if (!requestPath.startsWith(cookiePath)) return false;
101
+ // Prefix match counts only when the boundary is a '/', so /foo does not match /foobar.
102
+ return cookiePath.endsWith('/') || requestPath[cookiePath.length] === '/';
103
+ }
104
+
105
+ /**
106
+ * RFC 6265 s5.1.3 domain-match: exact, or host ends with '.' + domain.
107
+ * @param {string} host
108
+ * @param {string} cookieDomain
109
+ * @returns {boolean}
110
+ */
111
+ export function domainMatches(host, cookieDomain) {
112
+ if (host === cookieDomain) return true;
113
+ return host.endsWith('.' + cookieDomain);
114
+ }
115
+
116
+ /** Very loose IPv4/IPv6 literal check — a suffix "domain match" on an IP makes no sense. */
117
+ const looksLikeIp = (host) =>
118
+ /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':') || /^\[.*\]$/.test(host);
119
+
120
+ let seqCounter = 0; // creation-order tiebreak; the injected clock may legally stand still
121
+
122
+ /**
123
+ * A stored cookie, as entries() exposes it. Records are live jar state, not copies.
124
+ * @typedef {object} Cookie
125
+ * @property {string} name
126
+ * @property {string} value quotes already stripped
127
+ * @property {string} domain lowercased; the Domain attribute, or the request host
128
+ * @property {boolean} hostOnly true when no Domain attribute applied — exact-host match only
129
+ * @property {string} path
130
+ * @property {boolean} secure
131
+ * @property {boolean} httpOnly
132
+ * @property {string | null} sameSite lowercased attribute value, stored verbatim — servers send
133
+ * values outside strict/lax/none and this jar does not enforce SameSite anyway
134
+ * @property {number} expiry epoch ms; Infinity for a session cookie
135
+ * @property {number} creation epoch ms from the injected clock, kept across overwrites (s5.3)
136
+ * @property {number} seq creation-order tiebreak for the frozen-clock runtime
137
+ */
138
+
139
+ /**
140
+ * @typedef {object} CookieJarOptions
141
+ * @property {() => number} [now] injectable clock returning epoch ms. On the target runtime
142
+ * Date.now() freezes for a whole execution slice, so expiry must be testable via this knob.
143
+ * @property {number} [maxCookies] global cap, default 3000
144
+ * @property {number} [maxPerDomain] per-domain cap, default 50
145
+ */
146
+
147
+ export class CookieJar {
148
+ /**
149
+ * @param {CookieJarOptions} [options]
150
+ * The caps exist because this jar lives inside a long-lived Worker isolate: an unbounded
151
+ * jar fed by a hostile or merely enthusiastic server is a slow memory leak, so overflow
152
+ * evicts the oldest cookies instead of growing.
153
+ */
154
+ constructor({ now = () => Date.now(), maxCookies = 3000, maxPerDomain = 50 } = {}) {
155
+ this._now = now;
156
+ this._maxCookies = maxCookies;
157
+ this._maxPerDomain = maxPerDomain;
158
+ /** @type {Map<string, Cookie>} key "domain|path|name" -> cookie record */
159
+ this._cookies = new Map();
160
+ this._rejected = 0;
161
+ }
162
+
163
+ /** Count of Set-Cookie values ignored per RFC rules — the observability hook. */
164
+ get rejected() {
165
+ return this._rejected;
166
+ }
167
+
168
+ get size() {
169
+ return this._cookies.size;
170
+ }
171
+
172
+ /**
173
+ * Ingest the Set-Cookie values of one response. Never throws on a bad cookie — rejection is
174
+ * silent per RFC 6265, counted in `rejected`.
175
+ * @param {string|URL} url the request URL the response belongs to
176
+ * @param {string[]} setCookieValues one array entry per Set-Cookie header
177
+ * @returns {void}
178
+ */
179
+ setFromResponse(url, setCookieValues) {
180
+ const u = url instanceof URL ? url : new URL(url);
181
+ const host = u.hostname.toLowerCase();
182
+ const secure = u.protocol === 'https:';
183
+ for (const value of setCookieValues) {
184
+ if (!this._setOne(host, secure, u.pathname, value)) this._rejected++;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * @param {string} host
190
+ * @param {boolean} requestSecure
191
+ * @param {string} requestPath
192
+ * @param {string} setCookie
193
+ * @returns {boolean} stored or deliberately deleted (true) vs ignored (false)
194
+ */
195
+ _setOne(host, requestSecure, requestPath, setCookie) {
196
+ if (typeof setCookie !== 'string') return false;
197
+ const semi = setCookie.indexOf(';');
198
+ const pair = semi === -1 ? setCookie : setCookie.slice(0, semi);
199
+ // RFC 6265 s5.2: no '=' in the name-value pair means ignore the whole thing.
200
+ const eq = pair.indexOf('=');
201
+ if (eq === -1) return false;
202
+ const name = pair.slice(0, eq).trim();
203
+ let value = pair.slice(eq + 1).trim();
204
+ if (name === '') return false;
205
+ // Control characters in name or value are a smuggling vector, not a cookie.
206
+ if (/[\x00-\x1f\x7f]/.test(name) || /[\x00-\x1f\x7f]/.test(value)) return false;
207
+ if (/[\s;,]/.test(name)) return false;
208
+ // A quoted value is stored with its quotes stripped, matching what UAs send back.
209
+ if (value.length >= 2 && value[0] === '"' && value.at(-1) === '"') value = value.slice(1, -1);
210
+
211
+ // ---- attributes: names case-insensitive, LAST occurrence of each wins, unknown ignored.
212
+ let maxAge = null; // seconds, may be <= 0
213
+ let expires = null; // epoch ms
214
+ let domainAttr = null;
215
+ let pathAttr = null;
216
+ let secure = false;
217
+ let httpOnly = false;
218
+ let sameSite = null;
219
+ if (semi !== -1) {
220
+ for (const rawAttr of setCookie.slice(semi + 1).split(';')) {
221
+ const aeq = rawAttr.indexOf('=');
222
+ const aname = (aeq === -1 ? rawAttr : rawAttr.slice(0, aeq)).trim().toLowerCase();
223
+ const avalue = aeq === -1 ? '' : rawAttr.slice(aeq + 1).trim();
224
+ switch (aname) {
225
+ case 'expires': {
226
+ const t = parseCookieDate(avalue);
227
+ // An unparseable date means "ignore this attribute", never "reject the cookie":
228
+ // the RFC's error recovery is per-attribute.
229
+ if (t !== null) expires = t;
230
+ break;
231
+ }
232
+ case 'max-age': {
233
+ // s5.2.2: first char must be a digit or '-', remainder digits; anything else
234
+ // means ignore the attribute (Max-Age=forever is not an error, it is noise).
235
+ if (/^-?\d+$/.test(avalue)) maxAge = Number(avalue);
236
+ break;
237
+ }
238
+ case 'domain': {
239
+ if (avalue !== '') domainAttr = avalue.replace(/^\./, '').toLowerCase();
240
+ break;
241
+ }
242
+ case 'path': {
243
+ pathAttr = avalue;
244
+ break;
245
+ }
246
+ case 'secure':
247
+ secure = true;
248
+ break;
249
+ case 'httponly':
250
+ httpOnly = true;
251
+ break;
252
+ case 'samesite':
253
+ sameSite = avalue.toLowerCase();
254
+ break;
255
+ default:
256
+ // Unknown attributes are explicitly ignored (s5.2 last paragraph).
257
+ break;
258
+ }
259
+ }
260
+ }
261
+
262
+ // Secure cookies may only be SET over https, not just sent over it — otherwise an http
263
+ // man-in-the-middle can plant a cookie the application later trusts as Secure.
264
+ if (secure && !requestSecure) return false;
265
+
266
+ // ---- cookie name prefixes (RFC 6265bis s5.4; storage model s5.7 steps 20-21)
267
+ //
268
+ // A "__Secure-"/"__Host-" name is the server's claim that the cookie was set with specific
269
+ // attributes, and the receiving server trusts the NAME as proof of them. A Set-Cookie that
270
+ // breaks its own name's claim is therefore refused whole — storing it "repaired" would
271
+ // manufacture exactly the proof the server must not get.
272
+ //
273
+ // Matching is case-INSENSITIVE (s5.4 "UAs MUST match cookie name prefixes
274
+ // case-insensitively"): servers routinely compare names case-insensitively, so a
275
+ // "__SeCuRe-" lookalike must face the same rules as the honest spelling or it can
276
+ // impersonate the protected cookie. The regexes carry no 'u' flag on purpose: without it,
277
+ // /i folding can never map a non-ASCII character onto an ASCII one, making the match
278
+ // exactly ASCII-case-insensitive.
279
+ //
280
+ // The mimicry rule for nameless cookies (s5.7 step 22) needs no code: pairs with no '='
281
+ // or an empty name were refused above, so a value can never pose as a prefixed name.
282
+ if (/^__secure-/i.test(name)) {
283
+ // Step 20 requires only the Secure attribute here; being set over a secure channel is
284
+ // the check directly above. Together: https and Secure, or nothing.
285
+ if (!secure) return false;
286
+ } else if (/^__host-/i.test(name)) {
287
+ // Step 21: Secure, host-only, Path=/. Two clauses are deliberately stricter than the
288
+ // storage-model letter, matching the server-facing contract (s4.1.3.2: "a Secure
289
+ // attribute, a Path attribute with a value of /, and no Domain attribute"):
290
+ // - the Domain attribute must be ABSENT, not merely resolve to host-only (the no-dot
291
+ // and IP-literal branches below keep Domain=<host> host-only, but the name promised
292
+ // no Domain at all);
293
+ // - the Path attribute's value must literally be "/", not an invalid value that the
294
+ // default-path fallback would quietly repair to "/".
295
+ // Both refuse strictly more than s5.7 asks, never less — fail closed.
296
+ if (!secure || domainAttr !== null || pathAttr !== '/') return false;
297
+ }
298
+
299
+ // ---- domain validation
300
+ let domain = host;
301
+ let hostOnly = true;
302
+ if (domainAttr !== null) {
303
+ // Public-suffix guard, minimal version: a domain with no dot is a bare TLD ("com",
304
+ // "org", "internal"), and accepting it would let one site set cookies for every site
305
+ // under that TLD. A real public-suffix list (which would also catch "co.uk") is out of
306
+ // scope here and deliberately NOT approximated further — a half-faked PSL is worse than
307
+ // a documented gap, because it changes behaviour silently as the fake grows. The one
308
+ // legitimate no-dot case, Domain=localhost on localhost, is exact-equal to the host and
309
+ // handled by treating it as host-only, which is what the PSL algorithm prescribes.
310
+ if (!domainAttr.includes('.')) {
311
+ if (domainAttr !== host) return false;
312
+ // fall through with hostOnly = true, domain = host
313
+ } else if (looksLikeIp(host)) {
314
+ // A Domain attribute on an IP-literal request only makes sense as an exact match.
315
+ if (domainAttr !== host) return false;
316
+ } else {
317
+ if (!domainMatches(host, domainAttr)) return false;
318
+ domain = domainAttr;
319
+ hostOnly = false;
320
+ }
321
+ }
322
+
323
+ const path = pathAttr && pathAttr[0] === '/' ? pathAttr : defaultPath(requestPath);
324
+
325
+ // ---- expiry: Max-Age wins over Expires when both are present (s5.3 step 3).
326
+ const now = this._now();
327
+ let expiry = Infinity; // session cookie: lives as long as the jar
328
+ if (maxAge !== null) {
329
+ // Max-Age <= 0 means "expire immediately", i.e. delete any existing cookie.
330
+ expiry = maxAge <= 0 ? -Infinity : now + maxAge * 1000;
331
+ } else if (expires !== null) {
332
+ expiry = expires;
333
+ }
334
+
335
+ const key = `${domain}|${path}|${name}`;
336
+ const existing = this._cookies.get(key);
337
+ if (expiry <= now) {
338
+ // Setting an already-expired cookie is the standard deletion idiom.
339
+ this._cookies.delete(key);
340
+ return true;
341
+ }
342
+ const record = {
343
+ name,
344
+ value,
345
+ domain,
346
+ hostOnly,
347
+ path,
348
+ secure,
349
+ httpOnly,
350
+ sameSite,
351
+ expiry,
352
+ // Overwriting keeps the ORIGINAL creation time (s5.3 step 11.3) so the s5.4 sort order
353
+ // is stable across refreshes of the same cookie.
354
+ creation: existing ? existing.creation : now,
355
+ seq: existing ? existing.seq : seqCounter++,
356
+ };
357
+ // Map insertion order would put an overwrite at the end; delete-then-set keeps eviction
358
+ // scanning simple while `seq` preserves true creation order.
359
+ this._cookies.delete(key);
360
+ this._cookies.set(key, record);
361
+ this._enforceCaps(domain);
362
+ return true;
363
+ }
364
+
365
+ /**
366
+ * Evict expired cookies first, then the oldest by creation, per-domain then globally.
367
+ * @param {string} domain
368
+ */
369
+ _enforceCaps(domain) {
370
+ const now = this._now();
371
+ for (const [k, c] of this._cookies) if (c.expiry <= now) this._cookies.delete(k);
372
+
373
+ const inDomain = [...this._cookies.values()].filter((c) => c.domain === domain);
374
+ if (inDomain.length > this._maxPerDomain) {
375
+ inDomain.sort((a, b) => a.creation - b.creation || a.seq - b.seq);
376
+ for (const c of inDomain.slice(0, inDomain.length - this._maxPerDomain)) {
377
+ this._cookies.delete(`${c.domain}|${c.path}|${c.name}`);
378
+ }
379
+ }
380
+ if (this._cookies.size > this._maxCookies) {
381
+ const all = [...this._cookies.values()].sort(
382
+ (a, b) => a.creation - b.creation || a.seq - b.seq,
383
+ );
384
+ for (const c of all.slice(0, all.length - this._maxCookies)) {
385
+ this._cookies.delete(`${c.domain}|${c.path}|${c.name}`);
386
+ }
387
+ }
388
+ }
389
+
390
+ /**
391
+ * The Cookie header value for a request, or null if no cookie matches.
392
+ * @param {string|URL} url
393
+ * @returns {string | null}
394
+ */
395
+ headerFor(url) {
396
+ const u = url instanceof URL ? url : new URL(url);
397
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
398
+ const host = u.hostname.toLowerCase();
399
+ const requestPath = u.pathname || '/';
400
+ const secureChannel = u.protocol === 'https:';
401
+ const now = this._now();
402
+ const matched = [];
403
+ for (const [key, c] of this._cookies) {
404
+ if (c.expiry <= now) {
405
+ this._cookies.delete(key); // lazy expiry sweep; the injected clock decides "now"
406
+ continue;
407
+ }
408
+ if (c.hostOnly ? host !== c.domain : !domainMatches(host, c.domain)) continue;
409
+ if (!pathMatches(requestPath, c.path)) continue;
410
+ if (c.secure && !secureChannel) continue;
411
+ matched.push(c);
412
+ }
413
+ if (matched.length === 0) return null;
414
+ // s5.4: longer paths first, then earlier creation first. The seq tiebreak matters because
415
+ // the frozen-clock runtime hands out identical creation timestamps within one task.
416
+ matched.sort(
417
+ (a, b) => b.path.length - a.path.length || a.creation - b.creation || a.seq - b.seq,
418
+ );
419
+ return matched.map((c) => `${c.name}=${c.value}`).join('; ');
420
+ }
421
+
422
+ /**
423
+ * Everything currently stored, for tests and debugging. Records are live; do not mutate.
424
+ * @returns {Cookie[]}
425
+ */
426
+ entries() {
427
+ return [...this._cookies.values()];
428
+ }
429
+ }