@znight/http 0.1.2 → 0.1.4
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/README.md +154 -35
- package/dist/compiler.d.mts +77 -2
- package/dist/compiler.mjs +259 -101
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.mjs +3 -2
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.mjs +200 -23
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.mjs +174 -43
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +36 -4
- package/dist/index.mjs +1005 -257
- package/dist/index.mjs.map +1 -1
- package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
- package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
- package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
- package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
- package/dist/node.mjs +1 -1
- package/dist/utils-DIWdzVht.mjs +46 -0
- package/dist/utils-DIWdzVht.mjs.map +1 -0
- package/package.json +4 -4
- package/src/adapters/express.ts +4 -1
- package/src/adapters/fastify.ts +265 -25
- package/src/adapters/hono.ts +225 -58
- package/src/adapters/node-io.ts +128 -58
- package/src/adapters/node.ts +4 -1
- package/src/body.ts +31 -2
- package/src/coerce.ts +31 -0
- package/src/compiler/analyze.ts +3 -3
- package/src/compiler/generate.ts +127 -22
- package/src/compiler/index.ts +13 -0
- package/src/compiler/route-decorators.ts +45 -2
- package/src/compiler/route-groups.ts +216 -0
- package/src/compiler/validate.ts +15 -126
- package/src/context.ts +148 -78
- package/src/decorators.ts +19 -3
- package/src/index.ts +2 -1
- package/src/pipeline.ts +936 -177
- package/src/plugin-routes.ts +19 -0
- package/src/plugin.ts +7 -3
- package/src/response.ts +6 -8
- package/src/serialize.ts +48 -21
- package/src/utils.ts +25 -2
- package/src/validate.ts +28 -12
- package/dist/utils-DCbu3a4w.mjs +0 -27
- package/dist/utils-DCbu3a4w.mjs.map +0 -1
package/src/pipeline.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Container, CoreLogger, Hooks, HttpRequestEvent, Token } from '@znight/core'
|
|
2
|
-
import type { AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
|
|
1
|
+
import type { Container, CoreLogger, Hooks, HttpRequestEvent, OpenedScope, Token } from '@znight/core'
|
|
2
|
+
import type { AdapterHandler, AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
|
|
3
3
|
import type { CookieSettings } from './cookie-settings.ts'
|
|
4
4
|
import type { SerializedCookie } from './cookies.ts'
|
|
5
5
|
import type { CorsPolicy } from './cors.ts'
|
|
@@ -9,6 +9,7 @@ import type { TrustPolicy } from './proxy.ts'
|
|
|
9
9
|
import type { RouteTable } from './router.ts'
|
|
10
10
|
import type { BodyPlan } from './uploads.ts'
|
|
11
11
|
|
|
12
|
+
import { Buffer } from 'node:buffer'
|
|
12
13
|
import { displayName } from '@znight/core'
|
|
13
14
|
|
|
14
15
|
import { isEventStreamType } from './adapters/shared.ts'
|
|
@@ -26,12 +27,13 @@ import {
|
|
|
26
27
|
} from './exceptions.ts'
|
|
27
28
|
import { isFileBody, resolveFile } from './files.ts'
|
|
28
29
|
import { findInvalidHeader, isHeaderValue } from './headers.ts'
|
|
29
|
-
import { decodePathValue, matchRoutePath, type RoutePath } from './path.ts'
|
|
30
|
+
import { decodePathValue, matchRoutePath, type RoutePath, type RouteSegment, WILDCARD_PARAM } from './path.ts'
|
|
30
31
|
import { type RequestTarget, splitUrl } from './query.ts'
|
|
31
32
|
import { HttpResponse, isHttpResponse } from './response.ts'
|
|
32
33
|
import {
|
|
33
34
|
addMissingHeaders,
|
|
34
35
|
assignHeaders,
|
|
36
|
+
ContentType,
|
|
35
37
|
type SerializedReply,
|
|
36
38
|
type SerializeOptions,
|
|
37
39
|
serializeError,
|
|
@@ -40,9 +42,10 @@ import {
|
|
|
40
42
|
serializeSseReply,
|
|
41
43
|
} from './serialize.ts'
|
|
42
44
|
import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
|
|
43
|
-
import {
|
|
45
|
+
import { isBodylessStatus } from './status.ts'
|
|
46
|
+
import { type ContextRunner, captureContext, discardStream, type TrackedStream, trackStream } from './stream.ts'
|
|
44
47
|
import { HttpTokens } from './tokens.ts'
|
|
45
|
-
import { emptyRecord, errorMessage, isThenable } from './utils.ts'
|
|
48
|
+
import { emptyRecord, errorMessage, fastRecord, isBun, isThenable } from './utils.ts'
|
|
46
49
|
|
|
47
50
|
/** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
|
|
48
51
|
export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
|
|
@@ -127,11 +130,74 @@ const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
|
|
|
127
130
|
|
|
128
131
|
const MAX_PATH_IN_MESSAGE = 200
|
|
129
132
|
|
|
130
|
-
/** The default request id generator: a random UUID. */
|
|
133
|
+
/** The default request id generator: a random UUID (version 4, like `crypto.randomUUID()`). */
|
|
131
134
|
export function randomRequestId(): string {
|
|
132
|
-
return
|
|
135
|
+
return uuid()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Random bytes of the next UUIDs (Node.js), refilled every {@link UUID_BATCH} ids. */
|
|
139
|
+
const UUID_BATCH = 128
|
|
140
|
+
const uuidPool = new Uint8Array(16 * UUID_BATCH)
|
|
141
|
+
let uuidOffset = uuidPool.length
|
|
142
|
+
/** The text of the last UUID, its dashes in place. */
|
|
143
|
+
const uuidText = Buffer.alloc(36, 0x2d)
|
|
144
|
+
/** The two lower-case hexadecimal digits of each byte, as character codes. */
|
|
145
|
+
const HEX_CODES = new Uint8Array(512)
|
|
146
|
+
for (let byte = 0; byte < 256; byte++) {
|
|
147
|
+
const digits = byte.toString(16).padStart(2, '0')
|
|
148
|
+
HEX_CODES[byte * 2] = digits.charCodeAt(0)
|
|
149
|
+
HEX_CODES[byte * 2 + 1] = digits.charCodeAt(1)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function putHex(position: number, byte: number): void {
|
|
153
|
+
uuidText[position] = HEX_CODES[byte * 2] as number
|
|
154
|
+
uuidText[position + 1] = HEX_CODES[byte * 2 + 1] as number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A random version 4 UUID written from a pool of random bytes (`crypto.getRandomValues`), as Node.js
|
|
159
|
+
* does, but read as one flat string: `crypto.randomUUID()` returns a concatenation of 20 pieces that
|
|
160
|
+
* is flattened again when the header is written, which makes it about twice as slow.
|
|
161
|
+
*/
|
|
162
|
+
function pooledUuid(): string {
|
|
163
|
+
if (uuidOffset === uuidPool.length) {
|
|
164
|
+
crypto.getRandomValues(uuidPool)
|
|
165
|
+
uuidOffset = 0
|
|
166
|
+
}
|
|
167
|
+
const pool = uuidPool
|
|
168
|
+
const at = uuidOffset
|
|
169
|
+
uuidOffset = at + 16
|
|
170
|
+
putHex(0, pool[at] as number)
|
|
171
|
+
putHex(2, pool[at + 1] as number)
|
|
172
|
+
putHex(4, pool[at + 2] as number)
|
|
173
|
+
putHex(6, pool[at + 3] as number)
|
|
174
|
+
putHex(9, pool[at + 4] as number)
|
|
175
|
+
putHex(11, pool[at + 5] as number)
|
|
176
|
+
putHex(14, ((pool[at + 6] as number) & 0x0f) | 0x40)
|
|
177
|
+
putHex(16, pool[at + 7] as number)
|
|
178
|
+
putHex(19, ((pool[at + 8] as number) & 0x3f) | 0x80)
|
|
179
|
+
putHex(21, pool[at + 9] as number)
|
|
180
|
+
putHex(24, pool[at + 10] as number)
|
|
181
|
+
putHex(26, pool[at + 11] as number)
|
|
182
|
+
putHex(28, pool[at + 12] as number)
|
|
183
|
+
putHex(30, pool[at + 13] as number)
|
|
184
|
+
putHex(32, pool[at + 14] as number)
|
|
185
|
+
putHex(34, pool[at + 15] as number)
|
|
186
|
+
return readUuidText()
|
|
133
187
|
}
|
|
134
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The text of {@link uuidText}: Node.js `Buffer#latin1Slice` (what `toString('latin1')` calls after
|
|
191
|
+
* checking its arguments) when present.
|
|
192
|
+
*/
|
|
193
|
+
const readUuidText: () => string =
|
|
194
|
+
typeof (uuidText as { latin1Slice?: unknown }).latin1Slice === 'function'
|
|
195
|
+
? () => (uuidText as unknown as { latin1Slice(start: number, end: number): string }).latin1Slice(0, 36)
|
|
196
|
+
: () => uuidText.toString('latin1', 0, 36)
|
|
197
|
+
|
|
198
|
+
/** UUID generator: Bun's native `crypto.randomUUID()` is the fastest there. */
|
|
199
|
+
const uuid: () => string = isBun() ? () => crypto.randomUUID() : pooledUuid
|
|
200
|
+
|
|
135
201
|
/**
|
|
136
202
|
* The request pipeline (architecture §9.1):
|
|
137
203
|
* `runInScope({ HttpContext, RequestId }) → hooks.run('http:request') → guards → body → interceptors
|
|
@@ -147,6 +213,22 @@ export class Pipeline {
|
|
|
147
213
|
readonly #securityHeaders: readonly (readonly [string, string])[]
|
|
148
214
|
/** Every request id can be sent: an incoming one matched `REQUEST_ID`, the generated ones are UUIDs. */
|
|
149
215
|
readonly #validIds: boolean
|
|
216
|
+
/**
|
|
217
|
+
* The headers of a JSON reply of the fast lane, in their final order (content type and length,
|
|
218
|
+
* then the framework headers, see {@link #frameworkHeaders}): copied, then completed with the
|
|
219
|
+
* length and the request id.
|
|
220
|
+
*/
|
|
221
|
+
readonly #jsonHeaders: Readonly<Record<string, HeaderValue>>
|
|
222
|
+
/** The request id header of {@link #jsonHeaders}, `undefined` when the reply has none. */
|
|
223
|
+
readonly #jsonIdHeader: string | undefined
|
|
224
|
+
/** The headers of a reply without body of the fast lane (the framework headers, see {@link #jsonHeaders}). */
|
|
225
|
+
readonly #emptyHeaders: Readonly<Record<string, HeaderValue>>
|
|
226
|
+
/** The request id header of {@link #emptyHeaders}, `undefined` when the reply has none. */
|
|
227
|
+
readonly #emptyIdHeader: string | undefined
|
|
228
|
+
/** Whether {@link #jsonHeaders} and {@link #emptyHeaders} hold every framework header (see {@link #fastRoute}). */
|
|
229
|
+
readonly #fastHeaders: boolean
|
|
230
|
+
/** The `cookies` option (secure defaults, no signing, when not set). */
|
|
231
|
+
readonly #cookies: CookieSettings
|
|
150
232
|
#inflight = 0
|
|
151
233
|
#idle: (() => void)[] = []
|
|
152
234
|
/** Open event streams. */
|
|
@@ -167,9 +249,28 @@ export class Pipeline {
|
|
|
167
249
|
this.#options = options
|
|
168
250
|
this.#table = table
|
|
169
251
|
this.#validIds = options.generateRequestId === randomRequestId
|
|
252
|
+
this.#cookies = options.cookies ?? DEFAULT_COOKIE_SETTINGS
|
|
170
253
|
this.#securityHeaders = Object.entries(options.securityHeaders ?? {}).map(
|
|
171
254
|
([name, value]) => [name.toLowerCase(), value] as const,
|
|
172
255
|
)
|
|
256
|
+
// what #complete adds to the headers of serializeResult for a JSON value or no value (no CORS,
|
|
257
|
+
// not draining), with a placeholder where the request id goes
|
|
258
|
+
const placeholder = '-' as RequestId
|
|
259
|
+
const idHeader = options.requestIdHeader
|
|
260
|
+
// plain objects: a spread copies them several times faster than a record without prototype
|
|
261
|
+
const json: Record<string, HeaderValue> = { 'content-type': ContentType.json, 'content-length': '0' }
|
|
262
|
+
this.#frameworkHeaders(json, placeholder, undefined, false)
|
|
263
|
+
this.#jsonHeaders = json
|
|
264
|
+
this.#jsonIdHeader = idHeader !== undefined && json[idHeader] === placeholder ? idHeader : undefined
|
|
265
|
+
const empty: Record<string, HeaderValue> = {}
|
|
266
|
+
this.#frameworkHeaders(empty, placeholder, undefined, false)
|
|
267
|
+
this.#emptyHeaders = empty
|
|
268
|
+
this.#emptyIdHeader = idHeader !== undefined && empty[idHeader] === placeholder ? idHeader : undefined
|
|
269
|
+
// a header named like a property of Object.prototype (`__proto__`, `constructor`...) is not an own
|
|
270
|
+
// property of these plain objects: such options take the general pipeline
|
|
271
|
+
this.#fastHeaders =
|
|
272
|
+
(idHeader === undefined || !(idHeader in Object.prototype)) &&
|
|
273
|
+
this.#securityHeaders.every(([name]) => !(name in Object.prototype))
|
|
173
274
|
}
|
|
174
275
|
|
|
175
276
|
/** Number of requests being processed. */
|
|
@@ -196,6 +297,411 @@ export class Pipeline {
|
|
|
196
297
|
return new Promise(resolve => this.#idle.push(resolve))
|
|
197
298
|
}
|
|
198
299
|
|
|
300
|
+
/**
|
|
301
|
+
* The adapter handler of a route: {@link handle}, or a fast lane for the routes that need no
|
|
302
|
+
* guard, interceptor, event stream, raw response nor `@Header` when CORS is off (see
|
|
303
|
+
* {@link #fast}).
|
|
304
|
+
*/
|
|
305
|
+
handler(plan: RoutePlan): AdapterHandler {
|
|
306
|
+
const route = this.#fastRoute(plan)
|
|
307
|
+
if (route === undefined) return request => this.handle(plan, request)
|
|
308
|
+
return request => this.#fast(route, request)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The fast lane of a route, `undefined` when the route needs the general pipeline. */
|
|
312
|
+
#fastRoute(plan: RoutePlan): FastRoute | undefined {
|
|
313
|
+
const definition = plan.definition
|
|
314
|
+
if (
|
|
315
|
+
!this.#fastHeaders ||
|
|
316
|
+
this.#options.cors !== undefined ||
|
|
317
|
+
plan.redirect === true ||
|
|
318
|
+
plan.guards.length > 0 ||
|
|
319
|
+
plan.interceptors.length > 0 ||
|
|
320
|
+
definition.sse !== undefined ||
|
|
321
|
+
definition.rawResponse === true ||
|
|
322
|
+
definition.headers !== undefined ||
|
|
323
|
+
definition.method === 'ALL'
|
|
324
|
+
) {
|
|
325
|
+
return undefined
|
|
326
|
+
}
|
|
327
|
+
// the statuses of serializeResult: `@HttpCode` > 201 for POST routes > 200 (JSON) or 204 (none)
|
|
328
|
+
const post = definition.method === 'POST'
|
|
329
|
+
const json = definition.status ?? (post ? 201 : 200)
|
|
330
|
+
return {
|
|
331
|
+
plan,
|
|
332
|
+
target: { kind: 'route', plan },
|
|
333
|
+
method: definition.method,
|
|
334
|
+
// a HEAD reply has no body: JSON results of an explicit `@Head` route take the general pipeline
|
|
335
|
+
jsonStatus: definition.method === 'HEAD' || isBodylessStatus(json) ? undefined : json,
|
|
336
|
+
emptyStatus: definition.status ?? (post ? 201 : 204),
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The fast lane: what {@link handle} does for a route of {@link #fastRoute}, decided once instead
|
|
342
|
+
* of per request, without promises while the route stays synchronous. A request takes it when the
|
|
343
|
+
* server is not draining and its method is the route's (not a `HEAD` served by a `GET` route); a
|
|
344
|
+
* plain object or array returned by the handler is serialised as JSON, and `undefined` answered
|
|
345
|
+
* without body, with the headers in their final order (a template precomputed at boot).
|
|
346
|
+
* A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
|
|
347
|
+
* path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
|
|
348
|
+
* or any other result, headers or cookies set through the context — continues in the general
|
|
349
|
+
* pipeline, in the same request scope, with the same outcome.
|
|
350
|
+
* With `http:request` wrappers, the handler runs inside them (see {@link #fastWrapped}); listeners
|
|
351
|
+
* get the event once the reply is ready, as in the general pipeline.
|
|
352
|
+
*/
|
|
353
|
+
#fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
|
|
354
|
+
const plan = route.plan
|
|
355
|
+
if (this.draining || request.method !== route.method) return this.handle(plan, request)
|
|
356
|
+
const hooks = this.#hooks
|
|
357
|
+
const wrapped = hooks.hasWrappers('http:request')
|
|
358
|
+
// a hooked request starts now (see handle): its event is created with the exchange
|
|
359
|
+
const startTime = wrapped || hooks.hasListeners('http:request') ? performance.now() : undefined
|
|
360
|
+
const url = request.url
|
|
361
|
+
const mark = url.indexOf('?')
|
|
362
|
+
const path = mark === -1 ? url : url.slice(0, mark)
|
|
363
|
+
const params = routeParams(plan.path, path)
|
|
364
|
+
// an unusual path (see #route): nothing started yet, the general pipeline handles it
|
|
365
|
+
if (params === undefined) return this.handle(plan, request)
|
|
366
|
+
this.#enter()
|
|
367
|
+
let ctx: RequestContext
|
|
368
|
+
try {
|
|
369
|
+
const query = mark === -1 ? '' : url.slice(mark + 1)
|
|
370
|
+
ctx = this.#context(request, this.#requestId(request), path, query, plan.info, params, plan.body)
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.#leave()
|
|
373
|
+
return Promise.reject(error)
|
|
374
|
+
}
|
|
375
|
+
const scope = this.#container.openScope([
|
|
376
|
+
[HttpTokens.Context, ctx],
|
|
377
|
+
[HttpTokens.RequestId, ctx.requestId],
|
|
378
|
+
])
|
|
379
|
+
if (wrapped) {
|
|
380
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
381
|
+
x.done = scope.run(this.#fastWrapped, x, route)
|
|
382
|
+
return this.#fastExchange(x, scope)
|
|
383
|
+
}
|
|
384
|
+
const done = scope.run(this.#fastRun, route, request, ctx, startTime)
|
|
385
|
+
if (done instanceof Exchange) return this.#fastExchange(done, scope)
|
|
386
|
+
const closing = scope.close()
|
|
387
|
+
if (closing === undefined) {
|
|
388
|
+
ctx.complete()
|
|
389
|
+
this.#leave()
|
|
390
|
+
} else {
|
|
391
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
392
|
+
x.answer(done)
|
|
393
|
+
closing.then(
|
|
394
|
+
() => this.#over(x),
|
|
395
|
+
(error: unknown) => this.#disposalFailed(x, error),
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
// listeners are checked once the scope callback paused (one may have been registered by the handler)
|
|
399
|
+
if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
|
|
400
|
+
const event = requestEvent(request, ctx, startTime ?? performance.now())
|
|
401
|
+
event.status = done.status
|
|
402
|
+
return Promise.resolve(done).then(reply => {
|
|
403
|
+
hooks.emit('http:request', event)
|
|
404
|
+
return reply
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The reply of a fast-lane request whose asynchronous part runs in `x.done`: the request ends once
|
|
410
|
+
* it settled (see {@link #end}); listeners get the event once the reply is ready.
|
|
411
|
+
*/
|
|
412
|
+
#fastExchange(x: Exchange, scope: OpenedScope): Promise<AdapterReply> {
|
|
413
|
+
;(x.done as Promise<void>).then(
|
|
414
|
+
() => this.#end(x, scope, undefined),
|
|
415
|
+
(error: unknown) => this.#end(x, scope, error),
|
|
416
|
+
)
|
|
417
|
+
if (!this.#hooks.hasListeners('http:request')) return x.replied
|
|
418
|
+
x.tracked = true
|
|
419
|
+
return this.#emitted(x)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The fast lane in the request scope: the reply when the handler answered synchronously with a
|
|
424
|
+
* plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
|
|
425
|
+
* `startTime`: the request has `http:request` listeners, its event starts with the exchange.
|
|
426
|
+
*/
|
|
427
|
+
readonly #fastRun = (
|
|
428
|
+
route: FastRoute,
|
|
429
|
+
request: AdapterRequest,
|
|
430
|
+
ctx: RequestContext,
|
|
431
|
+
startTime: number | undefined,
|
|
432
|
+
): AdapterReply | Exchange => {
|
|
433
|
+
const plan = route.plan
|
|
434
|
+
if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT, startTime)
|
|
435
|
+
let result: unknown
|
|
436
|
+
let reply: AdapterReply | undefined
|
|
437
|
+
try {
|
|
438
|
+
result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
|
|
439
|
+
if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
|
|
440
|
+
reply = this.#fastReply(route, ctx, result)
|
|
441
|
+
} catch (error) {
|
|
442
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
443
|
+
x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
|
|
444
|
+
return x
|
|
445
|
+
}
|
|
446
|
+
if (reply !== undefined) {
|
|
447
|
+
ctx.commit()
|
|
448
|
+
ctx.releaseBody()
|
|
449
|
+
return reply
|
|
450
|
+
}
|
|
451
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
452
|
+
x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
|
|
453
|
+
return x
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The fast lane of a route with a body to read or a handler that returned a thenable. */
|
|
457
|
+
#fastLater(
|
|
458
|
+
route: FastRoute,
|
|
459
|
+
request: AdapterRequest,
|
|
460
|
+
ctx: RequestContext,
|
|
461
|
+
result: unknown,
|
|
462
|
+
startTime: number | undefined,
|
|
463
|
+
): Exchange {
|
|
464
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
465
|
+
x.done = this.#fastAsync(x, route, result)
|
|
466
|
+
return x
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The asynchronous part of the fast lane, in the request scope: the body read (`result` is
|
|
471
|
+
* {@link NO_RESULT}) and the handler run, or the thenable it returned awaited. Never rejects.
|
|
472
|
+
*/
|
|
473
|
+
async #fastAsync(x: Exchange, route: FastRoute, pending: unknown): Promise<void> {
|
|
474
|
+
const ctx = x.ctx
|
|
475
|
+
const plan = route.plan
|
|
476
|
+
let result = pending
|
|
477
|
+
let reply: AdapterReply | undefined
|
|
478
|
+
try {
|
|
479
|
+
if (result === NO_RESULT) {
|
|
480
|
+
const controller = this.#resolve(plan.controller)
|
|
481
|
+
await ctx.parseBody()
|
|
482
|
+
result = plan.definition.invoke(controller, ctx)
|
|
483
|
+
}
|
|
484
|
+
if (isThenable(result)) result = await result
|
|
485
|
+
reply = this.#fastReply(route, ctx, result)
|
|
486
|
+
} catch (error) {
|
|
487
|
+
await this.#respond(x, () => this.#failed(error, ctx, x.event))
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
if (reply === undefined) {
|
|
491
|
+
const value = result
|
|
492
|
+
await this.#respond(x, () => this.#settle(ctx, plan, x.event, value))
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
ctx.commit()
|
|
496
|
+
ctx.releaseBody()
|
|
497
|
+
// a listener registered since the request started gets a complete event
|
|
498
|
+
if (x.tracked) x.event.status = reply.status
|
|
499
|
+
x.answer(reply)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The reply of a plain JSON value (see {@link isPlainJson}) or of `undefined`: what
|
|
504
|
+
* `serializeResult` and {@link #complete} produce for it, the headers in their final order.
|
|
505
|
+
* `undefined` when the result or the context needs the general pipeline. Throws like
|
|
506
|
+
* `JSON.stringify`.
|
|
507
|
+
*/
|
|
508
|
+
#fastReply(route: FastRoute, ctx: RequestContext, result: unknown): AdapterReply | undefined {
|
|
509
|
+
const status = result === undefined ? route.emptyStatus : route.jsonStatus
|
|
510
|
+
// draining since the request started (`connection: close`, see #frameworkHeaders)
|
|
511
|
+
if (status === undefined || ctx.hasResponseHeaders || ctx.hasCookies || this.draining) return undefined
|
|
512
|
+
const requestId = ctx.requestId
|
|
513
|
+
// a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
|
|
514
|
+
if (!this.#validIds && !isHeaderValue(requestId)) return undefined
|
|
515
|
+
if (result === undefined) return this.#templateReply(status, null, undefined, requestId)
|
|
516
|
+
if (!isPlainJson(result)) return undefined
|
|
517
|
+
const json = JSON.stringify(result)
|
|
518
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
|
|
519
|
+
// encoded once, its length from the bytes (see serialize.ts encodeText)
|
|
520
|
+
return this.#templateReply(status, utf8(json), json, requestId)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The reply of a JSON body (`text` set) or of no body, its headers copied from the template of
|
|
525
|
+
* the fast lane (see {@link #jsonHeaders}), completed with the length and the request id (one that
|
|
526
|
+
* can be sent).
|
|
527
|
+
*/
|
|
528
|
+
#templateReply(
|
|
529
|
+
status: number,
|
|
530
|
+
body: Uint8Array | null,
|
|
531
|
+
text: string | undefined,
|
|
532
|
+
requestId: RequestId,
|
|
533
|
+
): AdapterReply {
|
|
534
|
+
if (text === undefined) {
|
|
535
|
+
const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
|
|
536
|
+
const idHeader = this.#emptyIdHeader
|
|
537
|
+
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
538
|
+
return { status, headers, body: null }
|
|
539
|
+
}
|
|
540
|
+
const headers: Record<string, HeaderValue> = { ...this.#jsonHeaders }
|
|
541
|
+
headers['content-length'] = String((body as Uint8Array).byteLength)
|
|
542
|
+
const idHeader = this.#jsonIdHeader
|
|
543
|
+
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
544
|
+
return { status, headers, body, text }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The fast lane through the `http:request` wrappers (registered when the request started), in the
|
|
549
|
+
* request scope: what {@link #respond} does for the route, the handler run inside `next()` like
|
|
550
|
+
* {@link #fastRun}. A JSON value or `undefined` is serialised there as `serializeResult` does, and
|
|
551
|
+
* the reply completed from the template of the fast lane when the wrappers added no response header
|
|
552
|
+
* (`event.responseHeaders`) nor cookie (see {@link #fastDeliver}). Never rejects.
|
|
553
|
+
*/
|
|
554
|
+
readonly #fastWrapped = (x: Exchange, route: FastRoute): Promise<void> =>
|
|
555
|
+
this.#hooks
|
|
556
|
+
.run('http:request', x.event, () => this.#fastOperation(x, route))
|
|
557
|
+
.then(
|
|
558
|
+
() => this.#fastDeliver(x, false, undefined),
|
|
559
|
+
(error: unknown) => this.#fastDeliver(x, true, error),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
/** The operation wrapped by the `http:request` wrappers in the fast lane: the route run, its outcome checked. */
|
|
563
|
+
#fastOperation(x: Exchange, route: FastRoute): Promise<Outcome> {
|
|
564
|
+
const ctx = x.ctx
|
|
565
|
+
const plan = route.plan
|
|
566
|
+
if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
|
|
567
|
+
let result: unknown
|
|
568
|
+
try {
|
|
569
|
+
result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
|
|
570
|
+
} catch (error) {
|
|
571
|
+
return this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
572
|
+
}
|
|
573
|
+
if (isThenable(result)) return this.#fastOperationLater(x, route, result)
|
|
574
|
+
return this.#fastChecked(x, this.#fastOutcome(x, route, result))
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** {@link #fastOperation} with a body to read (`pending` is {@link NO_RESULT}) or a thenable result. */
|
|
578
|
+
async #fastOperationLater(x: Exchange, route: FastRoute, pending: unknown): Promise<Outcome> {
|
|
579
|
+
const ctx = x.ctx
|
|
580
|
+
const plan = route.plan
|
|
581
|
+
let result = pending
|
|
582
|
+
try {
|
|
583
|
+
if (result === NO_RESULT) {
|
|
584
|
+
const controller = this.#resolve(plan.controller)
|
|
585
|
+
await ctx.parseBody()
|
|
586
|
+
result = plan.definition.invoke(controller, ctx)
|
|
587
|
+
}
|
|
588
|
+
if (isThenable(result)) result = await result
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return await this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
591
|
+
}
|
|
592
|
+
return await this.#fastChecked(x, this.#fastOutcome(x, route, result))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The outcome of a handler result in {@link #fastOperation}: for a plain JSON value or `undefined`,
|
|
597
|
+
* the reply of `serializeResult` built directly (`x.fast` set); anything else as in the general
|
|
598
|
+
* pipeline (see {@link #settle}). Never throws nor rejects (errors are mapped).
|
|
599
|
+
*/
|
|
600
|
+
#fastOutcome(x: Exchange, route: FastRoute, result: unknown): Outcome | Promise<Outcome> {
|
|
601
|
+
const ctx = x.ctx
|
|
602
|
+
const event = x.event
|
|
603
|
+
const status = result === undefined ? route.emptyStatus : route.jsonStatus
|
|
604
|
+
if (status === undefined || ctx.hasResponseHeaders || (result !== undefined && !isPlainJson(result))) {
|
|
605
|
+
return this.#settle(ctx, route.plan, event, result)
|
|
606
|
+
}
|
|
607
|
+
let reply: SerializedReply
|
|
608
|
+
if (result === undefined) {
|
|
609
|
+
reply = { status, headers: fastRecord<HeaderValue>(), body: null }
|
|
610
|
+
} else {
|
|
611
|
+
let json: string | undefined
|
|
612
|
+
try {
|
|
613
|
+
json = JSON.stringify(result)
|
|
614
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
|
|
615
|
+
} catch (error) {
|
|
616
|
+
return this.#failed(error, ctx, event)
|
|
617
|
+
}
|
|
618
|
+
const headers = fastRecord<HeaderValue>()
|
|
619
|
+
headers['content-type'] = ContentType.json
|
|
620
|
+
const body = utf8(json)
|
|
621
|
+
headers['content-length'] = String(body.byteLength)
|
|
622
|
+
reply = { status, headers, body, text: json, ownHeaders: true }
|
|
623
|
+
}
|
|
624
|
+
event.status = status
|
|
625
|
+
x.fast = true
|
|
626
|
+
return reply
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* What the general pipeline does with an outcome inside the wrappers (see {@link #respond}): its
|
|
631
|
+
* headers and cookies checked, the async context of a streamed body captured; kept in `x.outcome`.
|
|
632
|
+
*/
|
|
633
|
+
#fastChecked(x: Exchange, pending: Outcome | Promise<Outcome>): Promise<Outcome> {
|
|
634
|
+
if (pending instanceof Promise) return pending.then(outcome => this.#fastKeep(x, outcome))
|
|
635
|
+
return Promise.resolve(this.#fastKeep(x, pending))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#fastKeep(x: Exchange, outcome: Outcome): Outcome {
|
|
639
|
+
// a reply of #fastOutcome is valid by construction (what #checked returns for it)
|
|
640
|
+
const checked = x.fast ? outcome : this.#checked(outcome, x.ctx, x.event)
|
|
641
|
+
if (checked !== RAW && isStreamed(checked.body)) x.run = captureContext()
|
|
642
|
+
x.outcome = checked
|
|
643
|
+
return checked
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Answers a fast-lane request once its wrappers settled: a reply of {@link #fastOutcome} gets the
|
|
648
|
+
* headers of the template when nothing was added to it (no response header from the wrappers, no
|
|
649
|
+
* cookie, not draining, a request id that can be sent), which is what {@link #complete} gives;
|
|
650
|
+
* anything else is completed by {@link #deliver}. Never rejects.
|
|
651
|
+
*/
|
|
652
|
+
#fastDeliver(x: Exchange, failed: boolean, hookError: unknown): Promise<void> | undefined {
|
|
653
|
+
const outcome = x.outcome
|
|
654
|
+
const ctx = x.ctx
|
|
655
|
+
if (outcome === undefined) return this.#fastUnhandled(x, failed ? hookError : NOT_CALLED)
|
|
656
|
+
if (failed) this.#wrapperFailed(x, hookError)
|
|
657
|
+
else if (
|
|
658
|
+
x.fast &&
|
|
659
|
+
outcome !== RAW &&
|
|
660
|
+
!hasOwnKeys(x.event.responseHeaders) &&
|
|
661
|
+
!ctx.hasCookies &&
|
|
662
|
+
!this.draining &&
|
|
663
|
+
(this.#validIds || isHeaderValue(x.requestId))
|
|
664
|
+
) {
|
|
665
|
+
const reply = this.#templateReply(outcome.status, outcome.body as Uint8Array | null, outcome.text, x.requestId)
|
|
666
|
+
ctx.commit()
|
|
667
|
+
ctx.releaseBody()
|
|
668
|
+
x.answer(reply)
|
|
669
|
+
return undefined
|
|
670
|
+
}
|
|
671
|
+
const streaming = this.#send(x, outcome, true, x.run)
|
|
672
|
+
if (streaming === undefined) return undefined
|
|
673
|
+
return streaming.tracked.done.then(failure => this.#streamed(x, streaming, failure))
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** {@link #fastDeliver} when a wrapper failed before the request was handled or did not call `next()`. */
|
|
677
|
+
async #fastUnhandled(x: Exchange, failure: unknown): Promise<void> {
|
|
678
|
+
const streaming = this.#send(x, await this.#unhandled(x, failure), true, undefined)
|
|
679
|
+
if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
|
|
683
|
+
#end(x: Exchange, scope: OpenedScope, error: unknown): void {
|
|
684
|
+
const closing = scope.close()
|
|
685
|
+
if (error !== undefined) {
|
|
686
|
+
// like runInScope: the scope closes, then the failure is reported (#respond never rejects)
|
|
687
|
+
const report = (): void => this.#disposalFailed(x, error)
|
|
688
|
+
if (closing === undefined) report()
|
|
689
|
+
else closing.then(report, report)
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
if (closing === undefined) this.#over(x)
|
|
693
|
+
else
|
|
694
|
+
closing.then(
|
|
695
|
+
() => this.#over(x),
|
|
696
|
+
(failure: unknown) => this.#disposalFailed(x, failure),
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
#disposalFailed(x: Exchange, error: unknown): void {
|
|
701
|
+
this.#over(x)
|
|
702
|
+
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
|
|
703
|
+
}
|
|
704
|
+
|
|
199
705
|
/**
|
|
200
706
|
* Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
|
|
201
707
|
* open) until a streamed or file response body has been fully read or cancelled by the adapter.
|
|
@@ -304,163 +810,215 @@ export class Pipeline {
|
|
|
304
810
|
body: BodyPlan,
|
|
305
811
|
): Promise<AdapterReply> {
|
|
306
812
|
this.#enter()
|
|
307
|
-
let
|
|
308
|
-
let ctx: RequestContext
|
|
309
|
-
let event: HttpRequestEvent
|
|
813
|
+
let x: Exchange
|
|
310
814
|
try {
|
|
311
|
-
requestId = this.#requestId(request)
|
|
312
|
-
const
|
|
313
|
-
ctx = new RequestContext(request, {
|
|
314
|
-
requestId,
|
|
315
|
-
adapter: this.#options.adapter,
|
|
316
|
-
url,
|
|
317
|
-
route,
|
|
318
|
-
params,
|
|
319
|
-
body,
|
|
320
|
-
cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
|
|
321
|
-
client:
|
|
322
|
-
proxy === undefined
|
|
323
|
-
? undefined
|
|
324
|
-
: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true),
|
|
325
|
-
})
|
|
815
|
+
const requestId = this.#requestId(request)
|
|
816
|
+
const ctx = this.#context(request, requestId, url.path, url.query, route, params, body)
|
|
326
817
|
// an event stream requested while draining is served (guards run) with its signal already
|
|
327
818
|
// aborted: a well-behaved source ends at once and the client gets a jittered retry
|
|
328
819
|
if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
|
|
329
|
-
|
|
330
|
-
method: request.method,
|
|
331
|
-
path: ctx.path,
|
|
332
|
-
route: route?.path,
|
|
333
|
-
requestId,
|
|
334
|
-
headers: request.headers,
|
|
335
|
-
context: ctx,
|
|
336
|
-
startTime,
|
|
337
|
-
responseHeaders: {},
|
|
338
|
-
}
|
|
820
|
+
x = new Exchange(request, ctx, requestId, target, startTime)
|
|
339
821
|
} catch (error) {
|
|
340
822
|
// failed before its scope opened (a request id generator that throws...)
|
|
341
823
|
this.#leave()
|
|
342
824
|
return Promise.reject(error)
|
|
343
825
|
}
|
|
344
|
-
let outcome: Outcome | undefined
|
|
345
|
-
let cors: CorsDecision | undefined
|
|
346
|
-
const preflight = target.kind === 'preflight'
|
|
347
|
-
let resolve: (reply: AdapterReply) => void = () => undefined
|
|
348
|
-
let reject: (error: unknown) => void = () => undefined
|
|
349
|
-
const replied = new Promise<AdapterReply>((onReply, onError) => {
|
|
350
|
-
resolve = onReply
|
|
351
|
-
reject = onError
|
|
352
|
-
})
|
|
353
|
-
let answered = false
|
|
354
|
-
const answer = (reply: AdapterReply): void => {
|
|
355
|
-
answered = true
|
|
356
|
-
resolve(reply)
|
|
357
|
-
}
|
|
358
|
-
/** The request is over (response body sent or abandoned, scope closed). */
|
|
359
|
-
const over = (): void => {
|
|
360
|
-
ctx.complete()
|
|
361
|
-
try {
|
|
362
|
-
if (!answered) {
|
|
363
|
-
const failure = new Error('Request not handled')
|
|
364
|
-
const reply = serializeError(500, this.#internalBody(failure), ctx.method)
|
|
365
|
-
const completed = this.#complete(reply, ctx, event, requestId, cors, preflight, true)
|
|
366
|
-
event.status = 500
|
|
367
|
-
answer(completed)
|
|
368
|
-
}
|
|
369
|
-
} catch (error) {
|
|
370
|
-
reject(error)
|
|
371
|
-
} finally {
|
|
372
|
-
this.#leave()
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
826
|
// the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
|
|
376
827
|
this.#container
|
|
377
|
-
.runInScope(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
} else {
|
|
393
|
-
// what hooks.run() does without wrappers, in the same async context
|
|
394
|
-
outcome = this.#checked(await this.#process(ctx, target, event, cors), ctx, event)
|
|
395
|
-
}
|
|
396
|
-
} catch (error) {
|
|
397
|
-
failed = true
|
|
398
|
-
hookError = error
|
|
399
|
-
}
|
|
400
|
-
if (outcome === undefined) {
|
|
401
|
-
// a wrapper failed before the request was handled, or did not call next(): answer anyway
|
|
402
|
-
const error = failed ? hookError : new Error('An "http:request" hook wrapper did not call next()')
|
|
403
|
-
event.error ??= error
|
|
404
|
-
// the same checks as a handled reply: an exception mapper's HttpResponse may carry cookies
|
|
405
|
-
const fallback = await this.#errorReply(error, ctx)
|
|
406
|
-
outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
|
|
407
|
-
event.status = outcome.status
|
|
408
|
-
recheck = true
|
|
409
|
-
} else if (failed) {
|
|
410
|
-
this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
|
|
411
|
-
err: hookError,
|
|
412
|
-
requestId,
|
|
413
|
-
})
|
|
414
|
-
}
|
|
415
|
-
if (outcome === RAW) {
|
|
416
|
-
ctx.commit()
|
|
417
|
-
const status = request.rawStatus?.() ?? 200
|
|
418
|
-
event.status ??= status
|
|
419
|
-
answer({ status, headers: {}, body: null, raw: true })
|
|
420
|
-
return
|
|
421
|
-
}
|
|
422
|
-
const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight, recheck)
|
|
423
|
-
const body = serialized.body
|
|
424
|
-
if (!isStreamed(body)) {
|
|
425
|
-
const reply = adapterReply(serialized, body)
|
|
426
|
-
ctx.commit()
|
|
427
|
-
ctx.releaseBody()
|
|
428
|
-
answer(reply)
|
|
429
|
-
return
|
|
430
|
-
}
|
|
431
|
-
// files too: a file-backed Blob is read lazily, after the handler returned
|
|
432
|
-
const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
|
|
433
|
-
const session = serialized.sse
|
|
434
|
-
const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
|
|
435
|
-
if (session !== undefined) {
|
|
436
|
-
this.#streams.add(session)
|
|
437
|
-
if (this.draining) session.close('shutdown')
|
|
438
|
-
}
|
|
439
|
-
ctx.commit()
|
|
440
|
-
answer(reply)
|
|
441
|
-
const failure = await tracked.done
|
|
442
|
-
if (session !== undefined) this.#streams.delete(session)
|
|
443
|
-
if (failure !== undefined) {
|
|
444
|
-
this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
|
|
445
|
-
}
|
|
446
|
-
ctx.releaseBody()
|
|
447
|
-
}, [
|
|
448
|
-
[HttpTokens.Context, ctx],
|
|
449
|
-
[HttpTokens.RequestId, requestId],
|
|
450
|
-
])
|
|
451
|
-
.then(over, (error: unknown) => {
|
|
452
|
-
over()
|
|
453
|
-
// the callback above never throws: only the disposal of request-scoped instances can fail
|
|
454
|
-
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId })
|
|
455
|
-
})
|
|
828
|
+
.runInScope(
|
|
829
|
+
() => this.#respond(x, undefined),
|
|
830
|
+
[
|
|
831
|
+
[HttpTokens.Context, x.ctx],
|
|
832
|
+
[HttpTokens.RequestId, x.requestId],
|
|
833
|
+
],
|
|
834
|
+
)
|
|
835
|
+
.then(
|
|
836
|
+
() => this.#over(x),
|
|
837
|
+
(error: unknown) => {
|
|
838
|
+
this.#over(x)
|
|
839
|
+
// #respond never rejects: only the disposal of request-scoped instances can fail
|
|
840
|
+
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
|
|
841
|
+
},
|
|
842
|
+
)
|
|
456
843
|
// listeners run once the reply is ready, before the adapter gets it, out of the request scope
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
844
|
+
return this.#hooks.hasListeners('http:request') ? this.#emitted(x) : x.replied
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** The reply of a request once the `http:request` listeners saw its event. */
|
|
848
|
+
#emitted(x: Exchange): Promise<AdapterReply> {
|
|
849
|
+
return x.replied.then(reply => {
|
|
850
|
+
this.#hooks.emit('http:request', x.event)
|
|
460
851
|
return reply
|
|
461
852
|
})
|
|
462
853
|
}
|
|
463
854
|
|
|
855
|
+
/** The request context of a request (internal). */
|
|
856
|
+
#context(
|
|
857
|
+
request: AdapterRequest,
|
|
858
|
+
requestId: RequestId,
|
|
859
|
+
path: string,
|
|
860
|
+
query: string,
|
|
861
|
+
route: HttpRouteInfo | undefined,
|
|
862
|
+
params: Record<string, string>,
|
|
863
|
+
body: BodyPlan,
|
|
864
|
+
): RequestContext {
|
|
865
|
+
const proxy = this.#options.trustProxy
|
|
866
|
+
return new RequestContext(
|
|
867
|
+
request,
|
|
868
|
+
requestId,
|
|
869
|
+
this.#options.adapter,
|
|
870
|
+
path,
|
|
871
|
+
query,
|
|
872
|
+
route,
|
|
873
|
+
params,
|
|
874
|
+
body,
|
|
875
|
+
this.#cookies,
|
|
876
|
+
proxy === undefined
|
|
877
|
+
? undefined
|
|
878
|
+
: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true),
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Answers a request in its scope: its outcome (`process`, or the target of the exchange after the
|
|
884
|
+
* CORS decision and through the `http:request` wrappers), checked, completed and handed to the
|
|
885
|
+
* adapter; a streamed body is then tracked until it was sent or abandoned. Never rejects.
|
|
886
|
+
* `process` is given by the fast lane, which decided at the start of the request that no wrapper
|
|
887
|
+
* applies and that CORS is off.
|
|
888
|
+
*/
|
|
889
|
+
async #respond(x: Exchange, process: (() => Outcome | Promise<Outcome>) | undefined): Promise<void> {
|
|
890
|
+
const ctx = x.ctx
|
|
891
|
+
const requestId = x.requestId
|
|
892
|
+
const event = x.event
|
|
893
|
+
let outcome: Outcome | undefined
|
|
894
|
+
let hookError: unknown
|
|
895
|
+
let failed = false
|
|
896
|
+
let run: ContextRunner | undefined
|
|
897
|
+
// only a wrapper can change a reply between its checks below and the adapter
|
|
898
|
+
let recheck = false
|
|
899
|
+
let work = process
|
|
900
|
+
if (work === undefined) {
|
|
901
|
+
if (this.#options.cors !== undefined) x.cors = await this.#corsDecision(x.request, requestId)
|
|
902
|
+
recheck = this.#hooks.hasWrappers('http:request')
|
|
903
|
+
const target = x.target
|
|
904
|
+
work = () => this.#process(ctx, target, event, x.cors)
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
if (recheck) {
|
|
908
|
+
const inner = work
|
|
909
|
+
await this.#hooks.run('http:request', event, async () => {
|
|
910
|
+
outcome = this.#checked(await inner(), ctx, event)
|
|
911
|
+
// stream producers run in the context of the handler (scope, wrappers)
|
|
912
|
+
if (outcome !== RAW && isStreamed(outcome.body)) run = captureContext()
|
|
913
|
+
return outcome
|
|
914
|
+
})
|
|
915
|
+
} else {
|
|
916
|
+
// what hooks.run() does without wrappers, in the same async context
|
|
917
|
+
outcome = this.#checked(await work(), ctx, event)
|
|
918
|
+
}
|
|
919
|
+
} catch (error) {
|
|
920
|
+
failed = true
|
|
921
|
+
hookError = error
|
|
922
|
+
}
|
|
923
|
+
if (outcome === undefined) {
|
|
924
|
+
outcome = await this.#unhandled(x, failed ? hookError : NOT_CALLED)
|
|
925
|
+
recheck = true
|
|
926
|
+
} else if (failed) {
|
|
927
|
+
this.#wrapperFailed(x, hookError)
|
|
928
|
+
}
|
|
929
|
+
const streaming = this.#send(x, outcome, recheck, run)
|
|
930
|
+
if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
|
|
935
|
+
* did not call `next()` ({@link NOT_CALLED}): the error mapped and checked like a handled reply (an
|
|
936
|
+
* exception mapper's `HttpResponse` may carry cookies). Never rejects.
|
|
937
|
+
*/
|
|
938
|
+
async #unhandled(x: Exchange, failure: unknown): Promise<CheckedReply> {
|
|
939
|
+
const ctx = x.ctx
|
|
940
|
+
const event = x.event
|
|
941
|
+
const error = failure === NOT_CALLED ? new Error('An "http:request" hook wrapper did not call next()') : failure
|
|
942
|
+
event.error ??= error
|
|
943
|
+
const fallback = await this.#errorReply(error, ctx)
|
|
944
|
+
const reply = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
|
|
945
|
+
event.status = reply.status
|
|
946
|
+
return reply
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
#wrapperFailed(x: Exchange, error: unknown): void {
|
|
950
|
+
this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
|
|
951
|
+
err: error,
|
|
952
|
+
requestId: x.requestId,
|
|
953
|
+
})
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Completes the outcome of a request (`recheck`: wrappers ran) and hands its reply to the adapter.
|
|
958
|
+
* Returns the tracking of a streamed body (read in `run`, the context of the handler, when it was
|
|
959
|
+
* captured), whose end {@link #streamed} records.
|
|
960
|
+
*/
|
|
961
|
+
#send(x: Exchange, outcome: Outcome, recheck: boolean, run: ContextRunner | undefined): Streaming | undefined {
|
|
962
|
+
const ctx = x.ctx
|
|
963
|
+
const event = x.event
|
|
964
|
+
if (outcome === RAW) {
|
|
965
|
+
ctx.commit()
|
|
966
|
+
const status = x.request.rawStatus?.() ?? 200
|
|
967
|
+
event.status ??= status
|
|
968
|
+
x.answer({ status, headers: {}, body: null, raw: true })
|
|
969
|
+
return undefined
|
|
970
|
+
}
|
|
971
|
+
const serialized = this.#complete(outcome, ctx, event, x.requestId, x.cors, x.preflight, recheck)
|
|
972
|
+
const body = serialized.body
|
|
973
|
+
if (!isStreamed(body)) {
|
|
974
|
+
const reply = adapterReply(serialized, body)
|
|
975
|
+
ctx.commit()
|
|
976
|
+
ctx.releaseBody()
|
|
977
|
+
x.answer(reply)
|
|
978
|
+
return undefined
|
|
979
|
+
}
|
|
980
|
+
// files too: a file-backed Blob is read lazily, after the handler returned
|
|
981
|
+
const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
|
|
982
|
+
const session = serialized.sse
|
|
983
|
+
const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
|
|
984
|
+
if (session !== undefined) {
|
|
985
|
+
this.#streams.add(session)
|
|
986
|
+
if (this.draining) session.close('shutdown')
|
|
987
|
+
}
|
|
988
|
+
ctx.commit()
|
|
989
|
+
x.answer(reply)
|
|
990
|
+
return { tracked, session }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** A streamed body was sent or abandoned (`failure`: the stream failed). */
|
|
994
|
+
#streamed(x: Exchange, streaming: Streaming, failure: unknown): void {
|
|
995
|
+
const ctx = x.ctx
|
|
996
|
+
if (streaming.session !== undefined) this.#streams.delete(streaming.session)
|
|
997
|
+
if (failure !== undefined) {
|
|
998
|
+
this.#logger.error('The response stream failed', { err: failure, requestId: x.requestId, path: ctx.path })
|
|
999
|
+
}
|
|
1000
|
+
ctx.releaseBody()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** The request is over (response body sent or abandoned, scope closed). */
|
|
1004
|
+
#over(x: Exchange): void {
|
|
1005
|
+
const ctx = x.ctx
|
|
1006
|
+
ctx.complete()
|
|
1007
|
+
try {
|
|
1008
|
+
if (!x.answered) {
|
|
1009
|
+
const failure = new Error('Request not handled')
|
|
1010
|
+
const reply = serializeError(500, this.#internalBody(failure), ctx.method)
|
|
1011
|
+
const completed = this.#complete(reply, ctx, x.event, x.requestId, x.cors, x.preflight, true)
|
|
1012
|
+
x.event.status = 500
|
|
1013
|
+
x.answer(completed)
|
|
1014
|
+
}
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
x.fail(error)
|
|
1017
|
+
} finally {
|
|
1018
|
+
this.#leave()
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
464
1022
|
/**
|
|
465
1023
|
* Adds the headers of the `http:request` wrappers, then the framework headers (see
|
|
466
1024
|
* {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
|
|
@@ -504,7 +1062,7 @@ export class Pipeline {
|
|
|
504
1062
|
#checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
|
|
505
1063
|
const cookies = reply.cookies
|
|
506
1064
|
if (cookies === undefined || cookies.length === 0) return reply
|
|
507
|
-
const settings = this.#
|
|
1065
|
+
const settings = this.#cookies
|
|
508
1066
|
const lines: SerializedCookie[] = []
|
|
509
1067
|
try {
|
|
510
1068
|
for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
|
|
@@ -645,6 +1203,7 @@ export class Pipeline {
|
|
|
645
1203
|
|
|
646
1204
|
/** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
|
|
647
1205
|
async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
|
|
1206
|
+
let result: unknown
|
|
648
1207
|
try {
|
|
649
1208
|
const controller = this.#resolve(plan.controller)
|
|
650
1209
|
for (const guard of plan.guards) {
|
|
@@ -654,46 +1213,80 @@ export class Pipeline {
|
|
|
654
1213
|
if (plan.parseBody) await ctx.parseBody()
|
|
655
1214
|
const definition = plan.definition
|
|
656
1215
|
const interceptors = plan.interceptors
|
|
657
|
-
let result: unknown
|
|
658
1216
|
if (interceptors.length === 0) {
|
|
659
1217
|
result = definition.invoke(controller, ctx)
|
|
660
|
-
if (isThenable(result)) result = await result
|
|
661
1218
|
} else {
|
|
662
1219
|
const chain = async (index: number): Promise<unknown> => {
|
|
663
1220
|
if (index === interceptors.length) return await definition.invoke(controller, ctx)
|
|
664
1221
|
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
665
1222
|
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
666
1223
|
}
|
|
667
|
-
result =
|
|
668
|
-
}
|
|
669
|
-
if (definition.rawResponse === true && !(result instanceof Response)) return RAW
|
|
670
|
-
const options: SerializeOptions = {
|
|
671
|
-
method: ctx.method,
|
|
672
|
-
routeMethod: definition.method,
|
|
673
|
-
status: definition.status,
|
|
674
|
-
headers: definition.headers,
|
|
675
|
-
context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
|
|
1224
|
+
result = chain(0)
|
|
676
1225
|
}
|
|
677
|
-
const sse = definition.sse
|
|
678
|
-
let reply: SerializedReply
|
|
679
|
-
if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
|
|
680
|
-
// an @Sse route: the (awaited) result is the event source
|
|
681
|
-
if (!isSseSource(result)) {
|
|
682
|
-
throw new TypeError(
|
|
683
|
-
`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
|
|
684
|
-
)
|
|
685
|
-
}
|
|
686
|
-
reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options)
|
|
687
|
-
} else {
|
|
688
|
-
// only an HttpResponse may need an asynchronous step (a file to open)
|
|
689
|
-
reply = isHttpResponse(result)
|
|
690
|
-
? await this.#serialize(result, ctx, options, sse)
|
|
691
|
-
: serializeResult(result, options)
|
|
692
|
-
}
|
|
693
|
-
return this.#finish(event, reply)
|
|
694
1226
|
} catch (error) {
|
|
695
1227
|
return await this.#failed(error, ctx, event)
|
|
696
1228
|
}
|
|
1229
|
+
const settled = this.#settle(ctx, plan, event, result)
|
|
1230
|
+
return settled instanceof Promise ? await settled : settled
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* The rest of {@link #invoke} once the handler (and its interceptors) returned `result`: awaited
|
|
1235
|
+
* when it is a thenable, then serialised. Never throws nor rejects (errors are mapped).
|
|
1236
|
+
*/
|
|
1237
|
+
#settle(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
|
|
1238
|
+
if (isThenable(result)) return this.#settleLater(ctx, plan, event, result)
|
|
1239
|
+
try {
|
|
1240
|
+
const outcome = this.#outcome(ctx, plan, event, result)
|
|
1241
|
+
return outcome instanceof Promise ? outcome.catch(error => this.#failed(error, ctx, event)) : outcome
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
return this.#failed(error, ctx, event)
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async #settleLater(
|
|
1248
|
+
ctx: RequestContext,
|
|
1249
|
+
plan: RoutePlan,
|
|
1250
|
+
event: HttpRequestEvent,
|
|
1251
|
+
pending: PromiseLike<unknown>,
|
|
1252
|
+
): Promise<Outcome> {
|
|
1253
|
+
let result: unknown
|
|
1254
|
+
try {
|
|
1255
|
+
result = await pending
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
return await this.#failed(error, ctx, event)
|
|
1258
|
+
}
|
|
1259
|
+
const settled = this.#settle(ctx, plan, event, result)
|
|
1260
|
+
return settled instanceof Promise ? await settled : settled
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The outcome of a handler result: `RAW` for a `@Res()` route, otherwise its reply (files resolved,
|
|
1265
|
+
* event streams opened: only an `HttpResponse` may need an asynchronous step). Throws (or rejects)
|
|
1266
|
+
* when the result cannot be served.
|
|
1267
|
+
*/
|
|
1268
|
+
#outcome(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
|
|
1269
|
+
const definition = plan.definition
|
|
1270
|
+
if (definition.rawResponse === true && !(result instanceof Response)) return RAW
|
|
1271
|
+
const options: SerializeOptions = {
|
|
1272
|
+
method: ctx.method,
|
|
1273
|
+
routeMethod: definition.method,
|
|
1274
|
+
status: definition.status,
|
|
1275
|
+
headers: definition.headers,
|
|
1276
|
+
context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
|
|
1277
|
+
}
|
|
1278
|
+
const sse = definition.sse
|
|
1279
|
+
if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
|
|
1280
|
+
// an @Sse route: the (awaited) result is the event source
|
|
1281
|
+
if (!isSseSource(result)) {
|
|
1282
|
+
throw new TypeError(
|
|
1283
|
+
`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
|
|
1284
|
+
)
|
|
1285
|
+
}
|
|
1286
|
+
return this.#finish(event, serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options))
|
|
1287
|
+
}
|
|
1288
|
+
if (!isHttpResponse(result)) return this.#finish(event, serializeResult(result, options))
|
|
1289
|
+
return this.#serialize(result, ctx, options, sse).then(reply => this.#finish(event, reply))
|
|
697
1290
|
}
|
|
698
1291
|
|
|
699
1292
|
/** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
|
|
@@ -868,6 +1461,172 @@ export class Pipeline {
|
|
|
868
1461
|
}
|
|
869
1462
|
}
|
|
870
1463
|
|
|
1464
|
+
/** A route served by the fast lane (see `Pipeline.handler`), decided at boot. */
|
|
1465
|
+
interface FastRoute {
|
|
1466
|
+
readonly plan: RoutePlan
|
|
1467
|
+
readonly target: Target
|
|
1468
|
+
/** The method of the route definition (requests of another method take the general pipeline). */
|
|
1469
|
+
readonly method: string
|
|
1470
|
+
/** The status of a JSON reply, `undefined` when it has no body (such replies take the general pipeline). */
|
|
1471
|
+
readonly jsonStatus: number | undefined
|
|
1472
|
+
/** The status of a reply without value (`undefined` returned). */
|
|
1473
|
+
readonly emptyStatus: number
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** `#fastAsync` reads the body and runs the handler. */
|
|
1477
|
+
const NO_RESULT: unique symbol = Symbol('no result')
|
|
1478
|
+
|
|
1479
|
+
/** An `http:request` wrapper returned without calling `next()` (see `Pipeline.#unhandled`). */
|
|
1480
|
+
const NOT_CALLED: unique symbol = Symbol('next() not called')
|
|
1481
|
+
|
|
1482
|
+
/** A streamed body handed to the adapter, until it was sent or abandoned (see `Pipeline.#send`). */
|
|
1483
|
+
interface Streaming {
|
|
1484
|
+
readonly tracked: TrackedStream
|
|
1485
|
+
readonly session: SseSession | undefined
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** A request being answered (internal): its context, its target and the promise of its reply. */
|
|
1489
|
+
class Exchange {
|
|
1490
|
+
readonly request: AdapterRequest
|
|
1491
|
+
readonly ctx: RequestContext
|
|
1492
|
+
readonly requestId: RequestId
|
|
1493
|
+
readonly target: Target
|
|
1494
|
+
readonly preflight: boolean
|
|
1495
|
+
readonly replied: Promise<AdapterReply>
|
|
1496
|
+
cors: CorsDecision | undefined = undefined
|
|
1497
|
+
answered = false
|
|
1498
|
+
/** The `http:request` listeners will see the event (fast lane: keep its status). */
|
|
1499
|
+
tracked = false
|
|
1500
|
+
/** The asynchronous part of a fast-lane request (never rejects). */
|
|
1501
|
+
done: Promise<void> | undefined = undefined
|
|
1502
|
+
/** Fast lane with wrappers: the outcome of the route once checked (see `Pipeline.#fastKeep`). */
|
|
1503
|
+
outcome: Outcome | undefined = undefined
|
|
1504
|
+
/** Fast lane with wrappers: `outcome` is a JSON reply or a reply without body built by the lane. */
|
|
1505
|
+
fast = false
|
|
1506
|
+
/** Fast lane with wrappers: the async context of the handler, for a streamed body. */
|
|
1507
|
+
run: ContextRunner | undefined = undefined
|
|
1508
|
+
readonly #resolve: (reply: AdapterReply) => void
|
|
1509
|
+
readonly #reject: (error: unknown) => void
|
|
1510
|
+
#event: HttpRequestEvent | undefined
|
|
1511
|
+
|
|
1512
|
+
/** `startTime`: the event is created now (general pipeline); otherwise when first needed (fast lane). */
|
|
1513
|
+
constructor(request: AdapterRequest, ctx: RequestContext, requestId: RequestId, target: Target, startTime?: number) {
|
|
1514
|
+
this.request = request
|
|
1515
|
+
this.ctx = ctx
|
|
1516
|
+
this.requestId = requestId
|
|
1517
|
+
this.target = target
|
|
1518
|
+
this.preflight = target.kind === 'preflight'
|
|
1519
|
+
let resolve: ((reply: AdapterReply) => void) | undefined
|
|
1520
|
+
let reject: ((error: unknown) => void) | undefined
|
|
1521
|
+
this.replied = new Promise<AdapterReply>((onReply, onError) => {
|
|
1522
|
+
resolve = onReply
|
|
1523
|
+
reject = onError
|
|
1524
|
+
})
|
|
1525
|
+
this.#resolve = resolve as (reply: AdapterReply) => void
|
|
1526
|
+
this.#reject = reject as (error: unknown) => void
|
|
1527
|
+
if (startTime !== undefined) this.#event = requestEvent(request, ctx, startTime)
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* The `http:request` event. The fast lane creates it when the general pipeline takes over: no
|
|
1532
|
+
* hook saw the request start, so its `startTime` is not observed.
|
|
1533
|
+
*/
|
|
1534
|
+
get event(): HttpRequestEvent {
|
|
1535
|
+
this.#event ??= requestEvent(this.request, this.ctx, performance.now())
|
|
1536
|
+
return this.#event
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
answer(reply: AdapterReply): void {
|
|
1540
|
+
this.answered = true
|
|
1541
|
+
this.#resolve(reply)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
fail(error: unknown): void {
|
|
1545
|
+
this.#reject(error)
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** The `http:request` event of a request. */
|
|
1550
|
+
function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: number): HttpRequestEvent {
|
|
1551
|
+
return {
|
|
1552
|
+
method: request.method,
|
|
1553
|
+
path: ctx.path,
|
|
1554
|
+
route: ctx.route?.path,
|
|
1555
|
+
requestId: ctx.requestId,
|
|
1556
|
+
headers: request.headers,
|
|
1557
|
+
context: ctx,
|
|
1558
|
+
startTime,
|
|
1559
|
+
responseHeaders: {},
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** Whether a record has an own enumerable property (`event.responseHeaders` was written to). */
|
|
1564
|
+
function hasOwnKeys(record: object): boolean {
|
|
1565
|
+
for (const key in record) if (Object.hasOwn(record, key)) return true
|
|
1566
|
+
return false
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* The decoded parameters of a raw request path matched against a route, in one pass: what
|
|
1571
|
+
* `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
|
|
1572
|
+
* `undefined` when the path does not match or a value is not valid percent-encoding: the general
|
|
1573
|
+
* pipeline then answers it (a reroute, a 400).
|
|
1574
|
+
*/
|
|
1575
|
+
function routeParams(route: RoutePath, path: string): Record<string, string> | undefined {
|
|
1576
|
+
if (path.charCodeAt(0) !== 47 /* / */) return undefined
|
|
1577
|
+
const params = emptyRecord<string>()
|
|
1578
|
+
const segments = route.segments
|
|
1579
|
+
const last = segments.length - 1
|
|
1580
|
+
if (last === -1) return path === '/' ? params : undefined
|
|
1581
|
+
let position = 1
|
|
1582
|
+
for (let index = 0; index <= last; index++) {
|
|
1583
|
+
const segment = segments[index] as RouteSegment
|
|
1584
|
+
if (segment.kind === 'wildcard') {
|
|
1585
|
+
const rest = decodePathValue(path.slice(position))
|
|
1586
|
+
if (rest === undefined) return undefined
|
|
1587
|
+
params[WILDCARD_PARAM] = rest
|
|
1588
|
+
return params
|
|
1589
|
+
}
|
|
1590
|
+
const slash = path.indexOf('/', position)
|
|
1591
|
+
const end = slash === -1 ? path.length : slash
|
|
1592
|
+
if (segment.kind === 'static') {
|
|
1593
|
+
const value = segment.value
|
|
1594
|
+
if (end - position !== value.length || !path.startsWith(value, position)) return undefined
|
|
1595
|
+
} else {
|
|
1596
|
+
if (end === position) return undefined
|
|
1597
|
+
const decoded = decodePathValue(path.slice(position, end))
|
|
1598
|
+
if (decoded === undefined) return undefined
|
|
1599
|
+
params[segment.name] = decoded
|
|
1600
|
+
}
|
|
1601
|
+
if (index === last) return end === path.length ? params : undefined
|
|
1602
|
+
if (slash === -1) return undefined
|
|
1603
|
+
position = slash + 1
|
|
1604
|
+
}
|
|
1605
|
+
return params
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* A value the fast lane serialises as JSON: a plain object or array (its own prototype and
|
|
1610
|
+
* constructor), not duck-typed as a Node.js stream. Anything `serializeResult` could treat otherwise
|
|
1611
|
+
* (`HttpResponse`, `Response`, bytes, streams, `Blob`, strings, `undefined`...) is not.
|
|
1612
|
+
*/
|
|
1613
|
+
function isPlainJson(value: unknown): value is object {
|
|
1614
|
+
if (value === null || typeof value !== 'object') return false
|
|
1615
|
+
const prototype: unknown = Object.getPrototypeOf(value)
|
|
1616
|
+
const ctor = (value as { constructor?: unknown }).constructor
|
|
1617
|
+
if (prototype === Object.prototype) {
|
|
1618
|
+
if (ctor !== Object) return false
|
|
1619
|
+
} else if (prototype !== Array.prototype || ctor !== Array) {
|
|
1620
|
+
return false
|
|
1621
|
+
}
|
|
1622
|
+
return typeof (value as { pipe?: unknown }).pipe !== 'function'
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
const ENCODER = new TextEncoder()
|
|
1626
|
+
|
|
1627
|
+
/** UTF-8 encoding of a text body, like the serialiser (a pooled `Buffer` on Node.js, `TextEncoder` on Bun). */
|
|
1628
|
+
const utf8: (text: string) => Uint8Array = isBun() ? text => ENCODER.encode(text) : text => Buffer.from(text, 'utf8')
|
|
1629
|
+
|
|
871
1630
|
/**
|
|
872
1631
|
* The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
|
|
873
1632
|
* `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a
|