@znight/http 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import type { Container, CoreLogger, Hooks, HttpRequestEvent, Token } from '@znight/core'
|
|
2
|
+
import type { AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
|
|
3
|
+
import type { CookieSettings } from './cookie-settings.ts'
|
|
4
|
+
import type { SerializedCookie } from './cookies.ts'
|
|
5
|
+
import type { CorsPolicy } from './cors.ts'
|
|
6
|
+
import type { CanActivate, ExceptionMapper, HttpInterceptor } from './interfaces.ts'
|
|
7
|
+
import type { HttpRouteDefinition, HttpSseDefinition } from './metadata.ts'
|
|
8
|
+
import type { TrustPolicy } from './proxy.ts'
|
|
9
|
+
import type { RouteTable } from './router.ts'
|
|
10
|
+
import type { BodyPlan } from './uploads.ts'
|
|
11
|
+
|
|
12
|
+
import { displayName } from '@znight/core'
|
|
13
|
+
|
|
14
|
+
import { isEventStreamType } from './adapters/shared.ts'
|
|
15
|
+
import { type HttpRouteInfo, RequestContext, type RequestId } from './context.ts'
|
|
16
|
+
import { DEFAULT_COOKIE_SETTINGS, serializeWith } from './cookie-settings.ts'
|
|
17
|
+
import { corsReplyHeaders, isPreflight, mergeVary, preflightHeaders } from './cors.ts'
|
|
18
|
+
import {
|
|
19
|
+
BadRequestException,
|
|
20
|
+
ForbiddenException,
|
|
21
|
+
type HeaderValue,
|
|
22
|
+
HttpException,
|
|
23
|
+
isHttpException,
|
|
24
|
+
MethodNotAllowedException,
|
|
25
|
+
NotFoundException,
|
|
26
|
+
} from './exceptions.ts'
|
|
27
|
+
import { isFileBody, resolveFile } from './files.ts'
|
|
28
|
+
import { findInvalidHeader, isHeaderValue } from './headers.ts'
|
|
29
|
+
import { decodePathValue, matchRoutePath, type RoutePath } from './path.ts'
|
|
30
|
+
import { splitUrl } from './query.ts'
|
|
31
|
+
import { HttpResponse, isHttpResponse } from './response.ts'
|
|
32
|
+
import {
|
|
33
|
+
addMissingHeaders,
|
|
34
|
+
assignHeaders,
|
|
35
|
+
type SerializedReply,
|
|
36
|
+
type SerializeOptions,
|
|
37
|
+
serializeError,
|
|
38
|
+
serializeFileReply,
|
|
39
|
+
serializeResult,
|
|
40
|
+
serializeSseReply,
|
|
41
|
+
} from './serialize.ts'
|
|
42
|
+
import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
|
|
43
|
+
import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
|
|
44
|
+
import { HttpTokens } from './tokens.ts'
|
|
45
|
+
import { emptyRecord, errorMessage } from './utils.ts'
|
|
46
|
+
|
|
47
|
+
/** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
|
|
48
|
+
export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
|
|
49
|
+
|
|
50
|
+
/** Everything the pipeline needs to serve one registered route (internal). */
|
|
51
|
+
export interface RoutePlan {
|
|
52
|
+
readonly definition: HttpRouteDefinition
|
|
53
|
+
/** The registered path (a trailing-slash variant has an extra empty static segment). */
|
|
54
|
+
readonly path: RoutePath
|
|
55
|
+
readonly info: HttpRouteInfo
|
|
56
|
+
readonly controller: Resolvable<object>
|
|
57
|
+
readonly guards: readonly Resolvable<CanActivate>[]
|
|
58
|
+
readonly interceptors: readonly Resolvable<HttpInterceptor>[]
|
|
59
|
+
/** Parse the body before the interceptors (`@Body()` or upload parameters). */
|
|
60
|
+
readonly parseBody: boolean
|
|
61
|
+
/** Body limit and multipart rules of the route. */
|
|
62
|
+
readonly body: BodyPlan
|
|
63
|
+
/** Trailing-slash variant answered with a redirect to the path without its trailing slash. */
|
|
64
|
+
readonly redirect?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Pipeline settings (resolved plugin options). */
|
|
68
|
+
export interface PipelineOptions {
|
|
69
|
+
readonly adapter: string
|
|
70
|
+
/** Body plan of unmatched requests (plugin `bodyLimit`, generic multipart rules). */
|
|
71
|
+
readonly body: BodyPlan
|
|
72
|
+
readonly exposeErrors: boolean
|
|
73
|
+
readonly requestIdHeader: string | undefined
|
|
74
|
+
readonly generateRequestId: () => string
|
|
75
|
+
readonly mappers: readonly Resolvable<ExceptionMapper>[]
|
|
76
|
+
/** CORS policy (`cors` option), `undefined` when disabled. */
|
|
77
|
+
readonly cors?: CorsPolicy | undefined
|
|
78
|
+
/** Security headers added to every reply that does not set them (`securityHeaders` option). */
|
|
79
|
+
readonly securityHeaders?: Readonly<Record<string, string>>
|
|
80
|
+
/** `trustProxy` policy, `undefined` when proxies are not trusted. */
|
|
81
|
+
readonly trustProxy?: TrustPolicy | undefined
|
|
82
|
+
/** Cookie defaults and signing key (`cookies` option). Default: secure defaults, no signing. */
|
|
83
|
+
readonly cookies?: CookieSettings
|
|
84
|
+
/** Event stream defaults (`sse` option). */
|
|
85
|
+
readonly sse?: SseSettings
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolved plugin option `sse`. */
|
|
89
|
+
export interface SseSettings {
|
|
90
|
+
/** Heartbeat interval in ms; `0`: none. */
|
|
91
|
+
readonly heartbeat: number
|
|
92
|
+
/** `retry` sent first, if any. */
|
|
93
|
+
readonly retry: number | undefined
|
|
94
|
+
/** Time a source gets to finish after a graceful close started (ms). */
|
|
95
|
+
readonly shutdownGraceMs: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Default event stream settings. */
|
|
99
|
+
export const DEFAULT_SSE_SETTINGS: SseSettings = Object.freeze({
|
|
100
|
+
heartbeat: 8_000,
|
|
101
|
+
retry: undefined,
|
|
102
|
+
shutdownGraceMs: 1_000,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
type Target =
|
|
106
|
+
| { readonly kind: 'route'; readonly plan: RoutePlan }
|
|
107
|
+
| { readonly kind: 'error'; readonly error: unknown }
|
|
108
|
+
| { readonly kind: 'options'; readonly allow: string }
|
|
109
|
+
| { readonly kind: 'redirect'; readonly location: string }
|
|
110
|
+
/** A CORS preflight; `allow` is `undefined` when no route has the path (404). */
|
|
111
|
+
| { readonly kind: 'preflight'; readonly allow: string | undefined }
|
|
112
|
+
|
|
113
|
+
/** The CORS decision of a request (an `origin` header and a CORS policy). */
|
|
114
|
+
interface CorsDecision {
|
|
115
|
+
readonly policy: CorsPolicy
|
|
116
|
+
readonly origin: string
|
|
117
|
+
readonly allowed: boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const RAW = Symbol('raw response')
|
|
121
|
+
type Outcome = SerializedReply | typeof RAW
|
|
122
|
+
|
|
123
|
+
/** A reply whose `HttpResponse` cookies were serialised (see `#checkCookies`). */
|
|
124
|
+
type CheckedReply = SerializedReply & { readonly cookieLines?: readonly SerializedCookie[] }
|
|
125
|
+
|
|
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
|
+
const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
|
|
133
|
+
const MAX_PATH_IN_MESSAGE = 200
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The request pipeline (architecture §9.1):
|
|
137
|
+
* `runInScope({ HttpContext, RequestId }) → hooks.run('http:request') → guards → body → interceptors
|
|
138
|
+
* → invoke → serialise`, errors being mapped to responses inside `hooks.run`.
|
|
139
|
+
*/
|
|
140
|
+
export class Pipeline {
|
|
141
|
+
readonly #container: Container
|
|
142
|
+
readonly #hooks: Hooks
|
|
143
|
+
readonly #logger: CoreLogger
|
|
144
|
+
readonly #options: PipelineOptions
|
|
145
|
+
readonly #table: RouteTable<RoutePlan>
|
|
146
|
+
#inflight = 0
|
|
147
|
+
#idle: (() => void)[] = []
|
|
148
|
+
/** Open event streams. */
|
|
149
|
+
readonly #streams = new Set<SseSession>()
|
|
150
|
+
/** Set while the server drains: responses ask clients to close their connection. */
|
|
151
|
+
draining = false
|
|
152
|
+
|
|
153
|
+
constructor(
|
|
154
|
+
container: Container,
|
|
155
|
+
hooks: Hooks,
|
|
156
|
+
logger: CoreLogger,
|
|
157
|
+
options: PipelineOptions,
|
|
158
|
+
table: RouteTable<RoutePlan>,
|
|
159
|
+
) {
|
|
160
|
+
this.#container = container
|
|
161
|
+
this.#hooks = hooks
|
|
162
|
+
this.#logger = logger
|
|
163
|
+
this.#options = options
|
|
164
|
+
this.#table = table
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Number of requests being processed. */
|
|
168
|
+
get inflight(): number {
|
|
169
|
+
return this.#inflight
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Number of open event streams. */
|
|
173
|
+
get streams(): number {
|
|
174
|
+
return this.#streams.size
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Starts the graceful close of every open event stream (`ctx.signal` aborts with `kind`, the source
|
|
179
|
+
* gets `shutdownGraceMs` to finish, then the client receives a jittered `retry`).
|
|
180
|
+
*/
|
|
181
|
+
closeStreams(kind: 'shutdown' | 'expired'): void {
|
|
182
|
+
for (const session of [...this.#streams]) session.close(kind)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Resolves once no request is being processed. */
|
|
186
|
+
whenIdle(): Promise<void> {
|
|
187
|
+
if (this.#inflight === 0) return Promise.resolve()
|
|
188
|
+
return new Promise(resolve => this.#idle.push(resolve))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
|
|
193
|
+
* open) until a streamed or file response body has been fully read or cancelled by the adapter.
|
|
194
|
+
*/
|
|
195
|
+
async handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
|
|
196
|
+
const startTime = performance.now()
|
|
197
|
+
this.#enter()
|
|
198
|
+
let served: Served | undefined
|
|
199
|
+
try {
|
|
200
|
+
served = this.#isPreflight(request)
|
|
201
|
+
? await this.#preflight(request, startTime)
|
|
202
|
+
: await this.#route(plan, request, startTime)
|
|
203
|
+
return served.reply
|
|
204
|
+
} finally {
|
|
205
|
+
this.#leaveWhen(served)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<Served> {
|
|
210
|
+
const { path, query } = splitUrl(request.url)
|
|
211
|
+
const raw = matchRoutePath(plan.path, path)
|
|
212
|
+
// the native router matched a path the Znight grammar rejects (e.g. an empty `:param` in Fastify)
|
|
213
|
+
// or a normalised path (Hono): route the raw path with the route table instead
|
|
214
|
+
if (raw === undefined) return this.#fallback(request, undefined, startTime, !rerouted)
|
|
215
|
+
if (plan.redirect === true) {
|
|
216
|
+
const canonical = sameOriginPath(path.slice(0, -1))
|
|
217
|
+
const location = query === '' ? canonical : `${canonical}?${query}`
|
|
218
|
+
return this.#serve(request, undefined, emptyRecord(), { kind: 'redirect', location }, startTime, plan.body)
|
|
219
|
+
}
|
|
220
|
+
const params = emptyRecord<string>()
|
|
221
|
+
let malformed: string | undefined
|
|
222
|
+
for (const name of Object.keys(raw)) {
|
|
223
|
+
const decoded = decodePathValue(raw[name] as string)
|
|
224
|
+
if (decoded === undefined) malformed ??= name
|
|
225
|
+
else params[name] = decoded
|
|
226
|
+
}
|
|
227
|
+
const target: Target =
|
|
228
|
+
malformed === undefined
|
|
229
|
+
? { kind: 'route', plan }
|
|
230
|
+
: { kind: 'error', error: new BadRequestException(`Malformed URL parameter "${malformed}"`) }
|
|
231
|
+
return this.#serve(request, plan.info, params, target, startTime, plan.body)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Handles an unmatched request (404, 405, automatic OPTIONS) or a framework error. */
|
|
235
|
+
async fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
|
|
236
|
+
const startTime = performance.now()
|
|
237
|
+
this.#enter()
|
|
238
|
+
let served: Served | undefined
|
|
239
|
+
try {
|
|
240
|
+
served = await this.#fallback(request, error, startTime)
|
|
241
|
+
return served.reply
|
|
242
|
+
} finally {
|
|
243
|
+
this.#leaveWhen(served)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<Served> {
|
|
248
|
+
const body = this.#options.body
|
|
249
|
+
if (error !== undefined) {
|
|
250
|
+
const target: Target = { kind: 'error', error: frameworkError(error) }
|
|
251
|
+
return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
|
|
252
|
+
}
|
|
253
|
+
if (this.#isPreflight(request)) return this.#preflight(request, startTime)
|
|
254
|
+
const { path } = splitUrl(request.url)
|
|
255
|
+
if (reroute) {
|
|
256
|
+
// routing happens on the raw path: serve a route the adapter's router missed (Hono routes the
|
|
257
|
+
// WHATWG-normalised URL, where `/files/%2e%2e/x` became `/x`)
|
|
258
|
+
const plan = this.#table.find(request.method, path)
|
|
259
|
+
if (plan !== undefined) return this.#route(plan, request, startTime, true)
|
|
260
|
+
}
|
|
261
|
+
const allowed = this.#table.allowedMethods(path)
|
|
262
|
+
let target: Target
|
|
263
|
+
if (allowed.length === 0) {
|
|
264
|
+
target = { kind: 'error', error: new NotFoundException(`Cannot ${request.method} ${truncate(path)}`) }
|
|
265
|
+
} else if (request.method === 'OPTIONS') {
|
|
266
|
+
target = { kind: 'options', allow: allowHeader(allowed) }
|
|
267
|
+
} else {
|
|
268
|
+
// OPTIONS is answered automatically, hence supported (RFC 9110 §15.5.6: the same list as OPTIONS)
|
|
269
|
+
target = {
|
|
270
|
+
kind: 'error',
|
|
271
|
+
error: new MethodNotAllowedException(
|
|
272
|
+
`Method ${request.method} is not allowed on ${truncate(path)}`,
|
|
273
|
+
undefined,
|
|
274
|
+
{
|
|
275
|
+
headers: { allow: allowHeader(allowed) },
|
|
276
|
+
},
|
|
277
|
+
),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A CORS preflight (with the `cors` option): answered without running any route. */
|
|
284
|
+
#isPreflight(request: AdapterRequest): boolean {
|
|
285
|
+
return this.#options.cors !== undefined && isPreflight(request.method, request.headers)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#preflight(request: AdapterRequest, startTime: number): Promise<Served> {
|
|
289
|
+
const allowed = this.#table.allowedMethods(splitUrl(request.url).path)
|
|
290
|
+
const target: Target = { kind: 'preflight', allow: allowed.length === 0 ? undefined : allowHeader(allowed) }
|
|
291
|
+
return this.#serve(request, undefined, emptyRecord(), target, startTime, this.#options.body)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async #serve(
|
|
295
|
+
request: AdapterRequest,
|
|
296
|
+
route: HttpRouteInfo | undefined,
|
|
297
|
+
params: Record<string, string>,
|
|
298
|
+
target: Target,
|
|
299
|
+
startTime: number,
|
|
300
|
+
body: BodyPlan,
|
|
301
|
+
): Promise<Served> {
|
|
302
|
+
const requestId = this.#requestId(request)
|
|
303
|
+
const proxy = this.#options.trustProxy
|
|
304
|
+
const ctx = new RequestContext(request, {
|
|
305
|
+
requestId,
|
|
306
|
+
adapter: this.#options.adapter,
|
|
307
|
+
route,
|
|
308
|
+
params,
|
|
309
|
+
body,
|
|
310
|
+
cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
|
|
311
|
+
...(proxy === undefined
|
|
312
|
+
? {}
|
|
313
|
+
: { client: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true) }),
|
|
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: {},
|
|
327
|
+
}
|
|
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
|
+
// the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
|
|
337
|
+
const done = this.#container
|
|
338
|
+
.runInScope(async () => {
|
|
339
|
+
let hookError: unknown
|
|
340
|
+
let failed = false
|
|
341
|
+
let run: ContextRunner | undefined
|
|
342
|
+
if (this.#options.cors !== undefined) cors = await this.#corsDecision(request, requestId)
|
|
343
|
+
try {
|
|
344
|
+
await this.#hooks.run('http:request', event, async () => {
|
|
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
|
+
])
|
|
402
|
+
.then(
|
|
403
|
+
() => {
|
|
404
|
+
ctx.complete()
|
|
405
|
+
},
|
|
406
|
+
(error: unknown) => {
|
|
407
|
+
ctx.complete()
|
|
408
|
+
// the callback above never throws: only the disposal of request-scoped instances can fail
|
|
409
|
+
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId })
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
await Promise.race([ready, done])
|
|
413
|
+
if (outcome === RAW) {
|
|
414
|
+
const status = request.rawStatus?.() ?? 200
|
|
415
|
+
event.status ??= status
|
|
416
|
+
this.#emit(event)
|
|
417
|
+
return { reply: { status, headers: {}, body: null, raw: true }, done }
|
|
418
|
+
}
|
|
419
|
+
if (reply === undefined) {
|
|
420
|
+
const failure = new Error('Request not handled')
|
|
421
|
+
reply = this.#complete(
|
|
422
|
+
serializeError(500, this.#internalBody(failure), ctx.method),
|
|
423
|
+
ctx,
|
|
424
|
+
event,
|
|
425
|
+
requestId,
|
|
426
|
+
cors,
|
|
427
|
+
preflight,
|
|
428
|
+
)
|
|
429
|
+
event.status = 500
|
|
430
|
+
}
|
|
431
|
+
this.#emit(event)
|
|
432
|
+
return { reply, done }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Adds the headers of the `http:request` wrappers, then the framework headers (see
|
|
437
|
+
* {@link #frameworkHeaders}), then checks the final headers (see {@link #checkHeaders}).
|
|
438
|
+
*/
|
|
439
|
+
#complete(
|
|
440
|
+
reply: CheckedReply,
|
|
441
|
+
ctx: RequestContext,
|
|
442
|
+
event: HttpRequestEvent,
|
|
443
|
+
requestId: RequestId,
|
|
444
|
+
cors: CorsDecision | undefined,
|
|
445
|
+
preflight: boolean,
|
|
446
|
+
): SerializedReply {
|
|
447
|
+
addMissingHeaders(reply.headers, event.responseHeaders)
|
|
448
|
+
applyCookies(reply, ctx)
|
|
449
|
+
this.#frameworkHeaders(reply.headers, requestId, cors, preflight)
|
|
450
|
+
const checked = this.#checkHeaders(reply, ctx, event)
|
|
451
|
+
if (checked !== reply) {
|
|
452
|
+
applyCookies(checked, ctx)
|
|
453
|
+
this.#frameworkHeaders(checked.headers, requestId, cors, preflight)
|
|
454
|
+
}
|
|
455
|
+
return checked
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Serialises the cookies of an `HttpResponse` (`withCookie`) with the plugin settings (defaults,
|
|
460
|
+
* signing). A cookie breaking a rule that depends on them (a prefix with the `secure` default,
|
|
461
|
+
* `SameSite=None` without `secure`, signing without secrets, the size after signing) turns the
|
|
462
|
+
* reply into a plain 500, logged as `Invalid cookie "x": …` (like an invalid header).
|
|
463
|
+
*/
|
|
464
|
+
#checkCookies(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): CheckedReply {
|
|
465
|
+
const cookies = reply.cookies
|
|
466
|
+
if (cookies === undefined || cookies.length === 0) return reply
|
|
467
|
+
const settings = this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS
|
|
468
|
+
const lines: SerializedCookie[] = []
|
|
469
|
+
try {
|
|
470
|
+
for (const cookie of cookies) lines.push(serializeWith(cookie, settings))
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (reply.body instanceof ReadableStream) discardStream(reply.body)
|
|
473
|
+
event.error = error
|
|
474
|
+
event.status = 500
|
|
475
|
+
this.#log(error, ctx)
|
|
476
|
+
return serializeError(500, this.#internalBody(error), ctx.method)
|
|
477
|
+
}
|
|
478
|
+
return { ...reply, cookieLines: lines }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Headers the plugin adds to every reply when nothing set them: the request id, the CORS headers
|
|
483
|
+
* of an allowed origin (`vary: origin` whatever the origin) or, with a wildcard policy, of every
|
|
484
|
+
* reply (`access-control-allow-origin: *` does not depend on the request, so a cached reply serves
|
|
485
|
+
* CORS and non-CORS requests alike), the security headers; and `connection: close` while
|
|
486
|
+
* draining.
|
|
487
|
+
*/
|
|
488
|
+
#frameworkHeaders(
|
|
489
|
+
headers: Record<string, HeaderValue>,
|
|
490
|
+
requestId: RequestId,
|
|
491
|
+
cors: CorsDecision | undefined,
|
|
492
|
+
preflight: boolean,
|
|
493
|
+
): void {
|
|
494
|
+
const header = this.#options.requestIdHeader
|
|
495
|
+
if (header !== undefined && headers[header] === undefined && isHeaderValue(requestId)) headers[header] = requestId
|
|
496
|
+
const policy = this.#options.cors
|
|
497
|
+
if (policy !== undefined) {
|
|
498
|
+
// a preflight answer carries its own CORS headers; other replies get them for allowed origins,
|
|
499
|
+
// or always with a wildcard policy (with or without an origin header: no vary needed)
|
|
500
|
+
if (!preflight) {
|
|
501
|
+
if (policy.wildcard) addMissingHeaders(headers, corsReplyHeaders(policy, '*'))
|
|
502
|
+
else if (cors?.allowed === true) addMissingHeaders(headers, corsReplyHeaders(policy, cors.origin))
|
|
503
|
+
}
|
|
504
|
+
if (policy.varyOrigin) mergeVary(headers, 'origin')
|
|
505
|
+
}
|
|
506
|
+
const security = this.#options.securityHeaders
|
|
507
|
+
if (security !== undefined) addMissingHeaders(headers, security)
|
|
508
|
+
if (this.draining) headers['connection'] = 'close'
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The CORS decision of a request carrying an `origin` header, made once before the hooks run. An
|
|
513
|
+
* origin function that throws or rejects denies the origin (logged).
|
|
514
|
+
*/
|
|
515
|
+
async #corsDecision(request: AdapterRequest, requestId: RequestId): Promise<CorsDecision | undefined> {
|
|
516
|
+
const policy = this.#options.cors
|
|
517
|
+
const origin = request.headers['origin']
|
|
518
|
+
if (policy === undefined || origin === undefined) return undefined
|
|
519
|
+
let allowed = false
|
|
520
|
+
try {
|
|
521
|
+
// an origin that cannot be echoed in a response header is never allowed
|
|
522
|
+
allowed = isHeaderValue(origin) && (await policy.allows(origin)) === true
|
|
523
|
+
} catch (error) {
|
|
524
|
+
this.#logger.error('The CORS origin check failed: the origin is denied', { err: error, requestId, origin })
|
|
525
|
+
}
|
|
526
|
+
return { policy, origin, allowed }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* A reply with a header no adapter can send (`ctx.setHeader('x', 'a\r\nb')`, a redirect to a
|
|
531
|
+
* client-provided URL with CR/LF, a non Latin-1 value...) becomes a plain 500, identical on every
|
|
532
|
+
* adapter: its body is discarded (a stream is cancelled) and the error logged.
|
|
533
|
+
*/
|
|
534
|
+
#checkHeaders(reply: SerializedReply, ctx: RequestContext, event: HttpRequestEvent): SerializedReply {
|
|
535
|
+
const name = findInvalidHeader(reply.headers)
|
|
536
|
+
if (name === undefined) return reply
|
|
537
|
+
if (reply.body instanceof ReadableStream) discardStream(reply.body)
|
|
538
|
+
const error = new TypeError(`Invalid response header ${JSON.stringify(truncate(name))}: invalid name or value`)
|
|
539
|
+
event.error = error
|
|
540
|
+
event.status = 500
|
|
541
|
+
this.#log(error, ctx)
|
|
542
|
+
return serializeError(500, this.#internalBody(error), ctx.method)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async #process(
|
|
546
|
+
ctx: RequestContext,
|
|
547
|
+
target: Target,
|
|
548
|
+
event: HttpRequestEvent,
|
|
549
|
+
cors: CorsDecision | undefined,
|
|
550
|
+
): Promise<Outcome> {
|
|
551
|
+
try {
|
|
552
|
+
switch (target.kind) {
|
|
553
|
+
case 'error':
|
|
554
|
+
throw target.error
|
|
555
|
+
case 'preflight':
|
|
556
|
+
// never runs guards, interceptors or handlers (@Options and @All routes included)
|
|
557
|
+
if (target.allow === undefined) throw new NotFoundException(`Cannot OPTIONS ${truncate(ctx.path)}`)
|
|
558
|
+
if (cors === undefined || !cors.allowed) throw new ForbiddenException('CORS origin not allowed')
|
|
559
|
+
return this.#finish(event, {
|
|
560
|
+
status: 204,
|
|
561
|
+
headers: withContextHeaders(preflightHeaders(cors.policy, cors.origin, ctx.headers, target.allow), ctx),
|
|
562
|
+
body: null,
|
|
563
|
+
})
|
|
564
|
+
case 'options':
|
|
565
|
+
return this.#finish(event, {
|
|
566
|
+
status: 204,
|
|
567
|
+
headers: withContextHeaders({ allow: target.allow }, ctx),
|
|
568
|
+
body: null,
|
|
569
|
+
})
|
|
570
|
+
case 'redirect':
|
|
571
|
+
return this.#finish(
|
|
572
|
+
event,
|
|
573
|
+
serializeResult(undefined, {
|
|
574
|
+
method: ctx.method,
|
|
575
|
+
routeMethod: ctx.method,
|
|
576
|
+
status: 308,
|
|
577
|
+
headers: { location: target.location },
|
|
578
|
+
}),
|
|
579
|
+
)
|
|
580
|
+
default:
|
|
581
|
+
return await this.#invoke(ctx, target.plan, event)
|
|
582
|
+
}
|
|
583
|
+
} catch (error) {
|
|
584
|
+
event.error = error
|
|
585
|
+
const reply = await this.#errorReply(error, ctx)
|
|
586
|
+
event.status = reply.status
|
|
587
|
+
return reply
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
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
|
+
let result: unknown
|
|
601
|
+
if (interceptors.length === 0) {
|
|
602
|
+
result = await definition.invoke(controller, ctx)
|
|
603
|
+
} else {
|
|
604
|
+
const chain = async (index: number): Promise<unknown> => {
|
|
605
|
+
if (index === interceptors.length) return await definition.invoke(controller, ctx)
|
|
606
|
+
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
607
|
+
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
608
|
+
}
|
|
609
|
+
result = await chain(0)
|
|
610
|
+
}
|
|
611
|
+
if (definition.rawResponse === true && !(result instanceof Response)) return RAW
|
|
612
|
+
const options: SerializeOptions = {
|
|
613
|
+
method: ctx.method,
|
|
614
|
+
routeMethod: definition.method,
|
|
615
|
+
status: definition.status,
|
|
616
|
+
headers: definition.headers,
|
|
617
|
+
context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
|
|
618
|
+
}
|
|
619
|
+
const sse = definition.sse
|
|
620
|
+
let reply: SerializedReply
|
|
621
|
+
if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
|
|
622
|
+
// an @Sse route: the (awaited) result is the event source
|
|
623
|
+
if (!isSseSource(result)) {
|
|
624
|
+
throw new TypeError(
|
|
625
|
+
`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options)
|
|
629
|
+
} else {
|
|
630
|
+
reply = await this.#serialize(result, ctx, options, sse)
|
|
631
|
+
}
|
|
632
|
+
return this.#finish(event, reply)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
|
|
636
|
+
async #serialize(
|
|
637
|
+
result: unknown,
|
|
638
|
+
ctx: RequestContext,
|
|
639
|
+
options: SerializeOptions,
|
|
640
|
+
route?: HttpSseDefinition,
|
|
641
|
+
): Promise<SerializedReply> {
|
|
642
|
+
if (isHttpResponse(result)) {
|
|
643
|
+
if (isFileBody(result.body)) return serializeFileReply(await resolveFile(result.body, ctx), result, options)
|
|
644
|
+
if (isSseBody(result.body)) {
|
|
645
|
+
const body = result.body
|
|
646
|
+
// HttpResponse.sse() options win over the @Sse options of the route
|
|
647
|
+
const merged = { ...(route === undefined ? {} : sseOptions(route)), ...definedOptions(body.options) }
|
|
648
|
+
return serializeSseReply(this.#openStream(body.source, merged, ctx), result, options)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return serializeResult(result, options)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** An event stream reading `source` in the request (route options over the plugin `sse` option). */
|
|
655
|
+
#openStream(source: SseSource, options: SseOptions, ctx: RequestContext): SseSession {
|
|
656
|
+
const settings = this.#options.sse ?? DEFAULT_SSE_SETTINGS
|
|
657
|
+
const heartbeat = options.heartbeat === undefined ? settings.heartbeat : options.heartbeat || 0
|
|
658
|
+
const retry = options.retry ?? settings.retry
|
|
659
|
+
const logger = this.#logger
|
|
660
|
+
return new SseSession(
|
|
661
|
+
source,
|
|
662
|
+
{
|
|
663
|
+
heartbeat,
|
|
664
|
+
retry,
|
|
665
|
+
maxDuration: options.maxDuration,
|
|
666
|
+
graceMs: settings.shutdownGraceMs,
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
abort: kind => ctx.abort(kind),
|
|
670
|
+
error: (message, error) => logger.error(message, { err: error, requestId: ctx.requestId, path: ctx.path }),
|
|
671
|
+
warn: message => logger.warn(message, { requestId: ctx.requestId, path: ctx.path }),
|
|
672
|
+
},
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
#finish(event: HttpRequestEvent, reply: SerializedReply): SerializedReply {
|
|
677
|
+
event.status = reply.status
|
|
678
|
+
return reply
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Maps an error to a reply: exception mappers first, then the default rendering. Never throws. */
|
|
682
|
+
async #errorReply(error: unknown, ctx: RequestContext): Promise<SerializedReply> {
|
|
683
|
+
for (const mapper of this.#options.mappers) {
|
|
684
|
+
let mapped: unknown
|
|
685
|
+
try {
|
|
686
|
+
mapped = await this.#resolve(mapper).map(error, ctx)
|
|
687
|
+
} catch (mapperError) {
|
|
688
|
+
this.#logger.error('An exception mapper failed', { err: mapperError, requestId: ctx.requestId })
|
|
689
|
+
continue
|
|
690
|
+
}
|
|
691
|
+
if (mapped === undefined) continue
|
|
692
|
+
try {
|
|
693
|
+
if (isHttpResponse(mapped) && mapped.status === undefined) {
|
|
694
|
+
// an error response without explicit status keeps the status of the error
|
|
695
|
+
mapped = new HttpResponse(mapped.body, {
|
|
696
|
+
status: isHttpException(error) ? error.status : 500,
|
|
697
|
+
headers: mapped.headers,
|
|
698
|
+
cookies: (mapped as { cookies?: HttpResponse['cookies'] }).cookies ?? [],
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
if (mapped instanceof Response || isHttpResponse(mapped)) {
|
|
702
|
+
return await this.#serialize(mapped, ctx, {
|
|
703
|
+
method: ctx.method,
|
|
704
|
+
routeMethod: ctx.method,
|
|
705
|
+
status: undefined,
|
|
706
|
+
headers: undefined,
|
|
707
|
+
context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
if (isHttpException(mapped)) return this.#renderException(mapped, ctx, error)
|
|
711
|
+
} catch (renderError) {
|
|
712
|
+
this.#logger.error('Failed to render the result of an exception mapper', {
|
|
713
|
+
err: renderError,
|
|
714
|
+
requestId: ctx.requestId,
|
|
715
|
+
})
|
|
716
|
+
continue
|
|
717
|
+
}
|
|
718
|
+
this.#logger.warn(
|
|
719
|
+
'An exception mapper returned an unsupported value (expected HttpException, HttpResponse, Response or undefined)',
|
|
720
|
+
{
|
|
721
|
+
requestId: ctx.requestId,
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
return this.#renderException(error, ctx, error)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
#renderException(error: unknown, ctx: RequestContext, original: unknown): SerializedReply {
|
|
729
|
+
let status: number
|
|
730
|
+
let body: unknown
|
|
731
|
+
let extra: Readonly<Record<string, HeaderValue>> | undefined
|
|
732
|
+
if (isHttpException(error)) {
|
|
733
|
+
status = error.status
|
|
734
|
+
try {
|
|
735
|
+
body = error.toBody()
|
|
736
|
+
} catch (bodyError) {
|
|
737
|
+
this.#log(bodyError, ctx)
|
|
738
|
+
status = 500
|
|
739
|
+
body = this.#internalBody(bodyError)
|
|
740
|
+
}
|
|
741
|
+
extra = error.headers
|
|
742
|
+
if (status >= 500) this.#log(original, ctx)
|
|
743
|
+
} else {
|
|
744
|
+
status = 500
|
|
745
|
+
body = this.#internalBody(error)
|
|
746
|
+
this.#log(error, ctx)
|
|
747
|
+
}
|
|
748
|
+
let reply: SerializedReply
|
|
749
|
+
try {
|
|
750
|
+
reply = serializeError(status, body, ctx.method)
|
|
751
|
+
} catch (serializeFailure) {
|
|
752
|
+
this.#log(serializeFailure, ctx)
|
|
753
|
+
reply = serializeError(500, this.#internalBody(serializeFailure), ctx.method)
|
|
754
|
+
extra = undefined
|
|
755
|
+
}
|
|
756
|
+
if (ctx.hasResponseHeaders) assignHeaders(reply.headers, ctx.responseHeaders)
|
|
757
|
+
if (extra !== undefined) assignHeaders(reply.headers, extra)
|
|
758
|
+
return reply
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
#internalBody(error: unknown): object {
|
|
762
|
+
return this.#options.exposeErrors
|
|
763
|
+
? { statusCode: 500, error: 'Internal Server Error', message: errorMessage(error) }
|
|
764
|
+
: { statusCode: 500, error: 'Internal Server Error' }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
#log(error: unknown, ctx: RequestContext): void {
|
|
768
|
+
const route = ctx.route
|
|
769
|
+
this.#logger.error(`Request failed: ${ctx.method} ${route?.path ?? truncate(ctx.path)}`, {
|
|
770
|
+
err: error,
|
|
771
|
+
requestId: ctx.requestId,
|
|
772
|
+
method: ctx.method,
|
|
773
|
+
path: ctx.path,
|
|
774
|
+
...(route === undefined
|
|
775
|
+
? {}
|
|
776
|
+
: { route: route.path, handler: `${displayName(route.controller)}.${route.handler}` }),
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#resolve<T>(resolvable: Resolvable<T>): T {
|
|
781
|
+
return 'instance' in resolvable ? resolvable.instance : (this.#container.get(resolvable.token) as T)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
#requestId(request: AdapterRequest): RequestId {
|
|
785
|
+
const header = this.#options.requestIdHeader
|
|
786
|
+
if (header !== undefined) {
|
|
787
|
+
const incoming = request.headers[header]
|
|
788
|
+
if (incoming !== undefined && REQUEST_ID.test(incoming)) return incoming as RequestId
|
|
789
|
+
}
|
|
790
|
+
return this.#options.generateRequestId() as RequestId
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
#emit(event: HttpRequestEvent): void {
|
|
794
|
+
if (this.#hooks.hasListeners('http:request')) this.#hooks.emit('http:request', event)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
#enter(): void {
|
|
798
|
+
this.#inflight++
|
|
799
|
+
}
|
|
800
|
+
|
|
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
|
+
#leave(): void {
|
|
808
|
+
this.#inflight--
|
|
809
|
+
if (this.#inflight === 0 && this.#idle.length > 0) {
|
|
810
|
+
const waiting = this.#idle
|
|
811
|
+
this.#idle = []
|
|
812
|
+
for (const resolve of waiting) resolve()
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The reply handed to the adapter (internal fields of the serialised reply left out). A streamed
|
|
819
|
+
* `text/event-stream` body is an event stream whatever produced it (`@Sse`, `HttpResponse.sse()`, a
|
|
820
|
+
* proxied `fetch()` response, `HttpResponse.stream()`): the adapter sends its headers at once, even
|
|
821
|
+
* before a quiet stream's first byte, and lifts idle timeouts.
|
|
822
|
+
*/
|
|
823
|
+
function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob): AdapterReply {
|
|
824
|
+
const reply: AdapterReply = { status: serialized.status, headers: serialized.headers, body }
|
|
825
|
+
if (blob !== undefined) return { ...reply, blob }
|
|
826
|
+
const eventStream =
|
|
827
|
+
serialized.sse !== undefined ||
|
|
828
|
+
(body instanceof ReadableStream && isEventStreamType(serialized.headers['content-type']))
|
|
829
|
+
return eventStream ? { ...reply, eventStream: true } : reply
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Appends the cookies to `set-cookie`, after the raw values (`@Header`, `ctx.setHeader()`, the
|
|
834
|
+
* `HttpResponse` headers, a Web `Response`): the cookies of the context (`ctx.setCookie()`), then
|
|
835
|
+
* those of the `HttpResponse`, a cookie of the same key (name, domain, path) replacing the earlier
|
|
836
|
+
* one in place. One line per cookie.
|
|
837
|
+
*/
|
|
838
|
+
function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
|
|
839
|
+
const own = reply.cookieLines ?? []
|
|
840
|
+
const context = ctx.cookieLines
|
|
841
|
+
if (own.length === 0 && context.length === 0) return
|
|
842
|
+
const merged = new Map<string, string>()
|
|
843
|
+
for (const { key, line } of context) merged.set(key, line)
|
|
844
|
+
for (const { key, line } of own) merged.set(key, line)
|
|
845
|
+
const existing = reply.headers['set-cookie']
|
|
846
|
+
let raw: string[] = []
|
|
847
|
+
if (typeof existing === 'string') raw = [existing]
|
|
848
|
+
else if (existing !== undefined) raw = [...existing]
|
|
849
|
+
reply.headers['set-cookie'] = [...raw, ...merged.values()]
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** `SseOptions` of the `sse` metadata of a route (`heartbeat: 0` disables the heartbeat). */
|
|
853
|
+
function sseOptions(definition: HttpSseDefinition): SseOptions {
|
|
854
|
+
return {
|
|
855
|
+
...(definition.heartbeat === undefined
|
|
856
|
+
? {}
|
|
857
|
+
: { heartbeat: definition.heartbeat === 0 ? false : definition.heartbeat }),
|
|
858
|
+
...(definition.retry === undefined ? {} : { retry: definition.retry }),
|
|
859
|
+
...(definition.maxDuration === undefined ? {} : { maxDuration: definition.maxDuration }),
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function definedOptions(options: SseOptions): SseOptions {
|
|
864
|
+
const result: Record<string, unknown> = {}
|
|
865
|
+
for (const [key, value] of Object.entries(options)) if (value !== undefined) result[key] = value
|
|
866
|
+
return result as SseOptions
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function describeResult(value: unknown): string {
|
|
870
|
+
if (value === null) return 'null'
|
|
871
|
+
if (value === undefined) return 'undefined'
|
|
872
|
+
if (typeof value === 'string') return 'a string'
|
|
873
|
+
if (typeof value === 'object') return `an instance of ${(value as object).constructor?.name ?? 'Object'}`
|
|
874
|
+
return typeof value
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function withContextHeaders(headers: Record<string, HeaderValue>, ctx: RequestContext): Record<string, HeaderValue> {
|
|
878
|
+
const result = emptyRecord<HeaderValue>()
|
|
879
|
+
if (ctx.hasResponseHeaders) assignHeaders(result, ctx.responseHeaders)
|
|
880
|
+
assignHeaders(result, headers)
|
|
881
|
+
return result
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Converts an error raised by the server framework (bad URL...) to an `HttpException`. */
|
|
885
|
+
function frameworkError(error: unknown): unknown {
|
|
886
|
+
if (isHttpException(error)) return error
|
|
887
|
+
const status =
|
|
888
|
+
(error as { statusCode?: unknown; status?: unknown } | null)?.statusCode ??
|
|
889
|
+
(error as { status?: unknown } | null)?.status
|
|
890
|
+
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
891
|
+
return new HttpException(status, errorMessage(error), undefined, { cause: error })
|
|
892
|
+
}
|
|
893
|
+
return error
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** `Allow` of a path: its methods and `OPTIONS`, answered automatically when no route declares it. */
|
|
897
|
+
function allowHeader(allowed: readonly string[]): string {
|
|
898
|
+
return (allowed.includes('OPTIONS') ? allowed : [...allowed, 'OPTIONS']).join(', ')
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* A raw request path usable as a same-origin `Location`: backslashes, controls and spaces are
|
|
903
|
+
* percent-encoded and leading slashes collapsed, since browsers read `/\evil.com` or `//evil.com`
|
|
904
|
+
* as another host (an open redirect through `trailingSlash: 'redirect'`).
|
|
905
|
+
*/
|
|
906
|
+
function sameOriginPath(path: string): string {
|
|
907
|
+
let encoded = ''
|
|
908
|
+
for (const char of path) {
|
|
909
|
+
const code = char.charCodeAt(0)
|
|
910
|
+
encoded +=
|
|
911
|
+
code <= 0x20 || code === 0x7f || char === '\\' ? `%${code.toString(16).toUpperCase().padStart(2, '0')}` : char
|
|
912
|
+
}
|
|
913
|
+
return encoded.replace(/^\/{2,}/, '/')
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function truncate(path: string): string {
|
|
917
|
+
return path.length > MAX_PATH_IN_MESSAGE ? `${path.slice(0, MAX_PATH_IN_MESSAGE)}…` : path
|
|
918
|
+
}
|