@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.
- 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
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/** Helpers shared by the adapters (internal). */
|
|
2
|
+
import type { Readable } from 'node:stream'
|
|
3
|
+
import type {
|
|
4
|
+
HttpServerAddress,
|
|
5
|
+
InjectRequest,
|
|
6
|
+
InjectResponse,
|
|
7
|
+
InjectStreamRequest,
|
|
8
|
+
InjectStreamResponse,
|
|
9
|
+
} from '../adapter.ts'
|
|
10
|
+
|
|
11
|
+
import { encodeCookieValue, parseSetCookies } from '../cookies.ts'
|
|
12
|
+
import { BadRequestException, PayloadTooLargeException } from '../exceptions.ts'
|
|
13
|
+
import { failedStream } from '../stream.ts'
|
|
14
|
+
|
|
15
|
+
/** An inject request normalised for the underlying tools. */
|
|
16
|
+
export interface NormalizedInject {
|
|
17
|
+
readonly method: string
|
|
18
|
+
readonly url: string
|
|
19
|
+
readonly headers: Record<string, string>
|
|
20
|
+
readonly payload: string | Uint8Array | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Applies the defaults of {@link InjectRequest}: query merged in the URL, cookies appended to the
|
|
25
|
+
* `cookie` header (encoded values joined with `; `), body encoding and content type (`FormData` →
|
|
26
|
+
* `multipart/form-data`, `Blob` → its type).
|
|
27
|
+
*/
|
|
28
|
+
export async function normalizeInject(request: InjectRequest): Promise<NormalizedInject> {
|
|
29
|
+
if (request === null || typeof request !== 'object') throw new TypeError('inject() expects a request object')
|
|
30
|
+
if (typeof request.url !== 'string' || !request.url.startsWith('/')) {
|
|
31
|
+
throw new TypeError(`inject() expects a url starting with "/" (received ${JSON.stringify(request.url)})`)
|
|
32
|
+
}
|
|
33
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
34
|
+
let url = request.url
|
|
35
|
+
if (request.query !== undefined) {
|
|
36
|
+
const search = new URLSearchParams()
|
|
37
|
+
for (const [key, value] of Object.entries(request.query)) {
|
|
38
|
+
if (typeof value === 'string') search.append(key, value)
|
|
39
|
+
else for (const item of value) search.append(key, item)
|
|
40
|
+
}
|
|
41
|
+
const query = search.toString()
|
|
42
|
+
if (query !== '') url += `${url.includes('?') ? '&' : '?'}${query}`
|
|
43
|
+
}
|
|
44
|
+
const headers: Record<string, string> = {}
|
|
45
|
+
for (const [name, value] of Object.entries(request.headers ?? {})) headers[name.toLowerCase()] = value
|
|
46
|
+
if (request.signedCookies !== undefined) {
|
|
47
|
+
throw new TypeError('inject(): signedCookies are signed by HttpPlugin.inject() (adapters only accept cookies)')
|
|
48
|
+
}
|
|
49
|
+
const cookie = cookieHeader(request.cookies)
|
|
50
|
+
if (cookie !== '') headers['cookie'] = headers['cookie'] === undefined ? cookie : `${headers['cookie']}; ${cookie}`
|
|
51
|
+
const body = request.body
|
|
52
|
+
let payload: string | Uint8Array | undefined
|
|
53
|
+
if (body === undefined) {
|
|
54
|
+
payload = undefined
|
|
55
|
+
} else if (typeof body === 'string') {
|
|
56
|
+
payload = body
|
|
57
|
+
headers['content-type'] ??= 'text/plain; charset=utf-8'
|
|
58
|
+
} else if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
|
|
59
|
+
payload = body instanceof Uint8Array ? body : new Uint8Array(body)
|
|
60
|
+
headers['content-type'] ??= 'application/octet-stream'
|
|
61
|
+
} else if (body instanceof FormData) {
|
|
62
|
+
// the Fetch API encodes forms (the boundary of the generated content type must be used)
|
|
63
|
+
const encoded = new Response(body)
|
|
64
|
+
// read the generated content type (with its boundary) before consuming the body (Bun sets it lazily)
|
|
65
|
+
const type = encoded.headers.get('content-type')
|
|
66
|
+
payload = new Uint8Array(await encoded.arrayBuffer())
|
|
67
|
+
headers['content-type'] = type ?? encoded.headers.get('content-type') ?? 'multipart/form-data'
|
|
68
|
+
} else if (body instanceof Blob) {
|
|
69
|
+
payload = new Uint8Array(await body.arrayBuffer())
|
|
70
|
+
headers['content-type'] ??= body.type === '' ? 'application/octet-stream' : body.type
|
|
71
|
+
} else {
|
|
72
|
+
payload = JSON.stringify(body)
|
|
73
|
+
headers['content-type'] ??= 'application/json'
|
|
74
|
+
}
|
|
75
|
+
return { method, url, headers, payload }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The `cookie` header of inject cookies: `name=encoded` pairs joined with `; `. */
|
|
79
|
+
function cookieHeader(cookies: Readonly<Record<string, string>> | undefined): string {
|
|
80
|
+
if (cookies === undefined) return ''
|
|
81
|
+
if (cookies === null || typeof cookies !== 'object') throw new TypeError('inject(): cookies must be an object')
|
|
82
|
+
const pairs: string[] = []
|
|
83
|
+
for (const name of Object.keys(cookies)) {
|
|
84
|
+
if (!isRequestCookieName(name)) {
|
|
85
|
+
throw new TypeError(
|
|
86
|
+
`inject(): invalid cookie name ${JSON.stringify(name)} (empty, or with ";", "=", spaces or controls)`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
const value = cookies[name]
|
|
90
|
+
if (typeof value !== 'string') throw new TypeError(`inject(): the value of the cookie "${name}" must be a string`)
|
|
91
|
+
pairs.push(`${name}=${encodeCookieValue(value)}`)
|
|
92
|
+
}
|
|
93
|
+
return pairs.join('; ')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A name a `cookie` header can carry: no `;`, `=`, whitespace or controls, Latin-1. */
|
|
97
|
+
function isRequestCookieName(name: string): boolean {
|
|
98
|
+
if (name === '') return false
|
|
99
|
+
for (let index = 0; index < name.length; index++) {
|
|
100
|
+
const code = name.charCodeAt(index)
|
|
101
|
+
if (code <= 0x20 || code === 0x3b || code === 0x3d || code === 0x7f || code > 0xff) return false
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const DECODER = new TextDecoder()
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Normalises response headers: lower-case names, repeated values joined with `, `, except
|
|
110
|
+
* `set-cookie` (one entry per cookie: an `Expires` attribute contains `, `).
|
|
111
|
+
*/
|
|
112
|
+
export function injectHeaders(
|
|
113
|
+
headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
|
|
114
|
+
): Record<string, string | string[]> {
|
|
115
|
+
const normalized: Record<string, string | string[]> = {}
|
|
116
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
117
|
+
if (value === undefined) continue
|
|
118
|
+
const key = name.toLowerCase()
|
|
119
|
+
if (key === 'set-cookie') normalized[key] = typeof value === 'object' ? [...value] : [String(value)]
|
|
120
|
+
else normalized[key] = typeof value === 'object' ? value.join(', ') : String(value)
|
|
121
|
+
}
|
|
122
|
+
return normalized
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Builds an {@link InjectResponse} (headers normalised by {@link injectHeaders}, cookies parsed). */
|
|
126
|
+
export function injectResponse(
|
|
127
|
+
status: number,
|
|
128
|
+
headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
|
|
129
|
+
bytes: Uint8Array,
|
|
130
|
+
): InjectResponse {
|
|
131
|
+
const normalized = injectHeaders(headers)
|
|
132
|
+
const body = DECODER.decode(bytes)
|
|
133
|
+
return {
|
|
134
|
+
status,
|
|
135
|
+
headers: normalized,
|
|
136
|
+
body,
|
|
137
|
+
bytes,
|
|
138
|
+
cookies: parseSetCookies(normalized['set-cookie']),
|
|
139
|
+
json: <T = unknown>(): T => JSON.parse(body) as T,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether a `content-type` is `text/event-stream`. */
|
|
144
|
+
export function isEventStreamType(contentType: string | readonly string[] | undefined): boolean {
|
|
145
|
+
if (typeof contentType !== 'string') return false
|
|
146
|
+
const semicolon = contentType.indexOf(';')
|
|
147
|
+
return (semicolon === -1 ? contentType : contentType.slice(0, semicolon)).trim().toLowerCase() === 'text/event-stream'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `inject()` on top of `injectStream()`: reads the body to its end, except for an event stream
|
|
152
|
+
* (which would never end): its body is cancelled (the server sees a disconnect) and the call
|
|
153
|
+
* rejects with a hint.
|
|
154
|
+
*/
|
|
155
|
+
export async function bufferInjectStream(
|
|
156
|
+
injectStream: (request: InjectStreamRequest) => Promise<InjectStreamResponse>,
|
|
157
|
+
request: InjectRequest,
|
|
158
|
+
): Promise<InjectResponse> {
|
|
159
|
+
const response = await injectStream(request)
|
|
160
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
161
|
+
if (method !== 'HEAD' && isEventStreamType(response.headers['content-type'])) {
|
|
162
|
+
await response.body.cancel().catch(() => undefined)
|
|
163
|
+
throw new TypeError(
|
|
164
|
+
`inject(): ${method} ${request.url} answered an event stream (text/event-stream): use injectSse() or injectStream()`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
const reader = response.body.getReader()
|
|
168
|
+
const chunks: Uint8Array[] = []
|
|
169
|
+
let size = 0
|
|
170
|
+
for (;;) {
|
|
171
|
+
const { done, value } = await reader.read()
|
|
172
|
+
if (done) break
|
|
173
|
+
size += value.byteLength
|
|
174
|
+
chunks.push(value)
|
|
175
|
+
}
|
|
176
|
+
const bytes = concat(chunks, size)
|
|
177
|
+
const body = DECODER.decode(bytes)
|
|
178
|
+
return {
|
|
179
|
+
status: response.status,
|
|
180
|
+
headers: response.headers,
|
|
181
|
+
body,
|
|
182
|
+
bytes,
|
|
183
|
+
cookies: response.cookies,
|
|
184
|
+
json: <T = unknown>(): T => JSON.parse(body) as T,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Builds an {@link InjectStreamResponse}. */
|
|
189
|
+
export function injectStreamResponse(
|
|
190
|
+
status: number,
|
|
191
|
+
headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
|
|
192
|
+
body: ReadableStream<Uint8Array>,
|
|
193
|
+
): InjectStreamResponse {
|
|
194
|
+
const normalized = injectHeaders(headers)
|
|
195
|
+
return { status, headers: normalized, cookies: parseSetCookies(normalized['set-cookie']), body }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Builds an {@link HttpServerAddress} from a bound address. */
|
|
199
|
+
export function serverAddress(address: string, port: number, family?: string | number): HttpServerAddress {
|
|
200
|
+
const ipv6 = family === 'IPv6' || family === 6 || address.includes(':')
|
|
201
|
+
let client = address
|
|
202
|
+
if (address === '0.0.0.0') client = '127.0.0.1'
|
|
203
|
+
else if (address === '::') client = '::1'
|
|
204
|
+
const url = `http://${client.includes(':') ? `[${client}]` : client}:${port}`
|
|
205
|
+
return Object.freeze({
|
|
206
|
+
host: address,
|
|
207
|
+
port,
|
|
208
|
+
family: ipv6 ? 'IPv6' : 'IPv4',
|
|
209
|
+
url,
|
|
210
|
+
toString: () => url,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The 413 error of a body above `limit` bytes. */
|
|
215
|
+
export function payloadTooLarge(limit: number): PayloadTooLargeException {
|
|
216
|
+
return new PayloadTooLargeException(`Request body exceeds the limit of ${limit} bytes`, undefined, {
|
|
217
|
+
headers: { connection: 'close' },
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Rejects early when `content-length` announces more than `limit` bytes. */
|
|
222
|
+
export function exceedsDeclaredLength(contentLength: string | undefined, limit: number): boolean {
|
|
223
|
+
if (contentLength === undefined) return false
|
|
224
|
+
const length = Number(contentLength)
|
|
225
|
+
return Number.isFinite(length) && length > limit
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Bytes read (and discarded) from an abandoned Web request body before it is cancelled. */
|
|
229
|
+
const MAX_DISCARD_BYTES = 64 * 1024 * 1024
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The body of a Web request as a pull-based stream enforcing `limit` (see
|
|
233
|
+
* `AdapterRequest.streamBody`). `body` is only read when needed (a Node.js-backed request body
|
|
234
|
+
* starts buffering once accessed). On overflow or cancellation the rest of the body is read and
|
|
235
|
+
* discarded in the background (up to 64 MiB, then cancelled), so the server can write the response
|
|
236
|
+
* and keep the connection usable instead of resetting it.
|
|
237
|
+
*/
|
|
238
|
+
export function webBodyStream(
|
|
239
|
+
body: () => ReadableStream<Uint8Array> | null,
|
|
240
|
+
limit: number,
|
|
241
|
+
contentLength: string | undefined,
|
|
242
|
+
): ReadableStream<Uint8Array> {
|
|
243
|
+
if (exceedsDeclaredLength(contentLength, limit)) return failedStream(payloadTooLarge(limit))
|
|
244
|
+
const source = body()
|
|
245
|
+
if (source === null) return new ReadableStream<Uint8Array>({ start: controller => controller.close() })
|
|
246
|
+
const reader = source.getReader()
|
|
247
|
+
let size = 0
|
|
248
|
+
let abandoned = false
|
|
249
|
+
const abandon = (): void => {
|
|
250
|
+
if (abandoned) return
|
|
251
|
+
abandoned = true
|
|
252
|
+
discardReader(reader)
|
|
253
|
+
}
|
|
254
|
+
return new ReadableStream<Uint8Array>(
|
|
255
|
+
{
|
|
256
|
+
async pull(controller) {
|
|
257
|
+
let result: Awaited<ReturnType<typeof reader.read>>
|
|
258
|
+
try {
|
|
259
|
+
result = await reader.read()
|
|
260
|
+
} catch (error) {
|
|
261
|
+
abandoned = true
|
|
262
|
+
throw new BadRequestException('Failed to read the request body', undefined, { cause: error })
|
|
263
|
+
}
|
|
264
|
+
if (result.done) {
|
|
265
|
+
abandoned = true
|
|
266
|
+
controller.close()
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
size += result.value.byteLength
|
|
270
|
+
if (size > limit) {
|
|
271
|
+
abandon()
|
|
272
|
+
throw payloadTooLarge(limit)
|
|
273
|
+
}
|
|
274
|
+
controller.enqueue(result.value)
|
|
275
|
+
},
|
|
276
|
+
cancel: abandon,
|
|
277
|
+
},
|
|
278
|
+
{ highWaterMark: 0 },
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The part of a stream reader used to discard a body. */
|
|
283
|
+
interface BodyReader {
|
|
284
|
+
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>
|
|
285
|
+
cancel(): Promise<void>
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Reads and drops the rest of a request body in the background, cancelling it past 64 MiB. */
|
|
289
|
+
function discardReader(reader: BodyReader): void {
|
|
290
|
+
let discarded = 0
|
|
291
|
+
const next = (): void => {
|
|
292
|
+
reader.read().then(
|
|
293
|
+
result => {
|
|
294
|
+
if (result.done) return
|
|
295
|
+
discarded += result.value?.byteLength ?? 0
|
|
296
|
+
if (discarded > MAX_DISCARD_BYTES) reader.cancel().catch(() => undefined)
|
|
297
|
+
else next()
|
|
298
|
+
},
|
|
299
|
+
() => undefined,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
next()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Reads a Web request body fully, enforcing `limit` (see {@link webBodyStream}). */
|
|
306
|
+
export async function readWebStream(
|
|
307
|
+
body: () => ReadableStream<Uint8Array> | null,
|
|
308
|
+
limit: number,
|
|
309
|
+
contentLength: string | undefined,
|
|
310
|
+
): Promise<Uint8Array> {
|
|
311
|
+
const reader = webBodyStream(body, limit, contentLength).getReader()
|
|
312
|
+
const chunks: Uint8Array[] = []
|
|
313
|
+
let size = 0
|
|
314
|
+
for (;;) {
|
|
315
|
+
const { done, value } = await reader.read()
|
|
316
|
+
if (done) break
|
|
317
|
+
size += value.byteLength
|
|
318
|
+
chunks.push(value)
|
|
319
|
+
}
|
|
320
|
+
return concat(chunks, size)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Concatenates chunks (no copy for a single chunk). */
|
|
324
|
+
export function concat(chunks: readonly Uint8Array[], size: number): Uint8Array {
|
|
325
|
+
if (chunks.length === 0) return new Uint8Array(0)
|
|
326
|
+
if (chunks.length === 1) return chunks[0] as Uint8Array
|
|
327
|
+
const result = new Uint8Array(size)
|
|
328
|
+
let offset = 0
|
|
329
|
+
for (const chunk of chunks) {
|
|
330
|
+
result.set(chunk, offset)
|
|
331
|
+
offset += chunk.byteLength
|
|
332
|
+
}
|
|
333
|
+
return result
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The light-my-request response of a streaming inject (`payloadAsStream: true`). */
|
|
337
|
+
interface LightStreamResponse {
|
|
338
|
+
readonly statusCode: number
|
|
339
|
+
readonly headers: Record<string, string | number | string[] | undefined>
|
|
340
|
+
stream(): Readable
|
|
341
|
+
readonly raw: { readonly res: { destroy(error?: Error): void } }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Minimal typing of `light-my-request` (optional peer dependency). */
|
|
345
|
+
export interface LightMyRequest {
|
|
346
|
+
inject(dispatch: (req: never, res: never) => void, options: LightInjectOptions): PromiseLike<LightStreamResponse>
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Options of a streaming light-my-request inject (also `fastify.inject()`). */
|
|
350
|
+
export interface LightInjectOptions {
|
|
351
|
+
method: string
|
|
352
|
+
url: string
|
|
353
|
+
headers: Record<string, string>
|
|
354
|
+
payload?: string | Buffer
|
|
355
|
+
remoteAddress?: string
|
|
356
|
+
payloadAsStream: true
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let lightMyRequest: Promise<LightMyRequest> | undefined
|
|
360
|
+
|
|
361
|
+
/** Loads `light-my-request`, with an actionable error when it is not installed. */
|
|
362
|
+
export function loadLightMyRequest(adapter: string): Promise<LightMyRequest> {
|
|
363
|
+
lightMyRequest ??= import('light-my-request').then(
|
|
364
|
+
module => {
|
|
365
|
+
const loaded = module as unknown as { default?: LightMyRequest } & LightMyRequest
|
|
366
|
+
return loaded.default ?? loaded
|
|
367
|
+
},
|
|
368
|
+
(error: unknown) => {
|
|
369
|
+
lightMyRequest = undefined
|
|
370
|
+
throw new Error(
|
|
371
|
+
`inject() with the ${adapter} adapter requires the optional peer dependency "light-my-request" ` +
|
|
372
|
+
'(bun add -d light-my-request / npm i -D light-my-request)',
|
|
373
|
+
{ cause: error },
|
|
374
|
+
)
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
return lightMyRequest
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The light-my-request options of a normalised inject request (streaming mode). The signal is
|
|
382
|
+
* handled by {@link lightStreamResponse} (light-my-request would destroy its fake request with an
|
|
383
|
+
* unhandled error); an already aborted signal rejects at once, like `fetch()`.
|
|
384
|
+
*/
|
|
385
|
+
export async function lightInjectOptions(request: InjectStreamRequest): Promise<LightInjectOptions> {
|
|
386
|
+
request.signal?.throwIfAborted()
|
|
387
|
+
const normalized = await normalizeInject(request)
|
|
388
|
+
return {
|
|
389
|
+
method: normalized.method,
|
|
390
|
+
url: normalized.url,
|
|
391
|
+
headers: normalized.headers,
|
|
392
|
+
remoteAddress: '127.0.0.1',
|
|
393
|
+
payloadAsStream: true,
|
|
394
|
+
...(normalized.payload === undefined ? {} : { payload: toBuffer(normalized.payload) }),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Runs a request through a Node.js request listener with `light-my-request`, streaming the body. */
|
|
399
|
+
export async function injectNodeStream(
|
|
400
|
+
adapter: string,
|
|
401
|
+
dispatch: (req: never, res: never) => void,
|
|
402
|
+
request: InjectStreamRequest,
|
|
403
|
+
): Promise<InjectStreamResponse> {
|
|
404
|
+
const options = await lightInjectOptions(request)
|
|
405
|
+
const { inject } = await loadLightMyRequest(adapter)
|
|
406
|
+
return lightStreamResponse(await inject(dispatch, options), request.signal)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* An {@link InjectStreamResponse} of a light-my-request streaming response. Cancelling the body
|
|
411
|
+
* (or aborting `signal`) destroys the server response: the server sees a client disconnect.
|
|
412
|
+
*/
|
|
413
|
+
export function lightStreamResponse(response: LightStreamResponse, signal?: AbortSignal): InjectStreamResponse {
|
|
414
|
+
const readable = response.stream()
|
|
415
|
+
const disconnect = (): void => {
|
|
416
|
+
response.raw.res.destroy()
|
|
417
|
+
readable.destroy()
|
|
418
|
+
}
|
|
419
|
+
let finished = false
|
|
420
|
+
const body = new ReadableStream<Uint8Array>(
|
|
421
|
+
{
|
|
422
|
+
start(controller) {
|
|
423
|
+
readable.on('data', (chunk: Buffer) => {
|
|
424
|
+
if (finished) return
|
|
425
|
+
controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
|
|
426
|
+
if ((controller.desiredSize ?? 0) <= 0) readable.pause()
|
|
427
|
+
})
|
|
428
|
+
readable.once('end', () => {
|
|
429
|
+
if (finished) return
|
|
430
|
+
finished = true
|
|
431
|
+
controller.close()
|
|
432
|
+
})
|
|
433
|
+
readable.once('error', error => {
|
|
434
|
+
if (finished) return
|
|
435
|
+
finished = true
|
|
436
|
+
controller.error(error)
|
|
437
|
+
})
|
|
438
|
+
},
|
|
439
|
+
pull() {
|
|
440
|
+
if (readable.isPaused()) readable.resume()
|
|
441
|
+
},
|
|
442
|
+
cancel() {
|
|
443
|
+
finished = true
|
|
444
|
+
disconnect()
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
{ highWaterMark: 0 },
|
|
448
|
+
)
|
|
449
|
+
if (signal !== undefined) {
|
|
450
|
+
if (signal.aborted) disconnect()
|
|
451
|
+
else signal.addEventListener('abort', disconnect, { once: true })
|
|
452
|
+
}
|
|
453
|
+
return injectStreamResponse(response.statusCode, response.headers, body)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** A `Buffer` view of bytes (tools such as light-my-request JSON-encode other objects). */
|
|
457
|
+
export function toBuffer(payload: string | Uint8Array): string | Buffer {
|
|
458
|
+
return typeof payload === 'string' ? payload : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Runs a request through a Fetch handler (`(request: Request) => Promise<Response>`), streaming
|
|
463
|
+
* the body. Cancelling the body (or aborting `signal`) aborts the request signal and cancels the
|
|
464
|
+
* response body.
|
|
465
|
+
*/
|
|
466
|
+
export async function injectFetchStream(
|
|
467
|
+
fetch: (request: Request) => Response | Promise<Response>,
|
|
468
|
+
request: InjectStreamRequest,
|
|
469
|
+
): Promise<InjectStreamResponse> {
|
|
470
|
+
const signal = request.signal
|
|
471
|
+
signal?.throwIfAborted()
|
|
472
|
+
const normalized = await normalizeInject(request)
|
|
473
|
+
const abort = new AbortController()
|
|
474
|
+
signal?.addEventListener('abort', () => abort.abort(signal.reason), { once: true })
|
|
475
|
+
const init: RequestInit = { method: normalized.method, headers: normalized.headers, signal: abort.signal }
|
|
476
|
+
if (normalized.payload !== undefined && normalized.method !== 'GET' && normalized.method !== 'HEAD') {
|
|
477
|
+
init.body = normalized.payload
|
|
478
|
+
}
|
|
479
|
+
const response = await fetch(new Request(`http://localhost${normalized.url}`, init))
|
|
480
|
+
const headers: Record<string, string | readonly string[]> = {}
|
|
481
|
+
for (const [name, value] of response.headers) if (name !== 'set-cookie') headers[name] = value
|
|
482
|
+
const cookies = response.headers.getSetCookie()
|
|
483
|
+
if (cookies.length > 0) headers['set-cookie'] = cookies
|
|
484
|
+
const source = response.body
|
|
485
|
+
const reader = source?.getReader()
|
|
486
|
+
const body = new ReadableStream<Uint8Array>(
|
|
487
|
+
{
|
|
488
|
+
async pull(controller) {
|
|
489
|
+
if (reader === undefined) {
|
|
490
|
+
controller.close()
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
const result = await reader.read()
|
|
494
|
+
if (result.done) controller.close()
|
|
495
|
+
else controller.enqueue(result.value)
|
|
496
|
+
},
|
|
497
|
+
async cancel(reason) {
|
|
498
|
+
abort.abort(reason)
|
|
499
|
+
await reader?.cancel(reason).catch(() => undefined)
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
{ highWaterMark: 0 },
|
|
503
|
+
)
|
|
504
|
+
abort.signal.addEventListener('abort', () => reader?.cancel(abort.signal.reason).catch(() => undefined), {
|
|
505
|
+
once: true,
|
|
506
|
+
})
|
|
507
|
+
return injectStreamResponse(response.status, headers, body)
|
|
508
|
+
}
|
package/src/body.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { BadRequestException, UnsupportedMediaTypeException } from './exceptions.ts'
|
|
2
|
+
import { parseMultipart, toFormData } from './multipart.ts'
|
|
3
|
+
import { parseQuery } from './query.ts'
|
|
4
|
+
import { errorMessage } from './utils.ts'
|
|
5
|
+
|
|
6
|
+
/** The part of `TextDecoder` used here (the global type is not declared without the DOM lib). */
|
|
7
|
+
interface Decoder {
|
|
8
|
+
readonly encoding: string
|
|
9
|
+
decode(input: Uint8Array): string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const UTF8: Decoder = new TextDecoder('utf-8', { fatal: true })
|
|
13
|
+
|
|
14
|
+
/** A parsed `content-type` header. */
|
|
15
|
+
export interface MediaType {
|
|
16
|
+
/** Lower-case `type/subtype` without parameters. */
|
|
17
|
+
readonly type: string
|
|
18
|
+
/** Lower-case `charset` parameter, when present. */
|
|
19
|
+
readonly charset: string | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parses a `content-type` header value (`application/json; charset=utf-8`). */
|
|
23
|
+
export function parseMediaType(header: string | undefined): MediaType | undefined {
|
|
24
|
+
if (header === undefined) return undefined
|
|
25
|
+
const separator = header.indexOf(';')
|
|
26
|
+
const type = (separator === -1 ? header : header.slice(0, separator)).trim().toLowerCase()
|
|
27
|
+
if (type === '') return undefined
|
|
28
|
+
let charset: string | undefined
|
|
29
|
+
if (separator !== -1) {
|
|
30
|
+
for (const parameter of header.slice(separator + 1).split(';')) {
|
|
31
|
+
const equals = parameter.indexOf('=')
|
|
32
|
+
if (equals === -1) continue
|
|
33
|
+
if (parameter.slice(0, equals).trim().toLowerCase() !== 'charset') continue
|
|
34
|
+
charset = parameter
|
|
35
|
+
.slice(equals + 1)
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/^"|"$/g, '')
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { type, charset }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether `type` is a JSON media type (`application/json`, `application/problem+json`...). */
|
|
45
|
+
export function isJsonType(type: string): boolean {
|
|
46
|
+
return type === 'application/json' || type.endsWith('+json')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rejects a `content-encoding` other than `identity` with 415 (decompression is not supported). */
|
|
50
|
+
export function checkContentEncoding(contentEncoding: string | undefined): void {
|
|
51
|
+
if (contentEncoding === undefined) return
|
|
52
|
+
const encoding = contentEncoding.trim().toLowerCase()
|
|
53
|
+
if (encoding !== '' && encoding !== 'identity') {
|
|
54
|
+
throw new UnsupportedMediaTypeException(`Unsupported content encoding "${encoding}"`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether request headers announce a body (RFC 9112 §6.3: `transfer-encoding`, or a non-zero
|
|
60
|
+
* `content-length`).
|
|
61
|
+
*/
|
|
62
|
+
export function hasBody(headers: Readonly<Record<string, string | undefined>>): boolean {
|
|
63
|
+
if (headers['transfer-encoding'] !== undefined) return true
|
|
64
|
+
const length = headers['content-length']
|
|
65
|
+
return length !== undefined && length.trim() !== '0' && length.trim() !== ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parses a buffered request body according to its `content-type` (low-level helper; the request
|
|
70
|
+
* context parses bodies itself, multipart bodies as they arrive):
|
|
71
|
+
*
|
|
72
|
+
* | Content type | Result |
|
|
73
|
+
* | ------------------------------------- | ------------------------------------ |
|
|
74
|
+
* | empty body | `undefined` |
|
|
75
|
+
* | `application/json`, `*\/*+json` | parsed JSON (400 when malformed) |
|
|
76
|
+
* | `text/*` | string (charset honoured) |
|
|
77
|
+
* | `application/x-www-form-urlencoded` | record (repeated keys → arrays) |
|
|
78
|
+
* | `multipart/form-data` | `FormData` (every field and file) |
|
|
79
|
+
* | anything else, or no content type | `Uint8Array` |
|
|
80
|
+
*
|
|
81
|
+
* A `content-encoding` other than `identity` is rejected with 415 (decompression is not supported).
|
|
82
|
+
*/
|
|
83
|
+
export function parseBody(
|
|
84
|
+
bytes: Uint8Array,
|
|
85
|
+
contentType: string | undefined,
|
|
86
|
+
contentEncoding?: string | undefined,
|
|
87
|
+
): Promise<unknown> {
|
|
88
|
+
try {
|
|
89
|
+
return Promise.resolve(parseBytes(bytes, contentType, contentEncoding))
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return Promise.reject(error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseBytes(bytes: Uint8Array, contentType: string | undefined, contentEncoding: string | undefined): unknown {
|
|
96
|
+
if (bytes.byteLength === 0) return undefined
|
|
97
|
+
checkContentEncoding(contentEncoding)
|
|
98
|
+
const media = parseMediaType(contentType)
|
|
99
|
+
if (media === undefined) return bytes
|
|
100
|
+
if (isJsonType(media.type)) {
|
|
101
|
+
if (media.charset !== undefined && media.charset !== 'utf-8' && media.charset !== 'utf8') {
|
|
102
|
+
throw new UnsupportedMediaTypeException(`Unsupported charset "${media.charset}" for JSON (use utf-8)`)
|
|
103
|
+
}
|
|
104
|
+
const text = decode(bytes, UTF8, 'JSON')
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new BadRequestException(`Invalid JSON body: ${errorMessage(error)}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (media.type.startsWith('text/')) return decode(bytes, decoderFor(media.charset), 'text')
|
|
112
|
+
if (media.type === 'application/x-www-form-urlencoded') return parseQuery(decode(bytes, UTF8, 'form'))
|
|
113
|
+
if (media.type === 'multipart/form-data') {
|
|
114
|
+
const policy = { rules: [], maxParts: Number.POSITIVE_INFINITY, maxFieldSize: Number.POSITIVE_INFINITY }
|
|
115
|
+
return toFormData(parseMultipart(bytes, contentType as string, policy))
|
|
116
|
+
}
|
|
117
|
+
return bytes
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function decoderFor(charset: string | undefined): Decoder {
|
|
121
|
+
if (charset === undefined || charset === 'utf-8' || charset === 'utf8') return UTF8
|
|
122
|
+
try {
|
|
123
|
+
return new TextDecoder(charset as 'utf-8', { fatal: true })
|
|
124
|
+
} catch {
|
|
125
|
+
throw new UnsupportedMediaTypeException(`Unsupported charset "${charset}"`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function decode(bytes: Uint8Array, decoder: Decoder, what: string): string {
|
|
130
|
+
try {
|
|
131
|
+
return decoder.decode(bytes)
|
|
132
|
+
} catch {
|
|
133
|
+
throw new BadRequestException(`Invalid ${what} body: malformed ${decoder.encoding} data`)
|
|
134
|
+
}
|
|
135
|
+
}
|