@znight/http 0.0.0-stage → 0.1.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 (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/proxy.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Trusted proxies (RFC 0001 §5.6): `ctx.ip` and `ctx.protocol` behind load balancers, computed the
3
+ * same way on every adapter from the socket peer (`AdapterRequest.remoteAddress`) and the
4
+ * `x-forwarded-for` / `x-forwarded-proto` headers. The `Forwarded` header (RFC 7239) is not read.
5
+ */
6
+ import { invalidOption } from './option-error.ts'
7
+
8
+ /**
9
+ * The `trustProxy` option: `false` (default: the headers are ignored), a number of trusted hops
10
+ * (`1`: one load balancer in front of the server), or the addresses of the trusted proxies (IPv4 /
11
+ * IPv6 addresses, CIDR ranges and the aliases `'loopback'`, `'private'`, `'linklocal'`).
12
+ */
13
+ export type TrustProxy = false | number | readonly string[]
14
+
15
+ /** Client address and protocol of a request (internal). */
16
+ export interface ClientInfo {
17
+ readonly ip: string | undefined
18
+ readonly protocol: 'http' | 'https'
19
+ }
20
+
21
+ /** A resolved `trustProxy` policy (internal). */
22
+ export interface TrustPolicy {
23
+ /** `ctx.ip` and `ctx.protocol` of a request. */
24
+ resolve(
25
+ remoteAddress: string | undefined,
26
+ headers: Readonly<Record<string, string | undefined>>,
27
+ encrypted: boolean,
28
+ ): ClientInfo
29
+ }
30
+
31
+ /** A parsed address: IPv4 (4 bytes, IPv4-mapped IPv6 included) or IPv6 (16 bytes). */
32
+ interface Address {
33
+ readonly bytes: readonly number[]
34
+ /** Normalised text (`::ffff:10.0.0.1` → `10.0.0.1`). */
35
+ readonly text: string
36
+ }
37
+
38
+ interface Range {
39
+ readonly bytes: readonly number[]
40
+ readonly prefix: number
41
+ }
42
+
43
+ const ALIASES: Readonly<Record<string, readonly string[]>> = Object.freeze({
44
+ loopback: ['127.0.0.0/8', '::1/128'],
45
+ private: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7'],
46
+ linklocal: ['169.254.0.0/16', 'fe80::/10'],
47
+ })
48
+
49
+ const TRUST_HINTS: readonly string[] = [
50
+ 'trustProxy: 1 trusts one proxy (the socket peer); trustProxy: ["loopback", "10.0.0.0/8"] trusts these addresses',
51
+ 'Trusting every hop would let any client choose its ctx.ip with an x-forwarded-for header',
52
+ ]
53
+
54
+ /** Validates the `trustProxy` option (ZNIGHT005); `undefined` when proxies are not trusted. */
55
+ export function resolveTrustProxy(value: TrustProxy | undefined): TrustPolicy | undefined {
56
+ if (value === undefined || value === false) return undefined
57
+ if (typeof value === 'number') {
58
+ if (!Number.isSafeInteger(value) || value < 1) {
59
+ throw invalidOption(
60
+ 'trustProxy',
61
+ 'false, a number of hops (an integer >= 1) or a list of addresses',
62
+ value,
63
+ undefined,
64
+ TRUST_HINTS,
65
+ )
66
+ }
67
+ return hopPolicy(value)
68
+ }
69
+ if (!Array.isArray(value)) {
70
+ throw invalidOption(
71
+ 'trustProxy',
72
+ 'false, a number of hops (an integer >= 1) or a list of addresses',
73
+ value,
74
+ undefined,
75
+ TRUST_HINTS,
76
+ )
77
+ }
78
+ const ranges: Range[] = []
79
+ for (const entry of value) {
80
+ if (typeof entry !== 'string') {
81
+ throw invalidOption('trustProxy', 'a list of addresses, CIDR ranges or aliases', value, undefined, TRUST_HINTS)
82
+ }
83
+ const expanded = ALIASES[entry] ?? [entry]
84
+ for (const item of expanded) {
85
+ const range = parseRange(item)
86
+ if (range === undefined) {
87
+ throw invalidOption(
88
+ 'trustProxy',
89
+ `a list of IP addresses, CIDR ranges ("10.0.0.0/8") or aliases ("loopback", "private", "linklocal"); "${entry}" is invalid`,
90
+ value,
91
+ undefined,
92
+ TRUST_HINTS,
93
+ )
94
+ }
95
+ ranges.push(range)
96
+ }
97
+ }
98
+ return listPolicy(ranges)
99
+ }
100
+
101
+ function hopPolicy(hops: number): TrustPolicy {
102
+ return {
103
+ resolve(remoteAddress, headers, encrypted) {
104
+ const socket = remoteAddress === undefined ? undefined : parseAddress(remoteAddress)
105
+ if (socket === undefined) return { ip: remoteAddress, protocol: encrypted ? 'https' : 'http' }
106
+ let client = socket
107
+ const chain = forwardedFor(headers)
108
+ for (let index = 0; index < hops && index < chain.length; index++) {
109
+ const next = parseAddress(chain[index] as string)
110
+ if (next === undefined) break // a malformed entry stops the walk at the last valid address
111
+ client = next
112
+ }
113
+ return { ip: client.text, protocol: forwardedProto(headers, encrypted) }
114
+ },
115
+ }
116
+ }
117
+
118
+ function listPolicy(ranges: readonly Range[]): TrustPolicy {
119
+ const trusted = (address: Address): boolean => ranges.some(range => inRange(address, range))
120
+ return {
121
+ resolve(remoteAddress, headers, encrypted) {
122
+ const socket = remoteAddress === undefined ? undefined : parseAddress(remoteAddress)
123
+ if (socket === undefined) return { ip: remoteAddress, protocol: encrypted ? 'https' : 'http' }
124
+ if (!trusted(socket)) return { ip: socket.text, protocol: encrypted ? 'https' : 'http' }
125
+ let client = socket
126
+ for (const entry of forwardedFor(headers)) {
127
+ const next = parseAddress(entry)
128
+ if (next === undefined) break
129
+ client = next
130
+ if (!trusted(next)) break
131
+ }
132
+ return { ip: client.text, protocol: forwardedProto(headers, encrypted) }
133
+ },
134
+ }
135
+ }
136
+
137
+ /** `x-forwarded-for` entries from right (nearest proxy) to left (client). */
138
+ function forwardedFor(headers: Readonly<Record<string, string | undefined>>): string[] {
139
+ const value = headers['x-forwarded-for']
140
+ if (value === undefined) return []
141
+ return value
142
+ .split(',')
143
+ .map(entry => entry.trim())
144
+ .reverse()
145
+ }
146
+
147
+ /** The leftmost `x-forwarded-proto` value when it is `http` or `https`, else the connection protocol. */
148
+ function forwardedProto(headers: Readonly<Record<string, string | undefined>>, encrypted: boolean): 'http' | 'https' {
149
+ const value = headers['x-forwarded-proto']?.split(',')[0]?.trim().toLowerCase()
150
+ if (value === 'http' || value === 'https') return value
151
+ return encrypted ? 'https' : 'http'
152
+ }
153
+
154
+ function inRange(address: Address, range: Range): boolean {
155
+ if (address.bytes.length !== range.bytes.length) return false
156
+ let bits = range.prefix
157
+ for (let index = 0; index < range.bytes.length && bits > 0; index++) {
158
+ const mask = bits >= 8 ? 0xff : (0xff << (8 - bits)) & 0xff
159
+ if (((address.bytes[index] as number) & mask) !== ((range.bytes[index] as number) & mask)) return false
160
+ bits -= 8
161
+ }
162
+ return true
163
+ }
164
+
165
+ /** `10.0.0.0/8`, `fc00::/7` or a single address. */
166
+ function parseRange(value: string): Range | undefined {
167
+ const slash = value.indexOf('/')
168
+ const address = parseAddress(slash === -1 ? value : value.slice(0, slash))
169
+ if (address === undefined) return undefined
170
+ const bits = address.bytes.length * 8
171
+ if (slash === -1) return { bytes: address.bytes, prefix: bits }
172
+ const prefixText = value.slice(slash + 1)
173
+ if (!/^\d{1,3}$/.test(prefixText)) return undefined
174
+ const prefix = Number(prefixText)
175
+ return prefix > bits ? undefined : { bytes: address.bytes, prefix }
176
+ }
177
+
178
+ /** Parses an IPv4 or IPv6 address (zone ids ignored); IPv4-mapped IPv6 addresses become IPv4. */
179
+ export function parseAddress(value: string): Address | undefined {
180
+ const text = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value
181
+ const v4 = parseIPv4(text)
182
+ if (v4 !== undefined) return { bytes: v4, text }
183
+ const zone = text.indexOf('%')
184
+ const v6 = parseIPv6(zone === -1 ? text : text.slice(0, zone))
185
+ if (v6 === undefined) return undefined
186
+ if (v6.slice(0, 10).every(byte => byte === 0) && v6[10] === 0xff && v6[11] === 0xff) {
187
+ const bytes = v6.slice(12)
188
+ return { bytes, text: bytes.join('.') }
189
+ }
190
+ return { bytes: v6, text: text.toLowerCase() }
191
+ }
192
+
193
+ function parseIPv4(text: string): number[] | undefined {
194
+ const parts = text.split('.')
195
+ if (parts.length !== 4) return undefined
196
+ const bytes: number[] = []
197
+ for (const part of parts) {
198
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(part)) return undefined
199
+ const byte = Number(part)
200
+ if (byte > 255) return undefined
201
+ bytes.push(byte)
202
+ }
203
+ return bytes
204
+ }
205
+
206
+ function parseIPv6(text: string): number[] | undefined {
207
+ if (text === '' || text.length > 45) return undefined
208
+ const halves = text.split('::')
209
+ if (halves.length > 2) return undefined
210
+ const groups = (part: string): number[] | undefined => {
211
+ if (part === '') return []
212
+ const result: number[] = []
213
+ const items = part.split(':')
214
+ for (let index = 0; index < items.length; index++) {
215
+ const item = items[index] as string
216
+ if (index === items.length - 1 && item.includes('.')) {
217
+ const v4 = parseIPv4(item)
218
+ if (v4 === undefined) return undefined
219
+ result.push(((v4[0] as number) << 8) | (v4[1] as number), ((v4[2] as number) << 8) | (v4[3] as number))
220
+ } else {
221
+ if (!/^[0-9A-Fa-f]{1,4}$/.test(item)) return undefined
222
+ result.push(Number.parseInt(item, 16))
223
+ }
224
+ }
225
+ return result
226
+ }
227
+ const head = groups(halves[0] as string)
228
+ const tail = halves.length === 2 ? groups(halves[1] as string) : []
229
+ if (head === undefined || tail === undefined) return undefined
230
+ const missing = 8 - head.length - tail.length
231
+ if (halves.length === 2 ? missing < 1 : missing !== 0) return undefined
232
+ const words = [...head, ...new Array<number>(missing).fill(0), ...tail]
233
+ const bytes: number[] = []
234
+ for (const word of words) bytes.push(word >> 8, word & 0xff)
235
+ return bytes
236
+ }
package/src/query.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { emptyRecord } from './utils.ts'
2
+
3
+ /** Parsed query string (or `application/x-www-form-urlencoded` body): repeated keys become arrays. */
4
+ export type QueryRecord = Record<string, string | string[]>
5
+
6
+ /**
7
+ * Parses a query string (with or without the leading `?`) with `URLSearchParams` semantics (`+` is
8
+ * a space, malformed `%` sequences are kept as is, invalid UTF-8 becomes U+FFFD). A key present
9
+ * once maps to a string, a repeated key to an array of strings, in order. No nesting (`a[b]=1` is
10
+ * the key `a[b]`). The returned object has no prototype.
11
+ *
12
+ * `parseQuery('?tag=a&tag=b&page=2')` → `{ tag: ['a', 'b'], page: '2' }`
13
+ */
14
+ export function parseQuery(query: string): QueryRecord {
15
+ const result = emptyRecord<string | string[]>()
16
+ if (query === '' || query === '?') return result
17
+ for (const [key, value] of new URLSearchParams(query)) {
18
+ const existing = result[key]
19
+ if (existing === undefined) result[key] = value
20
+ else if (typeof existing === 'string') result[key] = [existing, value]
21
+ else existing.push(value)
22
+ }
23
+ return result
24
+ }
25
+
26
+ /** Splits a request target (`/users?page=2`) into its raw path and raw query (without `?`). */
27
+ export function splitUrl(url: string): { readonly path: string; readonly query: string } {
28
+ const index = url.indexOf('?')
29
+ return index === -1 ? { path: url, query: '' } : { path: url.slice(0, index), query: url.slice(index + 1) }
30
+ }
@@ -0,0 +1,240 @@
1
+ import type { HeaderValue } from './exceptions.ts'
2
+
3
+ import {
4
+ type ClearCookieOptions,
5
+ type CookieOptions,
6
+ checkCookie,
7
+ givenCookieKey,
8
+ type ResponseCookie,
9
+ } from './cookies.ts'
10
+ import { type ContentDispositionType, contentDisposition, FileBody, type FileResponseOptions } from './files.ts'
11
+ import { contentTypeFor, OCTET_STREAM } from './mime.ts'
12
+ import { SseBody, type SseOptions, type SseSource } from './sse.ts'
13
+ import { isValidStatus } from './status.ts'
14
+ import { isStreamSource, type StreamSource, toByteStream } from './stream.ts'
15
+
16
+ /** Options of {@link HttpResponse.stream}. */
17
+ export interface StreamResponseOptions {
18
+ /** Media type. Default: from the extension of `filename`, else `application/octet-stream`. */
19
+ readonly contentType?: string
20
+ /** Name proposed to the client (`Content-Disposition: attachment` unless `disposition` says otherwise). */
21
+ readonly filename?: string
22
+ /** `attachment` or `inline`. Default: `attachment` when `filename` is set, otherwise no header. */
23
+ readonly disposition?: ContentDispositionType
24
+ }
25
+
26
+ /** Options of {@link HttpResponse}. */
27
+ export interface HttpResponseInit {
28
+ /** Status code. Default: the route default (`@HttpCode`, 201 for POST, 204 without body, 200). */
29
+ readonly status?: number
30
+ /** Response headers (names are case-insensitive; arrays send the header several times). */
31
+ readonly headers?: Readonly<Record<string, HeaderValue>>
32
+ /** Cookies to set (see {@link HttpResponse.withCookie}), in order. */
33
+ readonly cookies?: readonly ResponseCookie[]
34
+ }
35
+
36
+ /** Redirect statuses accepted by {@link HttpResponse.redirect}. */
37
+ export type RedirectStatus = 301 | 302 | 303 | 307 | 308
38
+
39
+ /**
40
+ * A handler result carrying a status and headers next to the body. The body is serialised with the
41
+ * usual rules (objects → JSON, strings → text, bytes and streams → octet-stream).
42
+ *
43
+ * ```ts
44
+ * @Post('/')
45
+ * async create(@Body(CreateUser) input: CreateUser): Promise<HttpResponse<User>> {
46
+ * const user = await this.users.create(input)
47
+ * return HttpResponse.created(user, `/users/${user.id}`)
48
+ * }
49
+ * ```
50
+ *
51
+ * Status precedence: `@HttpCode` > `HttpResponse.status` > route default.
52
+ */
53
+ export class HttpResponse<T = unknown> {
54
+ readonly body: T
55
+ readonly status: number | undefined
56
+ readonly headers: Readonly<Record<string, HeaderValue>>
57
+ /** Cookies set by this response (`withCookie` / `withoutCookie`), in order; they win over `ctx.setCookie()`. */
58
+ readonly cookies: readonly ResponseCookie[]
59
+
60
+ constructor(body: T, init: HttpResponseInit = {}) {
61
+ if (init.status !== undefined && !isValidStatus(init.status)) {
62
+ throw new RangeError(`Invalid HTTP status ${String(init.status)} (expected an integer between 100 and 599)`)
63
+ }
64
+ this.body = body
65
+ this.status = init.status
66
+ this.headers = init.headers ?? {}
67
+ this.cookies = init.cookies === undefined ? NO_COOKIES : Object.freeze([...init.cookies])
68
+ }
69
+
70
+ /** `200 OK` with `body`. */
71
+ static ok<T>(body: T, headers?: Readonly<Record<string, HeaderValue>>): HttpResponse<T> {
72
+ return new HttpResponse(body, headers === undefined ? { status: 200 } : { status: 200, headers })
73
+ }
74
+
75
+ /**
76
+ * `201 Created`, with a `location` header when given. With a body, the response is typed with it
77
+ * (`Promise<HttpResponse<BookResponse>>` type-checks).
78
+ */
79
+ static created(): HttpResponse<undefined>
80
+ static created<T>(body: T, location?: string): HttpResponse<T>
81
+ static created<T>(body?: T, location?: string): HttpResponse<T | undefined> {
82
+ return new HttpResponse(body, location === undefined ? { status: 201 } : { status: 201, headers: { location } })
83
+ }
84
+
85
+ /** `202 Accepted`, typed with its body when there is one. */
86
+ static accepted(): HttpResponse<undefined>
87
+ static accepted<T>(body: T): HttpResponse<T>
88
+ static accepted<T>(body?: T): HttpResponse<T | undefined> {
89
+ return new HttpResponse(body, { status: 202 })
90
+ }
91
+
92
+ /** `204 No Content`. */
93
+ static noContent(): HttpResponse<undefined> {
94
+ return new HttpResponse(undefined, { status: 204 })
95
+ }
96
+
97
+ /** A redirect (`302 Found` by default) to `location`. */
98
+ static redirect(location: string, status: RedirectStatus = 302): HttpResponse<undefined> {
99
+ return new HttpResponse(undefined, { status, headers: { location } })
100
+ }
101
+
102
+ /**
103
+ * Sends a file from disk (`string` path or `file:` URL) or a `Blob` / `File`, streamed with
104
+ * `Content-Length`, `Content-Type` (from the extension, see `contentTypeFor`), `Last-Modified`,
105
+ * `ETag` and `Accept-Ranges`. Conditional requests answer 304 / 412 and single byte ranges 206
106
+ * (416 when unsatisfiable). A missing file answers 404.
107
+ *
108
+ * ```ts
109
+ * @Get('/reports/:name')
110
+ * download(@Param('name') name: string) {
111
+ * // `root` confines the path: '../../etc/passwd' answers 404
112
+ * return HttpResponse.file(name, { root: REPORTS_DIR, filename: `rapport ${name}` })
113
+ * }
114
+ * ```
115
+ *
116
+ * Never pass a path built from the request without `root`.
117
+ */
118
+ static file(file: string | URL | Blob, options?: FileResponseOptions): HttpResponse<FileBody> {
119
+ return new HttpResponse(new FileBody(file, options))
120
+ }
121
+
122
+ /**
123
+ * Streams a Web `ReadableStream`, a Node.js `Readable` or an async iterable (async generator) of
124
+ * `Uint8Array` / `string` chunks. The source is read as the client receives the data
125
+ * (backpressure) and cancelled when the client disconnects; it runs in the request scope.
126
+ *
127
+ * ```ts
128
+ * @Get('/export.csv')
129
+ * export() {
130
+ * return HttpResponse.stream(this.rows.csvLines(), { filename: 'export.csv' }) // text/csv; charset=utf-8
131
+ * }
132
+ * ```
133
+ */
134
+ static stream(source: StreamSource, options: StreamResponseOptions = {}): HttpResponse<ReadableStream<Uint8Array>> {
135
+ if (!isStreamSource(source)) {
136
+ throw new TypeError(
137
+ 'HttpResponse.stream() expects a ReadableStream, a Node.js Readable or an async iterable of Uint8Array / string chunks',
138
+ )
139
+ }
140
+ const headers: Record<string, HeaderValue> = {
141
+ 'content-type':
142
+ options.contentType ??
143
+ (options.filename === undefined ? undefined : contentTypeFor(options.filename)) ??
144
+ OCTET_STREAM,
145
+ }
146
+ const disposition = options.disposition ?? (options.filename === undefined ? undefined : 'attachment')
147
+ if (disposition !== undefined) headers['content-disposition'] = contentDisposition(options.filename, disposition)
148
+ return new HttpResponse(toByteStream(source), { headers })
149
+ }
150
+
151
+ /**
152
+ * An event stream (Server-Sent Events) on any route and any method: `200` unless `@HttpCode` or
153
+ * {@link withStatus} say otherwise (`EventSource` requires 200; `fetch()` clients read any status),
154
+ * even on `POST` routes. Same source, framing and lifecycle as an `@Sse()` route.
155
+ *
156
+ * ```ts
157
+ * @Post('/:id/assistant')
158
+ * assistant(@Param('id') id: string, @Body(Prompt) prompt: Prompt, @Ctx() ctx: HttpContext) {
159
+ * return HttpResponse.sse(this.llm.complete(id, prompt, { signal: ctx.signal }))
160
+ * }
161
+ * ```
162
+ */
163
+ static sse(source: SseSource, options?: SseOptions): HttpResponse<SseBody> {
164
+ return new HttpResponse(new SseBody(source, options))
165
+ }
166
+
167
+ /** A copy of this response with `name` set to `value`. */
168
+ withHeader(name: string, value: HeaderValue): HttpResponse<T> {
169
+ return this.#copy({ headers: { ...this.headers, [name.toLowerCase()]: value } })
170
+ }
171
+
172
+ /** A copy of this response with another status. */
173
+ withStatus(status: number): HttpResponse<T> {
174
+ return this.#copy({ status })
175
+ }
176
+
177
+ /**
178
+ * A copy of this response setting a cookie, with the secure defaults (`Path=/; Secure; HttpOnly;
179
+ * SameSite=Lax`, see the plugin option `cookies.defaults`). It replaces a cookie with the same name,
180
+ * domain and path set by `ctx.setCookie()` or an earlier `withCookie()`.
181
+ *
182
+ * ```ts
183
+ * return HttpResponse.ok({ id: user.id }).withCookie('__Host-uid', user.id, { signed: true, maxAge: 8 * 3600 })
184
+ * ```
185
+ *
186
+ * The name, the value, the lifetime, the domain, the path and the size are checked now; the rules
187
+ * depending on the plugin (`secure` default with a prefix or `SameSite=None`, signing, the final
188
+ * size) when the response is sent: a violation then answers a generic 500, logged as
189
+ * `Invalid cookie "name": …`.
190
+ *
191
+ * @throws TypeError / RangeError for an invalid cookie.
192
+ */
193
+ withCookie(name: string, value: string, options: CookieOptions = {}): HttpResponse<T> {
194
+ checkCookie(name, value, options)
195
+ return this.#copy({ cookies: withCookie(this.cookies, { name, value, options: Object.freeze({ ...options }) }) })
196
+ }
197
+
198
+ /** A copy of this response clearing a cookie (see `ctx.clearCookie()`). */
199
+ withoutCookie(name: string, options: ClearCookieOptions = {}): HttpResponse<T> {
200
+ if (options === null || typeof options !== 'object') {
201
+ throw new TypeError(`Invalid cookie ${JSON.stringify(String(name))}: options must be an object`)
202
+ }
203
+ const { maxAge: _maxAge, expires: _expires, signed: _signed, ...clear } = options as CookieOptions
204
+ checkCookie(name, undefined, clear)
205
+ return this.#copy({ cookies: withCookie(this.cookies, { name, value: undefined, options: Object.freeze(clear) }) })
206
+ }
207
+
208
+ #copy(changes: HttpResponseInit): HttpResponse<T> {
209
+ const status = changes.status ?? this.status
210
+ return new HttpResponse(this.body, {
211
+ ...(status === undefined ? {} : { status }),
212
+ headers: changes.headers ?? this.headers,
213
+ cookies: changes.cookies ?? this.cookies,
214
+ })
215
+ }
216
+ }
217
+
218
+ const NO_COOKIES: readonly ResponseCookie[] = Object.freeze([])
219
+
220
+ /** `cookies` with `cookie` added, replacing (in place) a cookie of the same name, domain and path. */
221
+ function withCookie(cookies: readonly ResponseCookie[], cookie: ResponseCookie): ResponseCookie[] {
222
+ const key = givenCookieKey(cookie.name, cookie.options)
223
+ const index = cookies.findIndex(existing => givenCookieKey(existing.name, existing.options) === key)
224
+ if (index === -1) return [...cookies, cookie]
225
+ const result = [...cookies]
226
+ result[index] = cookie
227
+ return result
228
+ }
229
+
230
+ /** Whether `value` is an {@link HttpResponse} (also across duplicated package copies). */
231
+ export function isHttpResponse(value: unknown): value is HttpResponse {
232
+ return (
233
+ value instanceof HttpResponse ||
234
+ (value !== null &&
235
+ typeof value === 'object' &&
236
+ (value as { constructor?: { name?: unknown } }).constructor?.name === 'HttpResponse' &&
237
+ 'body' in value &&
238
+ 'headers' in value)
239
+ )
240
+ }
package/src/router.ts ADDED
@@ -0,0 +1,119 @@
1
+ import type { RoutePath } from './path.ts'
2
+
3
+ interface RouteNode<T> {
4
+ statics: Map<string, RouteNode<T>> | undefined
5
+ param: RouteNode<T> | undefined
6
+ /** Values of routes ending with a wildcard at this position, by method. */
7
+ wildcard: Map<string, T> | undefined
8
+ /** Values of routes ending at this node, by method. */
9
+ values: Map<string, T> | undefined
10
+ }
11
+
12
+ function createNode<T>(): RouteNode<T> {
13
+ return { statics: undefined, param: undefined, wildcard: undefined, values: undefined }
14
+ }
15
+
16
+ /**
17
+ * A segment trie mapping `(method, route path)` to values. Lookups prefer static segments over
18
+ * parameters over the wildcard, with backtracking (the same semantics as find-my-way), and are
19
+ * case-sensitive on the raw request path. Used by the built-in Node adapter for routing and by the
20
+ * plugin to tell `404 Not Found` from `405 Method Not Allowed`.
21
+ */
22
+ export class RouteTable<T> {
23
+ readonly #root: RouteNode<T> = createNode()
24
+ #size = 0
25
+
26
+ /** Number of `(method, route)` entries. */
27
+ get size(): number {
28
+ return this.#size
29
+ }
30
+
31
+ /**
32
+ * Adds a value for `method` (upper case) and `route`.
33
+ *
34
+ * @returns the value already registered for the same method and shape, in which case nothing is
35
+ * added.
36
+ */
37
+ add(method: string, route: RoutePath, value: T): T | undefined {
38
+ let node = this.#root
39
+ for (const segment of route.segments) {
40
+ if (segment.kind === 'static') {
41
+ node.statics ??= new Map()
42
+ let child = node.statics.get(segment.value)
43
+ if (child === undefined) {
44
+ child = createNode()
45
+ node.statics.set(segment.value, child)
46
+ }
47
+ node = child
48
+ } else if (segment.kind === 'param') {
49
+ node.param ??= createNode()
50
+ node = node.param
51
+ } else {
52
+ node.wildcard ??= new Map()
53
+ return this.#set(node.wildcard, method, value)
54
+ }
55
+ }
56
+ node.values ??= new Map()
57
+ return this.#set(node.values, method, value)
58
+ }
59
+
60
+ #set(map: Map<string, T>, method: string, value: T): T | undefined {
61
+ const existing = map.get(method)
62
+ if (existing !== undefined) return existing
63
+ map.set(method, value)
64
+ this.#size++
65
+ return undefined
66
+ }
67
+
68
+ /** The value of the most specific route matching `method` and the raw `path` (no query string). */
69
+ find(method: string, path: string): T | undefined {
70
+ if (path.charCodeAt(0) !== 47 /* / */) return undefined
71
+ if (path === '/') return this.#root.values?.get(method) ?? this.#root.wildcard?.get(method)
72
+ return search(this.#root, path.split('/'), 1, method)
73
+ }
74
+
75
+ /** Methods of every route matching the raw `path`, sorted. */
76
+ allowedMethods(path: string): string[] {
77
+ const methods = new Set<string>()
78
+ if (path.charCodeAt(0) === 47 /* / */) {
79
+ if (path === '/') {
80
+ addKeys(methods, this.#root.values)
81
+ addKeys(methods, this.#root.wildcard)
82
+ } else {
83
+ collect(this.#root, path.split('/'), 1, methods)
84
+ }
85
+ }
86
+ return [...methods].sort()
87
+ }
88
+ }
89
+
90
+ function search<T>(node: RouteNode<T>, segments: readonly string[], index: number, method: string): T | undefined {
91
+ if (index === segments.length) return node.values?.get(method)
92
+ const segment = segments[index] as string
93
+ const child = node.statics?.get(segment)
94
+ if (child !== undefined) {
95
+ const found = search(child, segments, index + 1, method)
96
+ if (found !== undefined) return found
97
+ }
98
+ if (segment !== '' && node.param !== undefined) {
99
+ const found = search(node.param, segments, index + 1, method)
100
+ if (found !== undefined) return found
101
+ }
102
+ return node.wildcard?.get(method)
103
+ }
104
+
105
+ function collect<T>(node: RouteNode<T>, segments: readonly string[], index: number, methods: Set<string>): void {
106
+ if (index === segments.length) {
107
+ addKeys(methods, node.values)
108
+ return
109
+ }
110
+ const segment = segments[index] as string
111
+ const child = node.statics?.get(segment)
112
+ if (child !== undefined) collect(child, segments, index + 1, methods)
113
+ if (segment !== '' && node.param !== undefined) collect(node.param, segments, index + 1, methods)
114
+ addKeys(methods, node.wildcard)
115
+ }
116
+
117
+ function addKeys(target: Set<string>, source: Map<string, unknown> | undefined): void {
118
+ if (source !== undefined) for (const key of source.keys()) target.add(key)
119
+ }