@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.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- 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
|
+
}
|