@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/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
|
+
}
|
package/src/response.ts
ADDED
|
@@ -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
|
+
}
|