@znight/http 0.0.0-stage → 0.1.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
package/src/cookies.ts ADDED
@@ -0,0 +1,726 @@
1
+ /**
2
+ * Cookies (RFC 0006): parsing of the `cookie` request header, canonical value encoding, strict
3
+ * `Set-Cookie` serialisation (RFC 6265bis: attribute validation, name prefixes, size limits),
4
+ * `Set-Cookie` parsing (tests) and the response cookie jar.
5
+ *
6
+ * This module is import-free (pure functions): safe to import from anywhere, the compiler excepted
7
+ * (it has its own token grammar).
8
+ */
9
+
10
+ /** Options of a cookie set on a response (`ctx.setCookie()`, `HttpResponse.withCookie()`). */
11
+ export interface CookieOptions {
12
+ /** Lifetime in seconds (integer). `0` or less expires the cookie now. Default: session cookie. */
13
+ readonly maxAge?: number
14
+ /**
15
+ * Absolute expiry (sent as an IMF-fixdate), in the years 1601 to 9999 (browsers ignore other
16
+ * years). Browsers cap lifetimes at 400 days: prefer `maxAge` for long-lived cookies.
17
+ */
18
+ readonly expires?: Date
19
+ /**
20
+ * Domain (sub-domains included). Default: none, a host-only cookie. `null`: host-only even when
21
+ * `cookies.defaults` sets a domain. `__Host-` cookies never take the default domain.
22
+ */
23
+ readonly domain?: string | null
24
+ /** Default: `'/'`. */
25
+ readonly path?: string
26
+ /** Default: `true` (browsers only send it over HTTPS, `http://localhost` excepted). */
27
+ readonly secure?: boolean
28
+ /** Default: `true` (not readable from JavaScript). */
29
+ readonly httpOnly?: boolean
30
+ /** Default: `'lax'`. `'none'` requires `secure`. */
31
+ readonly sameSite?: 'strict' | 'lax' | 'none'
32
+ /** CHIPS partitioned cookie. Requires `secure`. Default: `false`. */
33
+ readonly partitioned?: boolean
34
+ /** Chromium `Priority`. Default: none. */
35
+ readonly priority?: 'low' | 'medium' | 'high'
36
+ /** Signs the value with the first secret of the plugin option `cookies.secrets`. Default: `false`. */
37
+ readonly signed?: boolean
38
+ }
39
+
40
+ /** Plugin-wide defaults (`cookies.defaults`). Lifetime and signing are per cookie. */
41
+ export type CookieDefaults = Omit<CookieOptions, 'maxAge' | 'expires' | 'signed'>
42
+
43
+ /** What identifies a cookie to clear (and keeps prefixed names valid). */
44
+ export type ClearCookieOptions = Pick<
45
+ CookieOptions,
46
+ 'domain' | 'path' | 'secure' | 'httpOnly' | 'sameSite' | 'partitioned'
47
+ >
48
+
49
+ /** A cookie to send, as given (defaults and signing are applied when the response is serialised). */
50
+ export interface ResponseCookie {
51
+ readonly name: string
52
+ /** `undefined`: a clearing cookie. */
53
+ readonly value: string | undefined
54
+ readonly options: CookieOptions
55
+ }
56
+
57
+ /** A parsed `Set-Cookie` line (see {@link parseSetCookie}). */
58
+ export interface SetCookieInfo {
59
+ readonly name: string
60
+ /** Decoded value (a signed cookie: `"<value>.<signature>"`, decoded). */
61
+ readonly value: string
62
+ /** The header line. */
63
+ readonly raw: string
64
+ readonly expires?: Date
65
+ readonly maxAge?: number
66
+ readonly domain?: string
67
+ readonly path?: string
68
+ readonly secure: boolean
69
+ readonly httpOnly: boolean
70
+ readonly sameSite?: 'strict' | 'lax' | 'none'
71
+ readonly partitioned: boolean
72
+ readonly priority?: 'low' | 'medium' | 'high'
73
+ }
74
+
75
+ /** The secure defaults: `Path=/; Secure; HttpOnly; SameSite=Lax`, host-only. */
76
+ export const DEFAULT_COOKIE_OPTIONS: Readonly<{
77
+ path: string
78
+ secure: boolean
79
+ httpOnly: boolean
80
+ sameSite: 'strict' | 'lax' | 'none'
81
+ }> = Object.freeze({ path: '/', secure: true, httpOnly: true, sameSite: 'lax' })
82
+
83
+ /** Largest `name=value` (bytes on the wire, after encoding and signing). */
84
+ export const MAX_COOKIE_SIZE = 4096
85
+ /** Largest `Domain` / `Path` attribute value (bytes). */
86
+ export const MAX_COOKIE_ATTRIBUTE_SIZE = 1024
87
+ const MAX_DOMAIN_LENGTH = 253
88
+ const EPOCH = 'Thu, 01 Jan 1970 00:00:00 GMT'
89
+ /** Years a cookie-date can carry (RFC 6265 §5.1.1; four digits in an IMF-fixdate). */
90
+ const MIN_EXPIRES_YEAR = 1601
91
+ const MAX_EXPIRES_YEAR = 9999
92
+
93
+ const OPTION_KEYS: ReadonlySet<string> = new Set([
94
+ 'maxAge',
95
+ 'expires',
96
+ 'domain',
97
+ 'path',
98
+ 'secure',
99
+ 'httpOnly',
100
+ 'sameSite',
101
+ 'partitioned',
102
+ 'priority',
103
+ 'signed',
104
+ ])
105
+ const SAME_SITE: Readonly<Record<string, string>> = { strict: 'Strict', lax: 'Lax', none: 'None' }
106
+ const PRIORITY: Readonly<Record<string, string>> = { low: 'Low', medium: 'Medium', high: 'High' }
107
+
108
+ // -------------------------------------------------------------------------------------------------
109
+ // Request cookies
110
+ // -------------------------------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Parses a `cookie` request header into a prototype-less, frozen record (`__proto__` is just a
114
+ * name). Pairs are split on `;` only (never `,`) and trimmed; a pair without `=` or with an empty
115
+ * name is ignored; names are kept as sent (case-sensitive); a value wrapped in double quotes is
116
+ * unquoted, and percent-decoded when it contains `%` (a malformed sequence keeps the raw value).
117
+ * When a name repeats, the first occurrence wins (browsers send the most specific path first).
118
+ * Linear time, never throws.
119
+ */
120
+ export function parseCookieHeader(header: string | undefined): Readonly<Record<string, string>> {
121
+ const result = Object.create(null) as Record<string, string>
122
+ if (typeof header === 'string') {
123
+ let start = 0
124
+ const length = header.length
125
+ // the next `=` at or after `start`, searched again only once passed: every character is scanned
126
+ // at most twice, even for a header made of `;` without `=` (linear time)
127
+ let equals = -1
128
+ while (start < length) {
129
+ let end = header.indexOf(';', start)
130
+ if (end === -1) end = length
131
+ if (equals < start) {
132
+ equals = header.indexOf('=', start)
133
+ if (equals === -1) equals = length
134
+ }
135
+ if (equals < end) {
136
+ const name = trim(header, start, equals)
137
+ if (name !== '' && !Object.hasOwn(result, name)) {
138
+ result[name] = decodeCookieValue(unquote(trim(header, equals + 1, end)))
139
+ }
140
+ }
141
+ start = end + 1
142
+ }
143
+ }
144
+ return Object.freeze(result)
145
+ }
146
+
147
+ /** `text.slice(start, end)` without leading and trailing SP / HTAB. */
148
+ function trim(text: string, start: number, end: number): string {
149
+ let from = start
150
+ let to = end
151
+ while (from < to && isBlank(text.charCodeAt(from))) from++
152
+ while (to > from && isBlank(text.charCodeAt(to - 1))) to--
153
+ return text.slice(from, to)
154
+ }
155
+
156
+ function isBlank(code: number): boolean {
157
+ return code === 0x20 || code === 0x09
158
+ }
159
+
160
+ function unquote(value: string): string {
161
+ return value.length >= 2 && value.charCodeAt(0) === 0x22 && value.charCodeAt(value.length - 1) === 0x22
162
+ ? value.slice(1, -1)
163
+ : value
164
+ }
165
+
166
+ /**
167
+ * Decodes a cookie value produced by {@link encodeCookieValue} (`decodeURIComponent` when it
168
+ * contains `%`; a malformed sequence keeps the raw value). Never throws.
169
+ */
170
+ export function decodeCookieValue(value: string): string {
171
+ if (!value.includes('%')) return value
172
+ try {
173
+ return decodeURIComponent(value)
174
+ } catch {
175
+ return value
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Encodes a cookie value canonically: the UTF-8 bytes outside RFC 6265 `cookie-octet` (space, `"`,
181
+ * `,`, `;`, `\`, controls, non-ASCII) and `%` are percent-encoded (upper-case hex), so that
182
+ * {@link decodeCookieValue} restores the value exactly (`a b;c` → `a%20b%3Bc`), and
183
+ * `encode(decode(w)) === w` for every encoded `w`. Quotes are never emitted.
184
+ *
185
+ * @throws TypeError for a non-string or a string with lone surrogates (they cannot round-trip).
186
+ */
187
+ export function encodeCookieValue(value: string): string {
188
+ if (typeof value !== 'string') {
189
+ throw new TypeError(`A cookie value must be a string (received ${describe(value)})`)
190
+ }
191
+ if (!value.isWellFormed()) {
192
+ throw new TypeError('A cookie value must be a well-formed string (it contains a lone surrogate)')
193
+ }
194
+ let encoded = ''
195
+ let plain = 0
196
+ for (let index = 0; index < value.length; index++) {
197
+ const code = value.charCodeAt(index)
198
+ if (isCookieOctet(code)) continue
199
+ encoded += value.slice(plain, index)
200
+ if (code < 0x80) {
201
+ encoded += `%${HEX[code] as string}`
202
+ plain = index + 1
203
+ } else {
204
+ // a whole code point (a surrogate pair is two UTF-16 units): `encodeURIComponent` gives its UTF-8 bytes
205
+ const point = value.codePointAt(index) as number
206
+ const width = point > 0xffff ? 2 : 1
207
+ encoded += encodeURIComponent(value.slice(index, index + width))
208
+ index += width - 1
209
+ plain = index + 1
210
+ }
211
+ }
212
+ return plain === 0 ? value : encoded + value.slice(plain)
213
+ }
214
+
215
+ const HEX: readonly string[] = Array.from({ length: 128 }, (_, code) =>
216
+ code.toString(16).toUpperCase().padStart(2, '0'),
217
+ )
218
+
219
+ /** RFC 6265 `cookie-octet`, `%` excluded (the escape character). */
220
+ function isCookieOctet(code: number): boolean {
221
+ return (
222
+ code === 0x21 ||
223
+ (code >= 0x23 && code <= 0x2b && code !== 0x25) ||
224
+ (code >= 0x2d && code <= 0x3a) ||
225
+ (code >= 0x3c && code <= 0x5b) ||
226
+ (code >= 0x5d && code <= 0x7e)
227
+ )
228
+ }
229
+
230
+ /** Whether `name` is an RFC 9110 token (a cookie name Znight can set). */
231
+ export function isCookieName(name: unknown): name is string {
232
+ if (typeof name !== 'string' || name === '') return false
233
+ for (let index = 0; index < name.length; index++) {
234
+ if (!isTokenChar(name.charCodeAt(index))) return false
235
+ }
236
+ return true
237
+ }
238
+
239
+ function isTokenChar(code: number): boolean {
240
+ // ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
241
+ return (
242
+ (code >= 0x30 && code <= 0x39) ||
243
+ (code >= 0x41 && code <= 0x5a) ||
244
+ (code >= 0x61 && code <= 0x7a) ||
245
+ code === 0x21 ||
246
+ (code >= 0x23 && code <= 0x27) ||
247
+ code === 0x2a ||
248
+ code === 0x2b ||
249
+ code === 0x2d ||
250
+ code === 0x2e ||
251
+ code === 0x5e ||
252
+ code === 0x5f ||
253
+ code === 0x60 ||
254
+ code === 0x7c ||
255
+ code === 0x7e
256
+ )
257
+ }
258
+
259
+ // -------------------------------------------------------------------------------------------------
260
+ // Response cookies
261
+ // -------------------------------------------------------------------------------------------------
262
+
263
+ /**
264
+ * Checks what a cookie says about itself, without plugin defaults: the name, the value type, the
265
+ * options (types, `maxAge`, `expires`, `domain`, `path`) and the size before signing. Run by
266
+ * `HttpResponse.withCookie()` / `withoutCookie()` at call time; the rules depending on the defaults
267
+ * (prefixes, `SameSite=None` and `partitioned` with `secure`), signing and the final size are checked
268
+ * when the response is serialised.
269
+ *
270
+ * @throws TypeError / RangeError naming the cookie.
271
+ */
272
+ export function checkCookie(name: string, value: string | undefined, options: CookieOptions | undefined): void {
273
+ checkName(name)
274
+ const checked = checkOptions(name, options)
275
+ if (value !== undefined) {
276
+ const encoded = encodeValue(name, value)
277
+ checkSize(name, encoded)
278
+ }
279
+ if (typeof checked.domain === 'string') normalizeDomain(name, checked.domain)
280
+ if (checked.path !== undefined) checkPath(name, checked.path)
281
+ }
282
+
283
+ /** A cookie resolved with the defaults: its `Set-Cookie` line and its jar key. */
284
+ export interface SerializedCookie {
285
+ /** `name` + domain + path: a later cookie with the same key replaces an earlier one. */
286
+ readonly key: string
287
+ readonly line: string
288
+ }
289
+
290
+ /**
291
+ * Serialises a response cookie with `defaults` (merged under the options): every rule of RFC 0006
292
+ * §4.3 is checked. `sign` turns the encoded value into its signed wire form (`signed: true`).
293
+ *
294
+ * @throws TypeError / RangeError naming the cookie.
295
+ */
296
+ export function serializeResponseCookie(
297
+ cookie: ResponseCookie,
298
+ defaults: CookieDefaults,
299
+ sign?: (name: string, encoded: string) => string,
300
+ ): SerializedCookie {
301
+ const { name, value } = cookie
302
+ checkName(name)
303
+ const options = checkOptions(name, cookie.options)
304
+ checkOptions(name, defaults, true)
305
+ const attributes = resolveAttributes(name, { ...applicableDefaults(name, defaults), ...definedOnly(options) })
306
+ let wire: string
307
+ let maxAge: number | undefined
308
+ let expires: string | undefined
309
+ if (value === undefined) {
310
+ wire = ''
311
+ maxAge = 0
312
+ expires = EPOCH
313
+ } else {
314
+ wire = encodeValue(name, value)
315
+ if (options.signed === true) {
316
+ if (sign === undefined) throw new TypeError(`Cannot sign the cookie "${name}": no signing key`)
317
+ wire = sign(name, wire)
318
+ }
319
+ if (options.maxAge !== undefined) maxAge = Math.max(options.maxAge, 0)
320
+ if (options.expires !== undefined) expires = options.expires.toUTCString()
321
+ }
322
+ checkSize(name, wire)
323
+ let line = `${name}=${wire}`
324
+ if (maxAge !== undefined) line += `; Max-Age=${maxAge}`
325
+ if (expires !== undefined) line += `; Expires=${expires}`
326
+ if (attributes.domain !== undefined) line += `; Domain=${attributes.domain}`
327
+ line += `; Path=${attributes.path}`
328
+ if (attributes.secure) line += '; Secure'
329
+ if (attributes.httpOnly) line += '; HttpOnly'
330
+ if (attributes.sameSite !== undefined) line += `; SameSite=${SAME_SITE[attributes.sameSite] as string}`
331
+ if (attributes.partitioned) line += '; Partitioned'
332
+ if (attributes.priority !== undefined && value !== undefined) {
333
+ line += `; Priority=${PRIORITY[attributes.priority] as string}`
334
+ }
335
+ return { key: cookieKey(name, attributes.domain, attributes.path), line }
336
+ }
337
+
338
+ /**
339
+ * The `Set-Cookie` line of a cookie with the secure defaults
340
+ * (`Path=/; Secure; HttpOnly; SameSite=Lax`, overridable per cookie), in the order
341
+ * `name=value; Max-Age; Expires; Domain; Path; Secure; HttpOnly; SameSite; Partitioned; Priority`.
342
+ * The value is percent-encoded (see {@link encodeCookieValue}). Signing (`signed: true`) needs the
343
+ * secrets of the plugin: use `ctx.setCookie()` or `HttpPlugin.signCookie()`.
344
+ *
345
+ * ```ts
346
+ * serializeCookie('theme', 'dark', { maxAge: 86_400, httpOnly: false })
347
+ * // theme=dark; Max-Age=86400; Path=/; Secure; SameSite=Lax
348
+ * ```
349
+ *
350
+ * @throws TypeError for an invalid name, value or attribute, a `SameSite=None` or partitioned cookie
351
+ * without `Secure`, a broken `__Secure-` / `__Host-` / `__Http-` / `__Host-Http-` prefix rule.
352
+ * @throws RangeError when `name=value` exceeds 4096 bytes, or `Domain` / `Path` 1024 bytes.
353
+ */
354
+ export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
355
+ return serializeResponseCookie({ name, value, options }, DEFAULT_COOKIE_OPTIONS).line
356
+ }
357
+
358
+ /**
359
+ * The `Set-Cookie` line clearing a cookie: `name=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`
360
+ * plus `Domain`, `Path`, `Secure`, `HttpOnly`, `SameSite` and `Partitioned` (secure defaults merged
361
+ * with `options`), so that a prefixed cookie is cleared with valid attributes.
362
+ */
363
+ export function serializeClearCookie(name: string, options: ClearCookieOptions = {}): string {
364
+ return serializeResponseCookie({ name, value: undefined, options }, DEFAULT_COOKIE_OPTIONS).line
365
+ }
366
+
367
+ /** The jar key of a cookie (RFC 6265: a cookie is identified by its name, domain and path). */
368
+ export function cookieKey(name: string, domain: string | undefined, path: string): string {
369
+ return `${name}\u0000${domain ?? ''}\u0000${path}`
370
+ }
371
+
372
+ interface Attributes {
373
+ readonly domain: string | undefined
374
+ readonly path: string
375
+ readonly secure: boolean
376
+ readonly httpOnly: boolean
377
+ readonly sameSite: 'strict' | 'lax' | 'none' | undefined
378
+ readonly partitioned: boolean
379
+ readonly priority: 'low' | 'medium' | 'high' | undefined
380
+ }
381
+
382
+ /**
383
+ * The defaults a cookie takes: all of them, but a `__Host-` cookie (host-only, `Path=/` by
384
+ * definition) never takes a default domain or path. Its own options are still checked.
385
+ */
386
+ function applicableDefaults(name: string, defaults: CookieDefaults): CookieDefaults {
387
+ if (!name.toLowerCase().startsWith('__host-')) return defaults
388
+ const { domain: _domain, path: _path, ...rest } = defaults
389
+ return rest
390
+ }
391
+
392
+ /** Attributes after the defaults, checked against the cross-attribute and prefix rules. */
393
+ function resolveAttributes(name: string, options: CookieOptions): Attributes {
394
+ // `null`: host-only, whatever the defaults
395
+ const domain = typeof options.domain === 'string' ? normalizeDomain(name, options.domain) : undefined
396
+ const path = options.path ?? '/'
397
+ checkPath(name, path)
398
+ const attributes: Attributes = {
399
+ domain,
400
+ path,
401
+ secure: options.secure === true,
402
+ httpOnly: options.httpOnly === true,
403
+ sameSite: options.sameSite,
404
+ partitioned: options.partitioned === true,
405
+ priority: options.priority,
406
+ }
407
+ if (attributes.sameSite === 'none' && !attributes.secure) {
408
+ throw invalid(name, 'SameSite=None requires secure: true (browsers reject it otherwise)')
409
+ }
410
+ if (attributes.partitioned && !attributes.secure) {
411
+ throw invalid(name, 'partitioned cookies require secure: true')
412
+ }
413
+ checkPrefix(name, attributes)
414
+ return attributes
415
+ }
416
+
417
+ /** `__Secure-`, `__Host-`, `__Http-`, `__Host-Http-` (case-insensitive, like browsers). */
418
+ function checkPrefix(name: string, attributes: Attributes): void {
419
+ const lower = name.toLowerCase()
420
+ const host = lower.startsWith('__host-')
421
+ const http = lower.startsWith('__http-') || lower.startsWith('__host-http-')
422
+ if (!host && !http && !lower.startsWith('__secure-')) return
423
+ const prefix = prefixName(host, http)
424
+ if (!attributes.secure) throw invalid(name, `the ${prefix} prefix requires secure: true`)
425
+ if (http && !attributes.httpOnly) throw invalid(name, `the ${prefix} prefix requires httpOnly: true`)
426
+ if (host) {
427
+ if (attributes.domain !== undefined) throw invalid(name, `the ${prefix} prefix forbids a domain`)
428
+ if (attributes.path !== '/') throw invalid(name, `the ${prefix} prefix requires path: '/'`)
429
+ }
430
+ }
431
+
432
+ function checkName(name: string): void {
433
+ if (!isCookieName(name)) {
434
+ throw new TypeError(
435
+ `Invalid cookie name ${describe(name)}: expected a token (letters, digits and ! # $ % & ' * + - . ^ _ \` | ~)`,
436
+ )
437
+ }
438
+ }
439
+
440
+ function encodeValue(name: string, value: string): string {
441
+ if (typeof value !== 'string') throw invalid(name, `the value must be a string (received ${describe(value)})`)
442
+ if (!value.isWellFormed()) throw invalid(name, 'the value contains a lone surrogate')
443
+ return encodeCookieValue(value)
444
+ }
445
+
446
+ function checkSize(name: string, wire: string): void {
447
+ const size = byteLength(name) + 1 + wire.length // the wire value is ASCII
448
+ if (size > MAX_COOKIE_SIZE) {
449
+ throw new RangeError(
450
+ `Invalid cookie "${name}": name=value is ${size} bytes on the wire, above the limit of ${MAX_COOKIE_SIZE} (browsers drop it)`,
451
+ )
452
+ }
453
+ }
454
+
455
+ /** Checks the type of every option (and rejects unknown keys); returns the options. */
456
+ function checkOptions(name: string, options: CookieOptions | undefined, defaults = false): CookieOptions {
457
+ if (options === undefined) return {}
458
+ const what = defaults ? 'cookies.defaults' : 'options'
459
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
460
+ throw invalid(name, `${what} must be an object (received ${describe(options)})`)
461
+ }
462
+ for (const key of Object.keys(options)) {
463
+ if (!OPTION_KEYS.has(key)) throw invalid(name, `unknown option "${key}"`)
464
+ }
465
+ const { maxAge, expires, domain, path, secure, httpOnly, sameSite, partitioned, priority, signed } = options
466
+ if (maxAge !== undefined && !(typeof maxAge === 'number' && Number.isSafeInteger(maxAge))) {
467
+ throw invalid(name, `maxAge must be an integer number of seconds (received ${describe(maxAge)})`)
468
+ }
469
+ if (expires !== undefined && !(expires instanceof Date && !Number.isNaN(expires.getTime()))) {
470
+ throw invalid(name, `expires must be a valid Date (received ${describe(expires)})`)
471
+ }
472
+ if (expires !== undefined) {
473
+ const year = expires.getUTCFullYear()
474
+ if (year < MIN_EXPIRES_YEAR || year > MAX_EXPIRES_YEAR) {
475
+ throw invalid(
476
+ name,
477
+ `expires must be in the years ${MIN_EXPIRES_YEAR} to ${MAX_EXPIRES_YEAR} (received ${expires.toISOString()}): browsers ignore other dates; use maxAge for a long lifetime (browsers cap it at 400 days)`,
478
+ )
479
+ }
480
+ }
481
+ if (domain !== undefined && domain !== null && typeof domain !== 'string') {
482
+ throw invalid(name, `domain must be a string or null (received ${describe(domain)})`)
483
+ }
484
+ if (path !== undefined && typeof path !== 'string') {
485
+ throw invalid(name, `path must be a string (received ${describe(path)})`)
486
+ }
487
+ for (const [key, flag] of [
488
+ ['secure', secure],
489
+ ['httpOnly', httpOnly],
490
+ ['partitioned', partitioned],
491
+ ['signed', signed],
492
+ ] as const) {
493
+ if (flag !== undefined && typeof flag !== 'boolean') {
494
+ throw invalid(name, `${key} must be a boolean (received ${describe(flag)})`)
495
+ }
496
+ }
497
+ if (sameSite !== undefined && !Object.hasOwn(SAME_SITE, sameSite)) {
498
+ throw invalid(name, `sameSite must be 'strict', 'lax' or 'none' (received ${describe(sameSite)})`)
499
+ }
500
+ if (priority !== undefined && !Object.hasOwn(PRIORITY, priority)) {
501
+ throw invalid(name, `priority must be 'low', 'medium' or 'high' (received ${describe(priority)})`)
502
+ }
503
+ return options
504
+ }
505
+
506
+ /** Lower-cased, one leading `.` removed; an ASCII host name of at most 253 characters. */
507
+ function normalizeDomain(name: string, domain: string): string {
508
+ const normalized = (domain.startsWith('.') ? domain.slice(1) : domain).toLowerCase()
509
+ if (normalized.length > MAX_COOKIE_ATTRIBUTE_SIZE) {
510
+ throw new RangeError(`Invalid cookie "${name}": the domain exceeds ${MAX_COOKIE_ATTRIBUTE_SIZE} bytes`)
511
+ }
512
+ if (!isHostName(normalized)) {
513
+ throw invalid(
514
+ name,
515
+ `invalid domain ${describe(domain)}: expected an ASCII host name (letters, digits, -, dot-separated labels; IDNs in punycode)`,
516
+ )
517
+ }
518
+ return normalized
519
+ }
520
+
521
+ function isHostName(domain: string): boolean {
522
+ if (domain === '' || domain.length > MAX_DOMAIN_LENGTH) return false
523
+ let label = 0
524
+ for (let index = 0; index < domain.length; index++) {
525
+ const code = domain.charCodeAt(index)
526
+ if (code === 0x2e) {
527
+ if (label === 0) return false
528
+ label = 0
529
+ continue
530
+ }
531
+ const valid = (code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x7a) || code === 0x2d
532
+ if (!valid) return false
533
+ label++
534
+ }
535
+ return label > 0
536
+ }
537
+
538
+ /** Starts with `/`, `%x20-7E` except `;`, at most 1024 bytes. */
539
+ function checkPath(name: string, path: string): void {
540
+ if (path.length > MAX_COOKIE_ATTRIBUTE_SIZE) {
541
+ throw new RangeError(`Invalid cookie "${name}": the path exceeds ${MAX_COOKIE_ATTRIBUTE_SIZE} bytes`)
542
+ }
543
+ let valid = path.startsWith('/')
544
+ for (let index = 0; valid && index < path.length; index++) {
545
+ const code = path.charCodeAt(index)
546
+ valid = code >= 0x20 && code <= 0x7e && code !== 0x3b
547
+ }
548
+ if (!valid) {
549
+ throw invalid(name, `invalid path ${describe(path)}: expected "/..." made of visible ASCII characters without ";"`)
550
+ }
551
+ }
552
+
553
+ /** The options without `undefined` values (they would erase defaults when spread). */
554
+ function definedOnly(options: CookieOptions): CookieOptions {
555
+ const result: Record<string, unknown> = {}
556
+ for (const key of Object.keys(options)) {
557
+ const value = (options as Record<string, unknown>)[key]
558
+ if (value !== undefined) result[key] = value
559
+ }
560
+ return result as CookieOptions
561
+ }
562
+
563
+ // -------------------------------------------------------------------------------------------------
564
+ // Set-Cookie parsing
565
+ // -------------------------------------------------------------------------------------------------
566
+
567
+ /**
568
+ * Parses a `Set-Cookie` line (attribute names are case-insensitive; the value is decoded like a
569
+ * request cookie). Used by `inject()` to fill `response.cookies`.
570
+ *
571
+ * @throws TypeError when the line has no `name=value` pair.
572
+ */
573
+ export function parseSetCookie(line: string): SetCookieInfo {
574
+ const parts = String(line).split(';')
575
+ const pair = parts[0] as string
576
+ const equals = pair.indexOf('=')
577
+ const name = equals === -1 ? '' : trim(pair, 0, equals)
578
+ if (name === '') throw new TypeError(`Invalid Set-Cookie line ${describe(line)}: missing name=value`)
579
+ const info: {
580
+ -readonly [K in keyof SetCookieInfo]: SetCookieInfo[K]
581
+ } = {
582
+ name,
583
+ value: decodeCookieValue(unquote(trim(pair, equals + 1, pair.length))),
584
+ raw: line,
585
+ secure: false,
586
+ httpOnly: false,
587
+ partitioned: false,
588
+ }
589
+ for (const part of parts.slice(1)) {
590
+ const separator = part.indexOf('=')
591
+ const key = trim(part, 0, separator === -1 ? part.length : separator).toLowerCase()
592
+ const value = separator === -1 ? '' : trim(part, separator + 1, part.length)
593
+ switch (key) {
594
+ case 'max-age': {
595
+ const seconds = /^-?\d+$/.test(value) ? Number(value) : Number.NaN
596
+ if (Number.isSafeInteger(seconds)) info.maxAge = seconds
597
+ break
598
+ }
599
+ case 'expires': {
600
+ const date = new Date(value)
601
+ if (!Number.isNaN(date.getTime())) info.expires = date
602
+ break
603
+ }
604
+ case 'domain':
605
+ if (value !== '') info.domain = (value.startsWith('.') ? value.slice(1) : value).toLowerCase()
606
+ break
607
+ case 'path':
608
+ if (value.startsWith('/')) info.path = value
609
+ break
610
+ case 'secure':
611
+ info.secure = true
612
+ break
613
+ case 'httponly':
614
+ info.httpOnly = true
615
+ break
616
+ case 'partitioned':
617
+ info.partitioned = true
618
+ break
619
+ case 'samesite': {
620
+ const sameSite = value.toLowerCase()
621
+ if (sameSite === 'strict' || sameSite === 'lax' || sameSite === 'none') info.sameSite = sameSite
622
+ break
623
+ }
624
+ case 'priority': {
625
+ const priority = value.toLowerCase()
626
+ if (priority === 'low' || priority === 'medium' || priority === 'high') info.priority = priority
627
+ break
628
+ }
629
+ default:
630
+ break
631
+ }
632
+ }
633
+ return Object.freeze(info)
634
+ }
635
+
636
+ /** The `set-cookie` lines of a response parsed by name (the last one wins), prototype-less and frozen. */
637
+ export function parseSetCookies(
638
+ lines: readonly string[] | string | undefined,
639
+ ): Readonly<Record<string, SetCookieInfo>> {
640
+ const result = Object.create(null) as Record<string, SetCookieInfo>
641
+ const list = typeof lines === 'string' ? [lines] : (lines ?? [])
642
+ for (const line of list) {
643
+ try {
644
+ const info = parseSetCookie(line)
645
+ result[info.name] = info
646
+ } catch {
647
+ // not a cookie line: ignored
648
+ }
649
+ }
650
+ return Object.freeze(result)
651
+ }
652
+
653
+ // -------------------------------------------------------------------------------------------------
654
+ // Jar
655
+ // -------------------------------------------------------------------------------------------------
656
+
657
+ /**
658
+ * Cookies of a response in insertion order, keyed by name, domain and path: a later cookie with
659
+ * the same key replaces the earlier one in place (set then clear gives one clearing cookie).
660
+ */
661
+ export class CookieJar<T> {
662
+ readonly #entries = new Map<string, T>()
663
+
664
+ /** Adds or replaces (in place) the entry of `key`. */
665
+ set(key: string, entry: T): void {
666
+ this.#entries.set(key, entry)
667
+ }
668
+
669
+ get size(): number {
670
+ return this.#entries.size
671
+ }
672
+
673
+ values(): T[] {
674
+ return [...this.#entries.values()]
675
+ }
676
+
677
+ entries(): [string, T][] {
678
+ return [...this.#entries.entries()]
679
+ }
680
+ }
681
+
682
+ /**
683
+ * The jar key of a cookie as given (`HttpResponse.withCookie()`, before the defaults are known):
684
+ * an absent domain or path is keyed as "default", a `null` domain as host-only.
685
+ */
686
+ export function givenCookieKey(name: string, options: CookieOptions): string {
687
+ const given = options.domain
688
+ let domain: string
689
+ if (given === undefined) domain = '\u0001'
690
+ else if (given === null) domain = ''
691
+ else domain = (given.startsWith('.') ? given.slice(1) : given).toLowerCase()
692
+ return `${name}\u0000${domain}\u0000${options.path ?? '\u0001'}`
693
+ }
694
+
695
+ // -------------------------------------------------------------------------------------------------
696
+ // Helpers
697
+ // -------------------------------------------------------------------------------------------------
698
+
699
+ function prefixName(host: boolean, http: boolean): string {
700
+ if (host) return http ? '__Host-Http-' : '__Host-'
701
+ return http ? '__Http-' : '__Secure-'
702
+ }
703
+
704
+ function invalid(name: string, reason: string): TypeError {
705
+ return new TypeError(`Invalid cookie "${name}": ${reason}`)
706
+ }
707
+
708
+ function byteLength(text: string): number {
709
+ let size = 0
710
+ for (let index = 0; index < text.length; index++) {
711
+ const code = text.charCodeAt(index)
712
+ if (code < 0x80) size += 1
713
+ else if (code < 0x800 || (code >= 0xd800 && code <= 0xdfff))
714
+ size += 2 // a surrogate pair: 4 bytes
715
+ else size += 3
716
+ }
717
+ return size
718
+ }
719
+
720
+ function describe(value: unknown): string {
721
+ if (typeof value === 'string') return JSON.stringify(value.length > 64 ? `${value.slice(0, 64)}…` : value)
722
+ if (value instanceof Date) return 'an invalid Date'
723
+ if (value === null) return 'null'
724
+ if (typeof value === 'object') return 'an object'
725
+ return typeof value === 'bigint' ? `${value}n` : String(value)
726
+ }