@znight/http 0.1.2 → 0.1.3
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 +79 -32
- package/dist/compiler.mjs +82 -13
- 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 +9 -1
- package/dist/index.mjs +813 -255
- 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/generate.ts +127 -22
- package/src/context.ts +148 -78
- package/src/index.ts +1 -1
- package/src/pipeline.ts +714 -176
- package/src/plugin.ts +1 -2
- 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'
|
|
45
|
+
import { isBodylessStatus } from './status.ts'
|
|
43
46
|
import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
|
|
44
47
|
import { HttpTokens } from './tokens.ts'
|
|
45
|
-
import { emptyRecord, errorMessage, isThenable } from './utils.ts'
|
|
48
|
+
import { emptyRecord, errorMessage, 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()
|
|
133
136
|
}
|
|
134
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()
|
|
187
|
+
}
|
|
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,242 @@ 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 no
|
|
343
|
+
* `http:request` wrapper nor listener is registered, the server is not draining and its method
|
|
344
|
+
* is the route's (not a `HEAD` served by a `GET` route); a plain object or array returned by the
|
|
345
|
+
* handler is serialised as JSON, and `undefined` answered without body, with the headers in their
|
|
346
|
+
* final order (a template precomputed at boot).
|
|
347
|
+
* A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
|
|
348
|
+
* path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
|
|
349
|
+
* or any other result, headers or cookies set through the context — continues in the general
|
|
350
|
+
* pipeline, in the same request scope, with the same outcome.
|
|
351
|
+
*/
|
|
352
|
+
#fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
|
|
353
|
+
const plan = route.plan
|
|
354
|
+
const hooks = this.#hooks
|
|
355
|
+
if (
|
|
356
|
+
this.draining ||
|
|
357
|
+
request.method !== route.method ||
|
|
358
|
+
hooks.hasWrappers('http:request') ||
|
|
359
|
+
hooks.hasListeners('http:request')
|
|
360
|
+
) {
|
|
361
|
+
return this.handle(plan, request)
|
|
362
|
+
}
|
|
363
|
+
const url = request.url
|
|
364
|
+
const mark = url.indexOf('?')
|
|
365
|
+
const path = mark === -1 ? url : url.slice(0, mark)
|
|
366
|
+
const params = routeParams(plan.path, path)
|
|
367
|
+
// an unusual path (see #route): nothing started yet, the general pipeline handles it
|
|
368
|
+
if (params === undefined) return this.handle(plan, request)
|
|
369
|
+
this.#enter()
|
|
370
|
+
let ctx: RequestContext
|
|
371
|
+
try {
|
|
372
|
+
const query = mark === -1 ? '' : url.slice(mark + 1)
|
|
373
|
+
ctx = this.#context(request, this.#requestId(request), path, query, plan.info, params, plan.body)
|
|
374
|
+
} catch (error) {
|
|
375
|
+
this.#leave()
|
|
376
|
+
return Promise.reject(error)
|
|
377
|
+
}
|
|
378
|
+
const scope = this.#container.openScope([
|
|
379
|
+
[HttpTokens.Context, ctx],
|
|
380
|
+
[HttpTokens.RequestId, ctx.requestId],
|
|
381
|
+
])
|
|
382
|
+
const done = scope.run(this.#fastRun, route, request, ctx)
|
|
383
|
+
if (done instanceof Exchange) {
|
|
384
|
+
;(done.done as Promise<void>).then(
|
|
385
|
+
() => this.#end(done, scope, undefined),
|
|
386
|
+
(error: unknown) => this.#end(done, scope, error),
|
|
387
|
+
)
|
|
388
|
+
if (!hooks.hasListeners('http:request')) return done.replied
|
|
389
|
+
done.tracked = true
|
|
390
|
+
return this.#emitted(done)
|
|
391
|
+
}
|
|
392
|
+
const closing = scope.close()
|
|
393
|
+
if (closing === undefined) {
|
|
394
|
+
ctx.complete()
|
|
395
|
+
this.#leave()
|
|
396
|
+
} else {
|
|
397
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
398
|
+
x.answer(done)
|
|
399
|
+
closing.then(
|
|
400
|
+
() => this.#over(x),
|
|
401
|
+
(error: unknown) => this.#disposalFailed(x, error),
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
// a listener registered while the handler ran (listeners are checked once the scope callback paused)
|
|
405
|
+
if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
|
|
406
|
+
const event = requestEvent(request, ctx, performance.now())
|
|
407
|
+
event.status = done.status
|
|
408
|
+
return Promise.resolve(done).then(reply => {
|
|
409
|
+
hooks.emit('http:request', event)
|
|
410
|
+
return reply
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The fast lane in the request scope: the reply when the handler answered synchronously with a
|
|
416
|
+
* plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
|
|
417
|
+
*/
|
|
418
|
+
readonly #fastRun = (route: FastRoute, request: AdapterRequest, ctx: RequestContext): AdapterReply | Exchange => {
|
|
419
|
+
const plan = route.plan
|
|
420
|
+
if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT)
|
|
421
|
+
let result: unknown
|
|
422
|
+
let reply: AdapterReply | undefined
|
|
423
|
+
try {
|
|
424
|
+
result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
|
|
425
|
+
if (isThenable(result)) return this.#fastLater(route, request, ctx, result)
|
|
426
|
+
reply = this.#fastReply(route, ctx, result)
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
429
|
+
x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
|
|
430
|
+
return x
|
|
431
|
+
}
|
|
432
|
+
if (reply !== undefined) {
|
|
433
|
+
ctx.commit()
|
|
434
|
+
ctx.releaseBody()
|
|
435
|
+
return reply
|
|
436
|
+
}
|
|
437
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
438
|
+
x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
|
|
439
|
+
return x
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The fast lane of a route with a body to read or a handler that returned a thenable. */
|
|
443
|
+
#fastLater(route: FastRoute, request: AdapterRequest, ctx: RequestContext, result: unknown): Exchange {
|
|
444
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
445
|
+
x.done = this.#fastAsync(x, route, result)
|
|
446
|
+
return x
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The asynchronous part of the fast lane, in the request scope: the body read (`result` is
|
|
451
|
+
* {@link NO_RESULT}) and the handler run, or the thenable it returned awaited. Never rejects.
|
|
452
|
+
*/
|
|
453
|
+
async #fastAsync(x: Exchange, route: FastRoute, pending: unknown): Promise<void> {
|
|
454
|
+
const ctx = x.ctx
|
|
455
|
+
const plan = route.plan
|
|
456
|
+
let result = pending
|
|
457
|
+
let reply: AdapterReply | undefined
|
|
458
|
+
try {
|
|
459
|
+
if (result === NO_RESULT) {
|
|
460
|
+
const controller = this.#resolve(plan.controller)
|
|
461
|
+
await ctx.parseBody()
|
|
462
|
+
result = plan.definition.invoke(controller, ctx)
|
|
463
|
+
}
|
|
464
|
+
if (isThenable(result)) result = await result
|
|
465
|
+
reply = this.#fastReply(route, ctx, result)
|
|
466
|
+
} catch (error) {
|
|
467
|
+
await this.#respond(x, () => this.#failed(error, ctx, x.event))
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
if (reply === undefined) {
|
|
471
|
+
const value = result
|
|
472
|
+
await this.#respond(x, () => this.#settle(ctx, plan, x.event, value))
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
ctx.commit()
|
|
476
|
+
ctx.releaseBody()
|
|
477
|
+
// a listener registered since the request started gets a complete event
|
|
478
|
+
if (x.tracked) x.event.status = reply.status
|
|
479
|
+
x.answer(reply)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The reply of a plain JSON value (see {@link isPlainJson}) or of `undefined`: what
|
|
484
|
+
* `serializeResult` and {@link #complete} produce for it, the headers in their final order.
|
|
485
|
+
* `undefined` when the result or the context needs the general pipeline. Throws like
|
|
486
|
+
* `JSON.stringify`.
|
|
487
|
+
*/
|
|
488
|
+
#fastReply(route: FastRoute, ctx: RequestContext, result: unknown): AdapterReply | undefined {
|
|
489
|
+
const status = result === undefined ? route.emptyStatus : route.jsonStatus
|
|
490
|
+
// draining since the request started (`connection: close`, see #frameworkHeaders)
|
|
491
|
+
if (status === undefined || ctx.hasResponseHeaders || ctx.hasCookies || this.draining) return undefined
|
|
492
|
+
const requestId = ctx.requestId
|
|
493
|
+
// a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
|
|
494
|
+
if (!this.#validIds && !isHeaderValue(requestId)) return undefined
|
|
495
|
+
if (result === undefined) {
|
|
496
|
+
const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
|
|
497
|
+
const idHeader = this.#emptyIdHeader
|
|
498
|
+
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
499
|
+
return { status, headers, body: null }
|
|
500
|
+
}
|
|
501
|
+
if (!isPlainJson(result)) return undefined
|
|
502
|
+
const json = JSON.stringify(result)
|
|
503
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
|
|
504
|
+
const headers: Record<string, HeaderValue> = { ...this.#jsonHeaders }
|
|
505
|
+
// encoded once, its length from the bytes (see serialize.ts encodeText)
|
|
506
|
+
const body = utf8(json)
|
|
507
|
+
headers['content-length'] = String(body.byteLength)
|
|
508
|
+
const idHeader = this.#jsonIdHeader
|
|
509
|
+
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
510
|
+
return { status, headers, body, text: json }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
|
|
514
|
+
#end(x: Exchange, scope: OpenedScope, error: unknown): void {
|
|
515
|
+
const closing = scope.close()
|
|
516
|
+
if (error !== undefined) {
|
|
517
|
+
// like runInScope: the scope closes, then the failure is reported (#respond never rejects)
|
|
518
|
+
const report = (): void => this.#disposalFailed(x, error)
|
|
519
|
+
if (closing === undefined) report()
|
|
520
|
+
else closing.then(report, report)
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
if (closing === undefined) this.#over(x)
|
|
524
|
+
else
|
|
525
|
+
closing.then(
|
|
526
|
+
() => this.#over(x),
|
|
527
|
+
(failure: unknown) => this.#disposalFailed(x, failure),
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#disposalFailed(x: Exchange, error: unknown): void {
|
|
532
|
+
this.#over(x)
|
|
533
|
+
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
|
|
534
|
+
}
|
|
535
|
+
|
|
199
536
|
/**
|
|
200
537
|
* Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
|
|
201
538
|
* open) until a streamed or file response body has been fully read or cancelled by the adapter.
|
|
@@ -304,163 +641,184 @@ export class Pipeline {
|
|
|
304
641
|
body: BodyPlan,
|
|
305
642
|
): Promise<AdapterReply> {
|
|
306
643
|
this.#enter()
|
|
307
|
-
let
|
|
308
|
-
let ctx: RequestContext
|
|
309
|
-
let event: HttpRequestEvent
|
|
644
|
+
let x: Exchange
|
|
310
645
|
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
|
-
})
|
|
646
|
+
const requestId = this.#requestId(request)
|
|
647
|
+
const ctx = this.#context(request, requestId, url.path, url.query, route, params, body)
|
|
326
648
|
// an event stream requested while draining is served (guards run) with its signal already
|
|
327
649
|
// aborted: a well-behaved source ends at once and the client gets a jittered retry
|
|
328
650
|
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
|
-
}
|
|
651
|
+
x = new Exchange(request, ctx, requestId, target, startTime)
|
|
339
652
|
} catch (error) {
|
|
340
653
|
// failed before its scope opened (a request id generator that throws...)
|
|
341
654
|
this.#leave()
|
|
342
655
|
return Promise.reject(error)
|
|
343
656
|
}
|
|
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
657
|
// the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
|
|
376
658
|
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
|
-
})
|
|
659
|
+
.runInScope(
|
|
660
|
+
() => this.#respond(x, undefined),
|
|
661
|
+
[
|
|
662
|
+
[HttpTokens.Context, x.ctx],
|
|
663
|
+
[HttpTokens.RequestId, x.requestId],
|
|
664
|
+
],
|
|
665
|
+
)
|
|
666
|
+
.then(
|
|
667
|
+
() => this.#over(x),
|
|
668
|
+
(error: unknown) => {
|
|
669
|
+
this.#over(x)
|
|
670
|
+
// #respond never rejects: only the disposal of request-scoped instances can fail
|
|
671
|
+
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId: x.requestId })
|
|
672
|
+
},
|
|
673
|
+
)
|
|
456
674
|
// listeners run once the reply is ready, before the adapter gets it, out of the request scope
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
675
|
+
return this.#hooks.hasListeners('http:request') ? this.#emitted(x) : x.replied
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** The reply of a request once the `http:request` listeners saw its event. */
|
|
679
|
+
#emitted(x: Exchange): Promise<AdapterReply> {
|
|
680
|
+
return x.replied.then(reply => {
|
|
681
|
+
this.#hooks.emit('http:request', x.event)
|
|
460
682
|
return reply
|
|
461
683
|
})
|
|
462
684
|
}
|
|
463
685
|
|
|
686
|
+
/** The request context of a request (internal). */
|
|
687
|
+
#context(
|
|
688
|
+
request: AdapterRequest,
|
|
689
|
+
requestId: RequestId,
|
|
690
|
+
path: string,
|
|
691
|
+
query: string,
|
|
692
|
+
route: HttpRouteInfo | undefined,
|
|
693
|
+
params: Record<string, string>,
|
|
694
|
+
body: BodyPlan,
|
|
695
|
+
): RequestContext {
|
|
696
|
+
const proxy = this.#options.trustProxy
|
|
697
|
+
return new RequestContext(
|
|
698
|
+
request,
|
|
699
|
+
requestId,
|
|
700
|
+
this.#options.adapter,
|
|
701
|
+
path,
|
|
702
|
+
query,
|
|
703
|
+
route,
|
|
704
|
+
params,
|
|
705
|
+
body,
|
|
706
|
+
this.#cookies,
|
|
707
|
+
proxy === undefined
|
|
708
|
+
? undefined
|
|
709
|
+
: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true),
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Answers a request in its scope: its outcome (`process`, or the target of the exchange after the
|
|
715
|
+
* CORS decision and through the `http:request` wrappers), checked, completed and handed to the
|
|
716
|
+
* adapter; a streamed body is then tracked until it was sent or abandoned. Never rejects.
|
|
717
|
+
* `process` is given by the fast lane, which decided at the start of the request that no wrapper
|
|
718
|
+
* applies and that CORS is off.
|
|
719
|
+
*/
|
|
720
|
+
async #respond(x: Exchange, process: (() => Outcome | Promise<Outcome>) | undefined): Promise<void> {
|
|
721
|
+
const ctx = x.ctx
|
|
722
|
+
const requestId = x.requestId
|
|
723
|
+
const event = x.event
|
|
724
|
+
let outcome: Outcome | undefined
|
|
725
|
+
let hookError: unknown
|
|
726
|
+
let failed = false
|
|
727
|
+
let run: ContextRunner | undefined
|
|
728
|
+
// only a wrapper can change a reply between its checks below and the adapter
|
|
729
|
+
let recheck = false
|
|
730
|
+
let work = process
|
|
731
|
+
if (work === undefined) {
|
|
732
|
+
if (this.#options.cors !== undefined) x.cors = await this.#corsDecision(x.request, requestId)
|
|
733
|
+
recheck = this.#hooks.hasWrappers('http:request')
|
|
734
|
+
const target = x.target
|
|
735
|
+
work = () => this.#process(ctx, target, event, x.cors)
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
if (recheck) {
|
|
739
|
+
const inner = work
|
|
740
|
+
await this.#hooks.run('http:request', event, async () => {
|
|
741
|
+
outcome = this.#checked(await inner(), ctx, event)
|
|
742
|
+
// stream producers run in the context of the handler (scope, wrappers)
|
|
743
|
+
if (outcome !== RAW && isStreamed(outcome.body)) run = captureContext()
|
|
744
|
+
return outcome
|
|
745
|
+
})
|
|
746
|
+
} else {
|
|
747
|
+
// what hooks.run() does without wrappers, in the same async context
|
|
748
|
+
outcome = this.#checked(await work(), ctx, event)
|
|
749
|
+
}
|
|
750
|
+
} catch (error) {
|
|
751
|
+
failed = true
|
|
752
|
+
hookError = error
|
|
753
|
+
}
|
|
754
|
+
if (outcome === undefined) {
|
|
755
|
+
// a wrapper failed before the request was handled, or did not call next(): answer anyway
|
|
756
|
+
const error = failed ? hookError : new Error('An "http:request" hook wrapper did not call next()')
|
|
757
|
+
event.error ??= error
|
|
758
|
+
// the same checks as a handled reply: an exception mapper's HttpResponse may carry cookies
|
|
759
|
+
const fallback = await this.#errorReply(error, ctx)
|
|
760
|
+
outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
|
|
761
|
+
event.status = outcome.status
|
|
762
|
+
recheck = true
|
|
763
|
+
} else if (failed) {
|
|
764
|
+
this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
|
|
765
|
+
err: hookError,
|
|
766
|
+
requestId,
|
|
767
|
+
})
|
|
768
|
+
}
|
|
769
|
+
if (outcome === RAW) {
|
|
770
|
+
ctx.commit()
|
|
771
|
+
const status = x.request.rawStatus?.() ?? 200
|
|
772
|
+
event.status ??= status
|
|
773
|
+
x.answer({ status, headers: {}, body: null, raw: true })
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
const serialized = this.#complete(outcome, ctx, event, requestId, x.cors, x.preflight, recheck)
|
|
777
|
+
const body = serialized.body
|
|
778
|
+
if (!isStreamed(body)) {
|
|
779
|
+
const reply = adapterReply(serialized, body)
|
|
780
|
+
ctx.commit()
|
|
781
|
+
ctx.releaseBody()
|
|
782
|
+
x.answer(reply)
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
// files too: a file-backed Blob is read lazily, after the handler returned
|
|
786
|
+
const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
|
|
787
|
+
const session = serialized.sse
|
|
788
|
+
const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
|
|
789
|
+
if (session !== undefined) {
|
|
790
|
+
this.#streams.add(session)
|
|
791
|
+
if (this.draining) session.close('shutdown')
|
|
792
|
+
}
|
|
793
|
+
ctx.commit()
|
|
794
|
+
x.answer(reply)
|
|
795
|
+
const failure = await tracked.done
|
|
796
|
+
if (session !== undefined) this.#streams.delete(session)
|
|
797
|
+
if (failure !== undefined) {
|
|
798
|
+
this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
|
|
799
|
+
}
|
|
800
|
+
ctx.releaseBody()
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** The request is over (response body sent or abandoned, scope closed). */
|
|
804
|
+
#over(x: Exchange): void {
|
|
805
|
+
const ctx = x.ctx
|
|
806
|
+
ctx.complete()
|
|
807
|
+
try {
|
|
808
|
+
if (!x.answered) {
|
|
809
|
+
const failure = new Error('Request not handled')
|
|
810
|
+
const reply = serializeError(500, this.#internalBody(failure), ctx.method)
|
|
811
|
+
const completed = this.#complete(reply, ctx, x.event, x.requestId, x.cors, x.preflight, true)
|
|
812
|
+
x.event.status = 500
|
|
813
|
+
x.answer(completed)
|
|
814
|
+
}
|
|
815
|
+
} catch (error) {
|
|
816
|
+
x.fail(error)
|
|
817
|
+
} finally {
|
|
818
|
+
this.#leave()
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
464
822
|
/**
|
|
465
823
|
* Adds the headers of the `http:request` wrappers, then the framework headers (see
|
|
466
824
|
* {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
|
|
@@ -504,7 +862,7 @@ export class Pipeline {
|
|
|
504
862
|
#checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
|
|
505
863
|
const cookies = reply.cookies
|
|
506
864
|
if (cookies === undefined || cookies.length === 0) return reply
|
|
507
|
-
const settings = this.#
|
|
865
|
+
const settings = this.#cookies
|
|
508
866
|
const lines: SerializedCookie[] = []
|
|
509
867
|
try {
|
|
510
868
|
for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
|
|
@@ -645,6 +1003,7 @@ export class Pipeline {
|
|
|
645
1003
|
|
|
646
1004
|
/** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
|
|
647
1005
|
async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
|
|
1006
|
+
let result: unknown
|
|
648
1007
|
try {
|
|
649
1008
|
const controller = this.#resolve(plan.controller)
|
|
650
1009
|
for (const guard of plan.guards) {
|
|
@@ -654,46 +1013,80 @@ export class Pipeline {
|
|
|
654
1013
|
if (plan.parseBody) await ctx.parseBody()
|
|
655
1014
|
const definition = plan.definition
|
|
656
1015
|
const interceptors = plan.interceptors
|
|
657
|
-
let result: unknown
|
|
658
1016
|
if (interceptors.length === 0) {
|
|
659
1017
|
result = definition.invoke(controller, ctx)
|
|
660
|
-
if (isThenable(result)) result = await result
|
|
661
1018
|
} else {
|
|
662
1019
|
const chain = async (index: number): Promise<unknown> => {
|
|
663
1020
|
if (index === interceptors.length) return await definition.invoke(controller, ctx)
|
|
664
1021
|
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
665
1022
|
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
666
1023
|
}
|
|
667
|
-
result =
|
|
1024
|
+
result = chain(0)
|
|
668
1025
|
}
|
|
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,
|
|
676
|
-
}
|
|
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
1026
|
} catch (error) {
|
|
695
1027
|
return await this.#failed(error, ctx, event)
|
|
696
1028
|
}
|
|
1029
|
+
const settled = this.#settle(ctx, plan, event, result)
|
|
1030
|
+
return settled instanceof Promise ? await settled : settled
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* The rest of {@link #invoke} once the handler (and its interceptors) returned `result`: awaited
|
|
1035
|
+
* when it is a thenable, then serialised. Never throws nor rejects (errors are mapped).
|
|
1036
|
+
*/
|
|
1037
|
+
#settle(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
|
|
1038
|
+
if (isThenable(result)) return this.#settleLater(ctx, plan, event, result)
|
|
1039
|
+
try {
|
|
1040
|
+
const outcome = this.#outcome(ctx, plan, event, result)
|
|
1041
|
+
return outcome instanceof Promise ? outcome.catch(error => this.#failed(error, ctx, event)) : outcome
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
return this.#failed(error, ctx, event)
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async #settleLater(
|
|
1048
|
+
ctx: RequestContext,
|
|
1049
|
+
plan: RoutePlan,
|
|
1050
|
+
event: HttpRequestEvent,
|
|
1051
|
+
pending: PromiseLike<unknown>,
|
|
1052
|
+
): Promise<Outcome> {
|
|
1053
|
+
let result: unknown
|
|
1054
|
+
try {
|
|
1055
|
+
result = await pending
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
return await this.#failed(error, ctx, event)
|
|
1058
|
+
}
|
|
1059
|
+
const settled = this.#settle(ctx, plan, event, result)
|
|
1060
|
+
return settled instanceof Promise ? await settled : settled
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The outcome of a handler result: `RAW` for a `@Res()` route, otherwise its reply (files resolved,
|
|
1065
|
+
* event streams opened: only an `HttpResponse` may need an asynchronous step). Throws (or rejects)
|
|
1066
|
+
* when the result cannot be served.
|
|
1067
|
+
*/
|
|
1068
|
+
#outcome(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent, result: unknown): Outcome | Promise<Outcome> {
|
|
1069
|
+
const definition = plan.definition
|
|
1070
|
+
if (definition.rawResponse === true && !(result instanceof Response)) return RAW
|
|
1071
|
+
const options: SerializeOptions = {
|
|
1072
|
+
method: ctx.method,
|
|
1073
|
+
routeMethod: definition.method,
|
|
1074
|
+
status: definition.status,
|
|
1075
|
+
headers: definition.headers,
|
|
1076
|
+
context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
|
|
1077
|
+
}
|
|
1078
|
+
const sse = definition.sse
|
|
1079
|
+
if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
|
|
1080
|
+
// an @Sse route: the (awaited) result is the event source
|
|
1081
|
+
if (!isSseSource(result)) {
|
|
1082
|
+
throw new TypeError(
|
|
1083
|
+
`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
|
|
1084
|
+
)
|
|
1085
|
+
}
|
|
1086
|
+
return this.#finish(event, serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options))
|
|
1087
|
+
}
|
|
1088
|
+
if (!isHttpResponse(result)) return this.#finish(event, serializeResult(result, options))
|
|
1089
|
+
return this.#serialize(result, ctx, options, sse).then(reply => this.#finish(event, reply))
|
|
697
1090
|
}
|
|
698
1091
|
|
|
699
1092
|
/** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
|
|
@@ -868,6 +1261,151 @@ export class Pipeline {
|
|
|
868
1261
|
}
|
|
869
1262
|
}
|
|
870
1263
|
|
|
1264
|
+
/** A route served by the fast lane (see `Pipeline.handler`), decided at boot. */
|
|
1265
|
+
interface FastRoute {
|
|
1266
|
+
readonly plan: RoutePlan
|
|
1267
|
+
readonly target: Target
|
|
1268
|
+
/** The method of the route definition (requests of another method take the general pipeline). */
|
|
1269
|
+
readonly method: string
|
|
1270
|
+
/** The status of a JSON reply, `undefined` when it has no body (such replies take the general pipeline). */
|
|
1271
|
+
readonly jsonStatus: number | undefined
|
|
1272
|
+
/** The status of a reply without value (`undefined` returned). */
|
|
1273
|
+
readonly emptyStatus: number
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** `#fastAsync` reads the body and runs the handler. */
|
|
1277
|
+
const NO_RESULT: unique symbol = Symbol('no result')
|
|
1278
|
+
|
|
1279
|
+
/** A request being answered (internal): its context, its target and the promise of its reply. */
|
|
1280
|
+
class Exchange {
|
|
1281
|
+
readonly request: AdapterRequest
|
|
1282
|
+
readonly ctx: RequestContext
|
|
1283
|
+
readonly requestId: RequestId
|
|
1284
|
+
readonly target: Target
|
|
1285
|
+
readonly preflight: boolean
|
|
1286
|
+
readonly replied: Promise<AdapterReply>
|
|
1287
|
+
cors: CorsDecision | undefined = undefined
|
|
1288
|
+
answered = false
|
|
1289
|
+
/** The `http:request` listeners will see the event (fast lane: keep its status). */
|
|
1290
|
+
tracked = false
|
|
1291
|
+
/** The asynchronous part of a fast-lane request (never rejects). */
|
|
1292
|
+
done: Promise<void> | undefined = undefined
|
|
1293
|
+
readonly #resolve: (reply: AdapterReply) => void
|
|
1294
|
+
readonly #reject: (error: unknown) => void
|
|
1295
|
+
#event: HttpRequestEvent | undefined
|
|
1296
|
+
|
|
1297
|
+
/** `startTime`: the event is created now (general pipeline); otherwise when first needed (fast lane). */
|
|
1298
|
+
constructor(request: AdapterRequest, ctx: RequestContext, requestId: RequestId, target: Target, startTime?: number) {
|
|
1299
|
+
this.request = request
|
|
1300
|
+
this.ctx = ctx
|
|
1301
|
+
this.requestId = requestId
|
|
1302
|
+
this.target = target
|
|
1303
|
+
this.preflight = target.kind === 'preflight'
|
|
1304
|
+
let resolve: ((reply: AdapterReply) => void) | undefined
|
|
1305
|
+
let reject: ((error: unknown) => void) | undefined
|
|
1306
|
+
this.replied = new Promise<AdapterReply>((onReply, onError) => {
|
|
1307
|
+
resolve = onReply
|
|
1308
|
+
reject = onError
|
|
1309
|
+
})
|
|
1310
|
+
this.#resolve = resolve as (reply: AdapterReply) => void
|
|
1311
|
+
this.#reject = reject as (error: unknown) => void
|
|
1312
|
+
if (startTime !== undefined) this.#event = requestEvent(request, ctx, startTime)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* The `http:request` event. The fast lane creates it when the general pipeline takes over: no
|
|
1317
|
+
* hook saw the request start, so its `startTime` is not observed.
|
|
1318
|
+
*/
|
|
1319
|
+
get event(): HttpRequestEvent {
|
|
1320
|
+
this.#event ??= requestEvent(this.request, this.ctx, performance.now())
|
|
1321
|
+
return this.#event
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
answer(reply: AdapterReply): void {
|
|
1325
|
+
this.answered = true
|
|
1326
|
+
this.#resolve(reply)
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
fail(error: unknown): void {
|
|
1330
|
+
this.#reject(error)
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** The `http:request` event of a request. */
|
|
1335
|
+
function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: number): HttpRequestEvent {
|
|
1336
|
+
return {
|
|
1337
|
+
method: request.method,
|
|
1338
|
+
path: ctx.path,
|
|
1339
|
+
route: ctx.route?.path,
|
|
1340
|
+
requestId: ctx.requestId,
|
|
1341
|
+
headers: request.headers,
|
|
1342
|
+
context: ctx,
|
|
1343
|
+
startTime,
|
|
1344
|
+
responseHeaders: {},
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* The decoded parameters of a raw request path matched against a route, in one pass: what
|
|
1350
|
+
* `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
|
|
1351
|
+
* `undefined` when the path does not match or a value is not valid percent-encoding: the general
|
|
1352
|
+
* pipeline then answers it (a reroute, a 400).
|
|
1353
|
+
*/
|
|
1354
|
+
function routeParams(route: RoutePath, path: string): Record<string, string> | undefined {
|
|
1355
|
+
if (path.charCodeAt(0) !== 47 /* / */) return undefined
|
|
1356
|
+
const params = emptyRecord<string>()
|
|
1357
|
+
const segments = route.segments
|
|
1358
|
+
const last = segments.length - 1
|
|
1359
|
+
if (last === -1) return path === '/' ? params : undefined
|
|
1360
|
+
let position = 1
|
|
1361
|
+
for (let index = 0; index <= last; index++) {
|
|
1362
|
+
const segment = segments[index] as RouteSegment
|
|
1363
|
+
if (segment.kind === 'wildcard') {
|
|
1364
|
+
const rest = decodePathValue(path.slice(position))
|
|
1365
|
+
if (rest === undefined) return undefined
|
|
1366
|
+
params[WILDCARD_PARAM] = rest
|
|
1367
|
+
return params
|
|
1368
|
+
}
|
|
1369
|
+
const slash = path.indexOf('/', position)
|
|
1370
|
+
const end = slash === -1 ? path.length : slash
|
|
1371
|
+
if (segment.kind === 'static') {
|
|
1372
|
+
const value = segment.value
|
|
1373
|
+
if (end - position !== value.length || !path.startsWith(value, position)) return undefined
|
|
1374
|
+
} else {
|
|
1375
|
+
if (end === position) return undefined
|
|
1376
|
+
const decoded = decodePathValue(path.slice(position, end))
|
|
1377
|
+
if (decoded === undefined) return undefined
|
|
1378
|
+
params[segment.name] = decoded
|
|
1379
|
+
}
|
|
1380
|
+
if (index === last) return end === path.length ? params : undefined
|
|
1381
|
+
if (slash === -1) return undefined
|
|
1382
|
+
position = slash + 1
|
|
1383
|
+
}
|
|
1384
|
+
return params
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* A value the fast lane serialises as JSON: a plain object or array (its own prototype and
|
|
1389
|
+
* constructor), not duck-typed as a Node.js stream. Anything `serializeResult` could treat otherwise
|
|
1390
|
+
* (`HttpResponse`, `Response`, bytes, streams, `Blob`, strings, `undefined`...) is not.
|
|
1391
|
+
*/
|
|
1392
|
+
function isPlainJson(value: unknown): value is object {
|
|
1393
|
+
if (value === null || typeof value !== 'object') return false
|
|
1394
|
+
const prototype: unknown = Object.getPrototypeOf(value)
|
|
1395
|
+
const ctor = (value as { constructor?: unknown }).constructor
|
|
1396
|
+
if (prototype === Object.prototype) {
|
|
1397
|
+
if (ctor !== Object) return false
|
|
1398
|
+
} else if (prototype !== Array.prototype || ctor !== Array) {
|
|
1399
|
+
return false
|
|
1400
|
+
}
|
|
1401
|
+
return typeof (value as { pipe?: unknown }).pipe !== 'function'
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const ENCODER = new TextEncoder()
|
|
1405
|
+
|
|
1406
|
+
/** UTF-8 encoding of a text body, like the serialiser (a pooled `Buffer` on Node.js, `TextEncoder` on Bun). */
|
|
1407
|
+
const utf8: (text: string) => Uint8Array = isBun() ? text => ENCODER.encode(text) : text => Buffer.from(text, 'utf8')
|
|
1408
|
+
|
|
871
1409
|
/**
|
|
872
1410
|
* The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
|
|
873
1411
|
* `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a
|