@znight/http 0.1.1 → 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/LICENSE +1 -1
- package/README.md +83 -32
- package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
- package/dist/compiler.d.mts +1 -1
- package/dist/compiler.mjs +82 -13
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/express.mjs +3 -2
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/fastify.mjs +254 -42
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/hono.mjs +272 -67
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +10 -2
- package/dist/index.mjs +930 -278
- package/dist/index.mjs.map +1 -1
- package/dist/{node-BUnkdSsa.mjs → node-DnYv_AMI.mjs} +4 -3
- package/dist/{node-BUnkdSsa.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
- package/dist/{node-io-BY1ZDLRQ.mjs → node-io-BkdRSQeg.mjs} +151 -86
- package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
- package/dist/node.d.mts +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/adapter.ts +6 -0
- package/src/adapters/express.ts +4 -1
- package/src/adapters/fastify.ts +329 -44
- package/src/adapters/hono.ts +341 -86
- package/src/adapters/node-io.ts +185 -82
- 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 +151 -78
- package/src/index.ts +1 -1
- package/src/pipeline.ts +809 -208
- package/src/plugin.ts +10 -4
- package/src/query.ts +7 -1
- package/src/response.ts +6 -8
- package/src/serialize.ts +83 -16
- 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 { splitUrl } from './query.ts'
|
|
30
|
+
import { decodePathValue, matchRoutePath, type RoutePath, type RouteSegment, WILDCARD_PARAM } from './path.ts'
|
|
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 } 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 }
|
|
@@ -123,15 +126,78 @@ type Outcome = SerializedReply | typeof RAW
|
|
|
123
126
|
/** A reply whose `HttpResponse` cookies were serialised (see `#checkCookies`). */
|
|
124
127
|
type CheckedReply = SerializedReply & { readonly cookieLines?: readonly SerializedCookie[] }
|
|
125
128
|
|
|
126
|
-
/** A reply, and a promise settled once the request is over (response body sent or abandoned, scope closed). */
|
|
127
|
-
interface Served {
|
|
128
|
-
readonly reply: AdapterReply
|
|
129
|
-
readonly done: Promise<void>
|
|
130
|
-
}
|
|
131
|
-
|
|
132
129
|
const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
|
|
130
|
+
|
|
133
131
|
const MAX_PATH_IN_MESSAGE = 200
|
|
134
132
|
|
|
133
|
+
/** The default request id generator: a random UUID (version 4, like `crypto.randomUUID()`). */
|
|
134
|
+
export function randomRequestId(): string {
|
|
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()
|
|
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
|
|
@@ -143,6 +209,26 @@ export class Pipeline {
|
|
|
143
209
|
readonly #logger: CoreLogger
|
|
144
210
|
readonly #options: PipelineOptions
|
|
145
211
|
readonly #table: RouteTable<RoutePlan>
|
|
212
|
+
/** The `securityHeaders` option as entries (lower-case names, values validated at boot). */
|
|
213
|
+
readonly #securityHeaders: readonly (readonly [string, string])[]
|
|
214
|
+
/** Every request id can be sent: an incoming one matched `REQUEST_ID`, the generated ones are UUIDs. */
|
|
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
|
|
146
232
|
#inflight = 0
|
|
147
233
|
#idle: (() => void)[] = []
|
|
148
234
|
/** Open event streams. */
|
|
@@ -162,6 +248,29 @@ export class Pipeline {
|
|
|
162
248
|
this.#logger = logger
|
|
163
249
|
this.#options = options
|
|
164
250
|
this.#table = table
|
|
251
|
+
this.#validIds = options.generateRequestId === randomRequestId
|
|
252
|
+
this.#cookies = options.cookies ?? DEFAULT_COOKIE_SETTINGS
|
|
253
|
+
this.#securityHeaders = Object.entries(options.securityHeaders ?? {}).map(
|
|
254
|
+
([name, value]) => [name.toLowerCase(), value] as const,
|
|
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))
|
|
165
274
|
}
|
|
166
275
|
|
|
167
276
|
/** Number of requests being processed. */
|
|
@@ -188,34 +297,266 @@ export class Pipeline {
|
|
|
188
297
|
return new Promise(resolve => this.#idle.push(resolve))
|
|
189
298
|
}
|
|
190
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
|
+
|
|
191
536
|
/**
|
|
192
537
|
* Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
|
|
193
538
|
* open) until a streamed or file response body has been fully read or cancelled by the adapter.
|
|
194
539
|
*/
|
|
195
|
-
|
|
540
|
+
handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
|
|
196
541
|
const startTime = performance.now()
|
|
197
|
-
this.#enter()
|
|
198
|
-
let served: Served | undefined
|
|
199
542
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return served.reply
|
|
204
|
-
} finally {
|
|
205
|
-
this.#leaveWhen(served)
|
|
543
|
+
return this.#isPreflight(request) ? this.#preflight(request, startTime) : this.#route(plan, request, startTime)
|
|
544
|
+
} catch (error) {
|
|
545
|
+
return Promise.reject(error)
|
|
206
546
|
}
|
|
207
547
|
}
|
|
208
548
|
|
|
209
|
-
#route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<
|
|
210
|
-
const
|
|
211
|
-
const raw = matchRoutePath(plan.path, path)
|
|
549
|
+
#route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<AdapterReply> {
|
|
550
|
+
const url = splitUrl(request.url)
|
|
551
|
+
const raw = matchRoutePath(plan.path, url.path)
|
|
212
552
|
// the native router matched a path the Znight grammar rejects (e.g. an empty `:param` in Fastify)
|
|
213
553
|
// or a normalised path (Hono): route the raw path with the route table instead
|
|
214
554
|
if (raw === undefined) return this.#fallback(request, undefined, startTime, !rerouted)
|
|
215
555
|
if (plan.redirect === true) {
|
|
216
|
-
const canonical = sameOriginPath(path.slice(0, -1))
|
|
217
|
-
const location = query === '' ? canonical : `${canonical}?${query}`
|
|
218
|
-
|
|
556
|
+
const canonical = sameOriginPath(url.path.slice(0, -1))
|
|
557
|
+
const location = url.query === '' ? canonical : `${canonical}?${url.query}`
|
|
558
|
+
const target: Target = { kind: 'redirect', location }
|
|
559
|
+
return this.#serve(request, url, undefined, emptyRecord(), target, startTime, plan.body)
|
|
219
560
|
}
|
|
220
561
|
const params = emptyRecord<string>()
|
|
221
562
|
let malformed: string | undefined
|
|
@@ -228,30 +569,28 @@ export class Pipeline {
|
|
|
228
569
|
malformed === undefined
|
|
229
570
|
? { kind: 'route', plan }
|
|
230
571
|
: { kind: 'error', error: new BadRequestException(`Malformed URL parameter "${malformed}"`) }
|
|
231
|
-
return this.#serve(request, plan.info, params, target, startTime, plan.body)
|
|
572
|
+
return this.#serve(request, url, plan.info, params, target, startTime, plan.body)
|
|
232
573
|
}
|
|
233
574
|
|
|
234
575
|
/** Handles an unmatched request (404, 405, automatic OPTIONS) or a framework error. */
|
|
235
|
-
|
|
576
|
+
fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
|
|
236
577
|
const startTime = performance.now()
|
|
237
|
-
this.#enter()
|
|
238
|
-
let served: Served | undefined
|
|
239
578
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
this.#leaveWhen(served)
|
|
579
|
+
return this.#fallback(request, error, startTime)
|
|
580
|
+
} catch (failure) {
|
|
581
|
+
return Promise.reject(failure)
|
|
244
582
|
}
|
|
245
583
|
}
|
|
246
584
|
|
|
247
|
-
#fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<
|
|
585
|
+
#fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<AdapterReply> {
|
|
248
586
|
const body = this.#options.body
|
|
249
587
|
if (error !== undefined) {
|
|
250
588
|
const target: Target = { kind: 'error', error: frameworkError(error) }
|
|
251
|
-
return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
|
|
589
|
+
return this.#serve(request, splitUrl(request.url), undefined, emptyRecord(), target, startTime, body)
|
|
252
590
|
}
|
|
253
591
|
if (this.#isPreflight(request)) return this.#preflight(request, startTime)
|
|
254
|
-
const
|
|
592
|
+
const url = splitUrl(request.url)
|
|
593
|
+
const path = url.path
|
|
255
594
|
if (reroute) {
|
|
256
595
|
// routing happens on the raw path: serve a route the adapter's router missed (Hono routes the
|
|
257
596
|
// WHATWG-normalised URL, where `/files/%2e%2e/x` became `/x`)
|
|
@@ -277,7 +616,7 @@ export class Pipeline {
|
|
|
277
616
|
),
|
|
278
617
|
}
|
|
279
618
|
}
|
|
280
|
-
return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
|
|
619
|
+
return this.#serve(request, url, undefined, emptyRecord(), target, startTime, body)
|
|
281
620
|
}
|
|
282
621
|
|
|
283
622
|
/** A CORS preflight (with the `cors` option): answered without running any route. */
|
|
@@ -285,156 +624,206 @@ export class Pipeline {
|
|
|
285
624
|
return this.#options.cors !== undefined && isPreflight(request.method, request.headers)
|
|
286
625
|
}
|
|
287
626
|
|
|
288
|
-
#preflight(request: AdapterRequest, startTime: number): Promise<
|
|
289
|
-
const
|
|
627
|
+
#preflight(request: AdapterRequest, startTime: number): Promise<AdapterReply> {
|
|
628
|
+
const url = splitUrl(request.url)
|
|
629
|
+
const allowed = this.#table.allowedMethods(url.path)
|
|
290
630
|
const target: Target = { kind: 'preflight', allow: allowed.length === 0 ? undefined : allowHeader(allowed) }
|
|
291
|
-
return this.#serve(request, undefined, emptyRecord(), target, startTime, this.#options.body)
|
|
631
|
+
return this.#serve(request, url, undefined, emptyRecord(), target, startTime, this.#options.body)
|
|
292
632
|
}
|
|
293
633
|
|
|
294
|
-
|
|
634
|
+
#serve(
|
|
295
635
|
request: AdapterRequest,
|
|
636
|
+
url: RequestTarget,
|
|
296
637
|
route: HttpRouteInfo | undefined,
|
|
297
638
|
params: Record<string, string>,
|
|
298
639
|
target: Target,
|
|
299
640
|
startTime: number,
|
|
300
641
|
body: BodyPlan,
|
|
301
|
-
): Promise<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
requestId
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// an event stream requested while draining is served (guards run) with its signal already
|
|
316
|
-
// aborted: a well-behaved source ends at once and the client gets a jittered retry
|
|
317
|
-
if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
|
|
318
|
-
const event: HttpRequestEvent = {
|
|
319
|
-
method: request.method,
|
|
320
|
-
path: ctx.path,
|
|
321
|
-
route: route?.path,
|
|
322
|
-
requestId,
|
|
323
|
-
headers: request.headers,
|
|
324
|
-
context: ctx,
|
|
325
|
-
startTime,
|
|
326
|
-
responseHeaders: {},
|
|
642
|
+
): Promise<AdapterReply> {
|
|
643
|
+
this.#enter()
|
|
644
|
+
let x: Exchange
|
|
645
|
+
try {
|
|
646
|
+
const requestId = this.#requestId(request)
|
|
647
|
+
const ctx = this.#context(request, requestId, url.path, url.query, route, params, body)
|
|
648
|
+
// an event stream requested while draining is served (guards run) with its signal already
|
|
649
|
+
// aborted: a well-behaved source ends at once and the client gets a jittered retry
|
|
650
|
+
if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
|
|
651
|
+
x = new Exchange(request, ctx, requestId, target, startTime)
|
|
652
|
+
} catch (error) {
|
|
653
|
+
// failed before its scope opened (a request id generator that throws...)
|
|
654
|
+
this.#leave()
|
|
655
|
+
return Promise.reject(error)
|
|
327
656
|
}
|
|
328
|
-
let outcome: Outcome | undefined
|
|
329
|
-
let reply: AdapterReply | undefined
|
|
330
|
-
let cors: CorsDecision | undefined
|
|
331
|
-
const preflight = target.kind === 'preflight'
|
|
332
|
-
let replied: () => void = () => undefined
|
|
333
|
-
const ready = new Promise<void>(resolve => {
|
|
334
|
-
replied = resolve
|
|
335
|
-
})
|
|
336
657
|
// the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
|
|
337
|
-
|
|
338
|
-
.runInScope(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
outcome = await this.#process(ctx, target, event, cors)
|
|
346
|
-
if (outcome !== RAW) outcome = this.#checkCookies(this.#checkHeaders(outcome, ctx, event), ctx, event)
|
|
347
|
-
run = captureContext() // stream producers run in the context of the handler (scope, hooks)
|
|
348
|
-
return outcome
|
|
349
|
-
})
|
|
350
|
-
} catch (error) {
|
|
351
|
-
failed = true
|
|
352
|
-
hookError = error
|
|
353
|
-
}
|
|
354
|
-
if (outcome === undefined) {
|
|
355
|
-
// a wrapper failed before the request was handled, or did not call next(): answer anyway
|
|
356
|
-
const error = failed ? hookError : new Error('An "http:request" hook wrapper did not call next()')
|
|
357
|
-
event.error ??= error
|
|
358
|
-
// the same checks as a handled reply: an exception mapper's HttpResponse may carry cookies
|
|
359
|
-
const fallback = await this.#errorReply(error, ctx)
|
|
360
|
-
outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
|
|
361
|
-
event.status = outcome.status
|
|
362
|
-
} else if (failed) {
|
|
363
|
-
this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
|
|
364
|
-
err: hookError,
|
|
365
|
-
requestId,
|
|
366
|
-
})
|
|
367
|
-
}
|
|
368
|
-
if (outcome === RAW) {
|
|
369
|
-
ctx.commit()
|
|
370
|
-
replied()
|
|
371
|
-
return
|
|
372
|
-
}
|
|
373
|
-
const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight)
|
|
374
|
-
const body = serialized.body
|
|
375
|
-
if (!(body instanceof ReadableStream || body instanceof Blob)) {
|
|
376
|
-
reply = adapterReply(serialized, body)
|
|
377
|
-
ctx.commit()
|
|
378
|
-
replied()
|
|
379
|
-
ctx.releaseBody()
|
|
380
|
-
return
|
|
381
|
-
}
|
|
382
|
-
// files too: a file-backed Blob is read lazily, after the handler returned
|
|
383
|
-
const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
|
|
384
|
-
const session = serialized.sse
|
|
385
|
-
reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
|
|
386
|
-
if (session !== undefined) {
|
|
387
|
-
this.#streams.add(session)
|
|
388
|
-
if (this.draining) session.close('shutdown')
|
|
389
|
-
}
|
|
390
|
-
ctx.commit()
|
|
391
|
-
replied()
|
|
392
|
-
const failure = await tracked.done
|
|
393
|
-
if (session !== undefined) this.#streams.delete(session)
|
|
394
|
-
if (failure !== undefined) {
|
|
395
|
-
this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
|
|
396
|
-
}
|
|
397
|
-
ctx.releaseBody()
|
|
398
|
-
}, [
|
|
399
|
-
[HttpTokens.Context, ctx],
|
|
400
|
-
[HttpTokens.RequestId, requestId],
|
|
401
|
-
])
|
|
658
|
+
this.#container
|
|
659
|
+
.runInScope(
|
|
660
|
+
() => this.#respond(x, undefined),
|
|
661
|
+
[
|
|
662
|
+
[HttpTokens.Context, x.ctx],
|
|
663
|
+
[HttpTokens.RequestId, x.requestId],
|
|
664
|
+
],
|
|
665
|
+
)
|
|
402
666
|
.then(
|
|
403
|
-
() =>
|
|
404
|
-
ctx.complete()
|
|
405
|
-
},
|
|
667
|
+
() => this.#over(x),
|
|
406
668
|
(error: unknown) => {
|
|
407
|
-
|
|
408
|
-
//
|
|
409
|
-
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId })
|
|
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 })
|
|
410
672
|
},
|
|
411
673
|
)
|
|
412
|
-
|
|
674
|
+
// listeners run once the reply is ready, before the adapter gets it, out of the request scope
|
|
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)
|
|
682
|
+
return reply
|
|
683
|
+
})
|
|
684
|
+
}
|
|
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
|
+
}
|
|
413
769
|
if (outcome === RAW) {
|
|
414
|
-
|
|
770
|
+
ctx.commit()
|
|
771
|
+
const status = x.request.rawStatus?.() ?? 200
|
|
415
772
|
event.status ??= status
|
|
416
|
-
|
|
417
|
-
return
|
|
773
|
+
x.answer({ status, headers: {}, body: null, raw: true })
|
|
774
|
+
return
|
|
418
775
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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()
|
|
430
819
|
}
|
|
431
|
-
this.#emit(event)
|
|
432
|
-
return { reply, done }
|
|
433
820
|
}
|
|
434
821
|
|
|
435
822
|
/**
|
|
436
823
|
* Adds the headers of the `http:request` wrappers, then the framework headers (see
|
|
437
|
-
* {@link #frameworkHeaders}),
|
|
824
|
+
* {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
|
|
825
|
+
* {@link #checkHeaders}): the reply was checked before, and the cookies and framework headers are
|
|
826
|
+
* valid by construction.
|
|
438
827
|
*/
|
|
439
828
|
#complete(
|
|
440
829
|
reply: CheckedReply,
|
|
@@ -443,10 +832,12 @@ export class Pipeline {
|
|
|
443
832
|
requestId: RequestId,
|
|
444
833
|
cors: CorsDecision | undefined,
|
|
445
834
|
preflight: boolean,
|
|
835
|
+
recheck: boolean,
|
|
446
836
|
): SerializedReply {
|
|
447
837
|
addMissingHeaders(reply.headers, event.responseHeaders)
|
|
448
838
|
applyCookies(reply, ctx)
|
|
449
839
|
this.#frameworkHeaders(reply.headers, requestId, cors, preflight)
|
|
840
|
+
if (!recheck) return reply
|
|
450
841
|
const checked = this.#checkHeaders(reply, ctx, event)
|
|
451
842
|
if (checked !== reply) {
|
|
452
843
|
applyCookies(checked, ctx)
|
|
@@ -455,6 +846,13 @@ export class Pipeline {
|
|
|
455
846
|
return checked
|
|
456
847
|
}
|
|
457
848
|
|
|
849
|
+
/** Checks the headers (unless only the serialiser set them), then the cookies of a reply. */
|
|
850
|
+
#checked(outcome: Outcome, ctx: RequestContext, event: HttpRequestEvent): Outcome {
|
|
851
|
+
if (outcome === RAW) return outcome
|
|
852
|
+
const checked = outcome.ownHeaders === true ? outcome : this.#checkHeaders(outcome, ctx, event)
|
|
853
|
+
return this.#checkCookies(checked, ctx, event)
|
|
854
|
+
}
|
|
855
|
+
|
|
458
856
|
/**
|
|
459
857
|
* Serialises the cookies of an `HttpResponse` (`withCookie`) with the plugin settings (defaults,
|
|
460
858
|
* signing). A cookie breaking a rule that depends on them (a prefix with the `secure` default,
|
|
@@ -464,7 +862,7 @@ export class Pipeline {
|
|
|
464
862
|
#checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
|
|
465
863
|
const cookies = reply.cookies
|
|
466
864
|
if (cookies === undefined || cookies.length === 0) return reply
|
|
467
|
-
const settings = this.#
|
|
865
|
+
const settings = this.#cookies
|
|
468
866
|
const lines: SerializedCookie[] = []
|
|
469
867
|
try {
|
|
470
868
|
for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
|
|
@@ -492,7 +890,9 @@ export class Pipeline {
|
|
|
492
890
|
preflight: boolean,
|
|
493
891
|
): void {
|
|
494
892
|
const header = this.#options.requestIdHeader
|
|
495
|
-
if (header !== undefined && headers[header] === undefined && isHeaderValue(requestId))
|
|
893
|
+
if (header !== undefined && headers[header] === undefined && (this.#validIds || isHeaderValue(requestId))) {
|
|
894
|
+
headers[header] = requestId
|
|
895
|
+
}
|
|
496
896
|
const policy = this.#options.cors
|
|
497
897
|
if (policy !== undefined) {
|
|
498
898
|
// a preflight answer carries its own CORS headers; other replies get them for allowed origins,
|
|
@@ -503,8 +903,7 @@ export class Pipeline {
|
|
|
503
903
|
}
|
|
504
904
|
if (policy.varyOrigin) mergeVary(headers, 'origin')
|
|
505
905
|
}
|
|
506
|
-
const
|
|
507
|
-
if (security !== undefined) addMissingHeaders(headers, security)
|
|
906
|
+
for (const [name, value] of this.#securityHeaders) if (headers[name] === undefined) headers[name] = value
|
|
508
907
|
if (this.draining) headers['connection'] = 'close'
|
|
509
908
|
}
|
|
510
909
|
|
|
@@ -542,12 +941,23 @@ export class Pipeline {
|
|
|
542
941
|
return serializeError(500, this.#internalBody(error), ctx.method)
|
|
543
942
|
}
|
|
544
943
|
|
|
545
|
-
|
|
944
|
+
/** The outcome of a request: its route run, or a reply of the framework. Never rejects (errors are mapped). */
|
|
945
|
+
#process(
|
|
546
946
|
ctx: RequestContext,
|
|
547
947
|
target: Target,
|
|
548
948
|
event: HttpRequestEvent,
|
|
549
949
|
cors: CorsDecision | undefined,
|
|
550
950
|
): Promise<Outcome> {
|
|
951
|
+
return target.kind === 'route' ? this.#invoke(ctx, target.plan, event) : this.#answer(ctx, target, event, cors)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Answers a request without running a route. Never rejects. */
|
|
955
|
+
async #answer(
|
|
956
|
+
ctx: RequestContext,
|
|
957
|
+
target: Exclude<Target, { readonly kind: 'route' }>,
|
|
958
|
+
event: HttpRequestEvent,
|
|
959
|
+
cors: CorsDecision | undefined,
|
|
960
|
+
): Promise<SerializedReply> {
|
|
551
961
|
try {
|
|
552
962
|
switch (target.kind) {
|
|
553
963
|
case 'error':
|
|
@@ -577,37 +987,86 @@ export class Pipeline {
|
|
|
577
987
|
headers: { location: target.location },
|
|
578
988
|
}),
|
|
579
989
|
)
|
|
580
|
-
default:
|
|
581
|
-
return await this.#invoke(ctx, target.plan, event)
|
|
582
990
|
}
|
|
583
991
|
} catch (error) {
|
|
584
|
-
|
|
585
|
-
const reply = await this.#errorReply(error, ctx)
|
|
586
|
-
event.status = reply.status
|
|
587
|
-
return reply
|
|
992
|
+
return await this.#failed(error, ctx, event)
|
|
588
993
|
}
|
|
589
994
|
}
|
|
590
995
|
|
|
996
|
+
/** The reply of an error (see {@link #errorReply}), recorded on the event. */
|
|
997
|
+
async #failed(error: unknown, ctx: RequestContext, event: HttpRequestEvent): Promise<SerializedReply> {
|
|
998
|
+
event.error = error
|
|
999
|
+
const reply = await this.#errorReply(error, ctx)
|
|
1000
|
+
event.status = reply.status
|
|
1001
|
+
return reply
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
|
|
591
1005
|
async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
|
|
592
|
-
const controller = this.#resolve(plan.controller)
|
|
593
|
-
for (const guard of plan.guards) {
|
|
594
|
-
const allowed = await this.#resolve(guard).canActivate(ctx)
|
|
595
|
-
if (allowed !== true) throw new ForbiddenException('Forbidden resource')
|
|
596
|
-
}
|
|
597
|
-
if (plan.parseBody) await ctx.parseBody()
|
|
598
|
-
const definition = plan.definition
|
|
599
|
-
const interceptors = plan.interceptors
|
|
600
1006
|
let result: unknown
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (
|
|
606
|
-
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
607
|
-
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
1007
|
+
try {
|
|
1008
|
+
const controller = this.#resolve(plan.controller)
|
|
1009
|
+
for (const guard of plan.guards) {
|
|
1010
|
+
const allowed = await this.#resolve(guard).canActivate(ctx)
|
|
1011
|
+
if (allowed !== true) throw new ForbiddenException('Forbidden resource')
|
|
608
1012
|
}
|
|
609
|
-
|
|
1013
|
+
if (plan.parseBody) await ctx.parseBody()
|
|
1014
|
+
const definition = plan.definition
|
|
1015
|
+
const interceptors = plan.interceptors
|
|
1016
|
+
if (interceptors.length === 0) {
|
|
1017
|
+
result = definition.invoke(controller, ctx)
|
|
1018
|
+
} else {
|
|
1019
|
+
const chain = async (index: number): Promise<unknown> => {
|
|
1020
|
+
if (index === interceptors.length) return await definition.invoke(controller, ctx)
|
|
1021
|
+
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
1022
|
+
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
1023
|
+
}
|
|
1024
|
+
result = chain(0)
|
|
1025
|
+
}
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
return await this.#failed(error, ctx, event)
|
|
610
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
|
|
611
1070
|
if (definition.rawResponse === true && !(result instanceof Response)) return RAW
|
|
612
1071
|
const options: SerializeOptions = {
|
|
613
1072
|
method: ctx.method,
|
|
@@ -617,7 +1076,6 @@ export class Pipeline {
|
|
|
617
1076
|
context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
|
|
618
1077
|
}
|
|
619
1078
|
const sse = definition.sse
|
|
620
|
-
let reply: SerializedReply
|
|
621
1079
|
if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
|
|
622
1080
|
// an @Sse route: the (awaited) result is the event source
|
|
623
1081
|
if (!isSseSource(result)) {
|
|
@@ -625,11 +1083,10 @@ export class Pipeline {
|
|
|
625
1083
|
`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
|
|
626
1084
|
)
|
|
627
1085
|
}
|
|
628
|
-
|
|
629
|
-
} else {
|
|
630
|
-
reply = await this.#serialize(result, ctx, options, sse)
|
|
1086
|
+
return this.#finish(event, serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options))
|
|
631
1087
|
}
|
|
632
|
-
return this.#finish(event,
|
|
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))
|
|
633
1090
|
}
|
|
634
1091
|
|
|
635
1092
|
/** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
|
|
@@ -790,20 +1247,10 @@ export class Pipeline {
|
|
|
790
1247
|
return this.#options.generateRequestId() as RequestId
|
|
791
1248
|
}
|
|
792
1249
|
|
|
793
|
-
#emit(event: HttpRequestEvent): void {
|
|
794
|
-
if (this.#hooks.hasListeners('http:request')) this.#hooks.emit('http:request', event)
|
|
795
|
-
}
|
|
796
|
-
|
|
797
1250
|
#enter(): void {
|
|
798
1251
|
this.#inflight++
|
|
799
1252
|
}
|
|
800
1253
|
|
|
801
|
-
/** Leaves once the request is over (immediately when it failed before being served). */
|
|
802
|
-
#leaveWhen(served: Served | undefined): void {
|
|
803
|
-
if (served === undefined) this.#leave()
|
|
804
|
-
else void served.done.then(() => this.#leave())
|
|
805
|
-
}
|
|
806
|
-
|
|
807
1254
|
#leave(): void {
|
|
808
1255
|
this.#inflight--
|
|
809
1256
|
if (this.#inflight === 0 && this.#idle.length > 0) {
|
|
@@ -814,6 +1261,151 @@ export class Pipeline {
|
|
|
814
1261
|
}
|
|
815
1262
|
}
|
|
816
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
|
+
|
|
817
1409
|
/**
|
|
818
1410
|
* The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
|
|
819
1411
|
* `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a
|
|
@@ -821,6 +1413,8 @@ export class Pipeline {
|
|
|
821
1413
|
* before a quiet stream's first byte, and lifts idle timeouts.
|
|
822
1414
|
*/
|
|
823
1415
|
function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob): AdapterReply {
|
|
1416
|
+
const text = serialized.text
|
|
1417
|
+
if (text !== undefined) return { status: serialized.status, headers: serialized.headers, body, text }
|
|
824
1418
|
const reply: AdapterReply = { status: serialized.status, headers: serialized.headers, body }
|
|
825
1419
|
if (blob !== undefined) return { ...reply, blob }
|
|
826
1420
|
const eventStream =
|
|
@@ -836,7 +1430,7 @@ function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob)
|
|
|
836
1430
|
* one in place. One line per cookie.
|
|
837
1431
|
*/
|
|
838
1432
|
function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
|
|
839
|
-
const own = reply.cookieLines ??
|
|
1433
|
+
const own = reply.cookieLines ?? NO_COOKIE_LINES
|
|
840
1434
|
const context = ctx.cookieLines
|
|
841
1435
|
if (own.length === 0 && context.length === 0) return
|
|
842
1436
|
const merged = new Map<string, string>()
|
|
@@ -849,6 +1443,13 @@ function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
|
|
|
849
1443
|
reply.headers['set-cookie'] = [...raw, ...merged.values()]
|
|
850
1444
|
}
|
|
851
1445
|
|
|
1446
|
+
const NO_COOKIE_LINES: readonly SerializedCookie[] = Object.freeze([])
|
|
1447
|
+
|
|
1448
|
+
/** Whether a reply body is streamed (a file is read through a stream too). */
|
|
1449
|
+
function isStreamed(body: ReplyBody): body is ReadableStream<Uint8Array> | Blob {
|
|
1450
|
+
return body instanceof ReadableStream || body instanceof Blob
|
|
1451
|
+
}
|
|
1452
|
+
|
|
852
1453
|
/** `SseOptions` of the `sse` metadata of a route (`heartbeat: 0` disables the heartbeat). */
|
|
853
1454
|
function sseOptions(definition: HttpSseDefinition): SseOptions {
|
|
854
1455
|
return {
|