@znight/http 0.1.0 → 0.1.2
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 +4 -0
- package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
- package/dist/compiler.d.mts +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/express.mjs +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/fastify.mjs +57 -22
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/hono.mjs +112 -38
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +251 -157
- package/dist/index.mjs.map +1 -1
- package/dist/{node-BUnkdSsa.mjs → node-Clm1Ia7T.mjs} +2 -2
- package/dist/{node-BUnkdSsa.mjs.map → node-Clm1Ia7T.mjs.map} +1 -1
- package/dist/{node-io-BY1ZDLRQ.mjs → node-io-CVnqpVNS.mjs} +52 -26
- package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-CVnqpVNS.mjs.map} +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +1 -1
- package/package.json +4 -4
- package/src/adapter.ts +6 -0
- package/src/adapters/fastify.ts +66 -21
- package/src/adapters/hono.ts +135 -47
- package/src/adapters/node-io.ts +57 -24
- package/src/context.ts +7 -4
- package/src/pipeline.ts +237 -174
- package/src/plugin.ts +9 -2
- package/src/query.ts +7 -1
- package/src/serialize.ts +51 -11
package/src/pipeline.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { isFileBody, resolveFile } from './files.ts'
|
|
28
28
|
import { findInvalidHeader, isHeaderValue } from './headers.ts'
|
|
29
29
|
import { decodePathValue, matchRoutePath, type RoutePath } from './path.ts'
|
|
30
|
-
import { splitUrl } from './query.ts'
|
|
30
|
+
import { type RequestTarget, splitUrl } from './query.ts'
|
|
31
31
|
import { HttpResponse, isHttpResponse } from './response.ts'
|
|
32
32
|
import {
|
|
33
33
|
addMissingHeaders,
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
|
|
43
43
|
import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
|
|
44
44
|
import { HttpTokens } from './tokens.ts'
|
|
45
|
-
import { emptyRecord, errorMessage } from './utils.ts'
|
|
45
|
+
import { emptyRecord, errorMessage, isThenable } from './utils.ts'
|
|
46
46
|
|
|
47
47
|
/** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
|
|
48
48
|
export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
|
|
@@ -123,15 +123,15 @@ type Outcome = SerializedReply | typeof RAW
|
|
|
123
123
|
/** A reply whose `HttpResponse` cookies were serialised (see `#checkCookies`). */
|
|
124
124
|
type CheckedReply = SerializedReply & { readonly cookieLines?: readonly SerializedCookie[] }
|
|
125
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
126
|
const REQUEST_ID = /^[\w\-.:@/+=]{1,128}$/
|
|
127
|
+
|
|
133
128
|
const MAX_PATH_IN_MESSAGE = 200
|
|
134
129
|
|
|
130
|
+
/** The default request id generator: a random UUID. */
|
|
131
|
+
export function randomRequestId(): string {
|
|
132
|
+
return crypto.randomUUID()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
135
|
/**
|
|
136
136
|
* The request pipeline (architecture §9.1):
|
|
137
137
|
* `runInScope({ HttpContext, RequestId }) → hooks.run('http:request') → guards → body → interceptors
|
|
@@ -143,6 +143,10 @@ export class Pipeline {
|
|
|
143
143
|
readonly #logger: CoreLogger
|
|
144
144
|
readonly #options: PipelineOptions
|
|
145
145
|
readonly #table: RouteTable<RoutePlan>
|
|
146
|
+
/** The `securityHeaders` option as entries (lower-case names, values validated at boot). */
|
|
147
|
+
readonly #securityHeaders: readonly (readonly [string, string])[]
|
|
148
|
+
/** Every request id can be sent: an incoming one matched `REQUEST_ID`, the generated ones are UUIDs. */
|
|
149
|
+
readonly #validIds: boolean
|
|
146
150
|
#inflight = 0
|
|
147
151
|
#idle: (() => void)[] = []
|
|
148
152
|
/** Open event streams. */
|
|
@@ -162,6 +166,10 @@ export class Pipeline {
|
|
|
162
166
|
this.#logger = logger
|
|
163
167
|
this.#options = options
|
|
164
168
|
this.#table = table
|
|
169
|
+
this.#validIds = options.generateRequestId === randomRequestId
|
|
170
|
+
this.#securityHeaders = Object.entries(options.securityHeaders ?? {}).map(
|
|
171
|
+
([name, value]) => [name.toLowerCase(), value] as const,
|
|
172
|
+
)
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
/** Number of requests being processed. */
|
|
@@ -192,30 +200,26 @@ export class Pipeline {
|
|
|
192
200
|
* Handles a request routed to `plan` by the adapter. The request stays in flight (and its scope
|
|
193
201
|
* open) until a streamed or file response body has been fully read or cancelled by the adapter.
|
|
194
202
|
*/
|
|
195
|
-
|
|
203
|
+
handle(plan: RoutePlan, request: AdapterRequest): Promise<AdapterReply> {
|
|
196
204
|
const startTime = performance.now()
|
|
197
|
-
this.#enter()
|
|
198
|
-
let served: Served | undefined
|
|
199
205
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return served.reply
|
|
204
|
-
} finally {
|
|
205
|
-
this.#leaveWhen(served)
|
|
206
|
+
return this.#isPreflight(request) ? this.#preflight(request, startTime) : this.#route(plan, request, startTime)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return Promise.reject(error)
|
|
206
209
|
}
|
|
207
210
|
}
|
|
208
211
|
|
|
209
|
-
#route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<
|
|
210
|
-
const
|
|
211
|
-
const raw = matchRoutePath(plan.path, path)
|
|
212
|
+
#route(plan: RoutePlan, request: AdapterRequest, startTime: number, rerouted = false): Promise<AdapterReply> {
|
|
213
|
+
const url = splitUrl(request.url)
|
|
214
|
+
const raw = matchRoutePath(plan.path, url.path)
|
|
212
215
|
// the native router matched a path the Znight grammar rejects (e.g. an empty `:param` in Fastify)
|
|
213
216
|
// or a normalised path (Hono): route the raw path with the route table instead
|
|
214
217
|
if (raw === undefined) return this.#fallback(request, undefined, startTime, !rerouted)
|
|
215
218
|
if (plan.redirect === true) {
|
|
216
|
-
const canonical = sameOriginPath(path.slice(0, -1))
|
|
217
|
-
const location = query === '' ? canonical : `${canonical}?${query}`
|
|
218
|
-
|
|
219
|
+
const canonical = sameOriginPath(url.path.slice(0, -1))
|
|
220
|
+
const location = url.query === '' ? canonical : `${canonical}?${url.query}`
|
|
221
|
+
const target: Target = { kind: 'redirect', location }
|
|
222
|
+
return this.#serve(request, url, undefined, emptyRecord(), target, startTime, plan.body)
|
|
219
223
|
}
|
|
220
224
|
const params = emptyRecord<string>()
|
|
221
225
|
let malformed: string | undefined
|
|
@@ -228,30 +232,28 @@ export class Pipeline {
|
|
|
228
232
|
malformed === undefined
|
|
229
233
|
? { kind: 'route', plan }
|
|
230
234
|
: { kind: 'error', error: new BadRequestException(`Malformed URL parameter "${malformed}"`) }
|
|
231
|
-
return this.#serve(request, plan.info, params, target, startTime, plan.body)
|
|
235
|
+
return this.#serve(request, url, plan.info, params, target, startTime, plan.body)
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
/** Handles an unmatched request (404, 405, automatic OPTIONS) or a framework error. */
|
|
235
|
-
|
|
239
|
+
fallback(request: AdapterRequest, error?: unknown): Promise<AdapterReply> {
|
|
236
240
|
const startTime = performance.now()
|
|
237
|
-
this.#enter()
|
|
238
|
-
let served: Served | undefined
|
|
239
241
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
this.#leaveWhen(served)
|
|
242
|
+
return this.#fallback(request, error, startTime)
|
|
243
|
+
} catch (failure) {
|
|
244
|
+
return Promise.reject(failure)
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
#fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<
|
|
248
|
+
#fallback(request: AdapterRequest, error: unknown, startTime: number, reroute = true): Promise<AdapterReply> {
|
|
248
249
|
const body = this.#options.body
|
|
249
250
|
if (error !== undefined) {
|
|
250
251
|
const target: Target = { kind: 'error', error: frameworkError(error) }
|
|
251
|
-
return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
|
|
252
|
+
return this.#serve(request, splitUrl(request.url), undefined, emptyRecord(), target, startTime, body)
|
|
252
253
|
}
|
|
253
254
|
if (this.#isPreflight(request)) return this.#preflight(request, startTime)
|
|
254
|
-
const
|
|
255
|
+
const url = splitUrl(request.url)
|
|
256
|
+
const path = url.path
|
|
255
257
|
if (reroute) {
|
|
256
258
|
// routing happens on the raw path: serve a route the adapter's router missed (Hono routes the
|
|
257
259
|
// WHATWG-normalised URL, where `/files/%2e%2e/x` became `/x`)
|
|
@@ -277,7 +279,7 @@ export class Pipeline {
|
|
|
277
279
|
),
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
|
-
return this.#serve(request, undefined, emptyRecord(), target, startTime, body)
|
|
282
|
+
return this.#serve(request, url, undefined, emptyRecord(), target, startTime, body)
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
/** A CORS preflight (with the `cors` option): answered without running any route. */
|
|
@@ -285,68 +287,112 @@ export class Pipeline {
|
|
|
285
287
|
return this.#options.cors !== undefined && isPreflight(request.method, request.headers)
|
|
286
288
|
}
|
|
287
289
|
|
|
288
|
-
#preflight(request: AdapterRequest, startTime: number): Promise<
|
|
289
|
-
const
|
|
290
|
+
#preflight(request: AdapterRequest, startTime: number): Promise<AdapterReply> {
|
|
291
|
+
const url = splitUrl(request.url)
|
|
292
|
+
const allowed = this.#table.allowedMethods(url.path)
|
|
290
293
|
const target: Target = { kind: 'preflight', allow: allowed.length === 0 ? undefined : allowHeader(allowed) }
|
|
291
|
-
return this.#serve(request, undefined, emptyRecord(), target, startTime, this.#options.body)
|
|
294
|
+
return this.#serve(request, url, undefined, emptyRecord(), target, startTime, this.#options.body)
|
|
292
295
|
}
|
|
293
296
|
|
|
294
|
-
|
|
297
|
+
#serve(
|
|
295
298
|
request: AdapterRequest,
|
|
299
|
+
url: RequestTarget,
|
|
296
300
|
route: HttpRouteInfo | undefined,
|
|
297
301
|
params: Record<string, string>,
|
|
298
302
|
target: Target,
|
|
299
303
|
startTime: number,
|
|
300
304
|
body: BodyPlan,
|
|
301
|
-
): Promise<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
305
|
+
): Promise<AdapterReply> {
|
|
306
|
+
this.#enter()
|
|
307
|
+
let requestId: RequestId
|
|
308
|
+
let ctx: RequestContext
|
|
309
|
+
let event: HttpRequestEvent
|
|
310
|
+
try {
|
|
311
|
+
requestId = this.#requestId(request)
|
|
312
|
+
const proxy = this.#options.trustProxy
|
|
313
|
+
ctx = new RequestContext(request, {
|
|
314
|
+
requestId,
|
|
315
|
+
adapter: this.#options.adapter,
|
|
316
|
+
url,
|
|
317
|
+
route,
|
|
318
|
+
params,
|
|
319
|
+
body,
|
|
320
|
+
cookies: this.#options.cookies ?? DEFAULT_COOKIE_SETTINGS,
|
|
321
|
+
client:
|
|
322
|
+
proxy === undefined
|
|
323
|
+
? undefined
|
|
324
|
+
: proxy.resolve(request.remoteAddress ?? request.ip, request.headers, request.encrypted === true),
|
|
325
|
+
})
|
|
326
|
+
// an event stream requested while draining is served (guards run) with its signal already
|
|
327
|
+
// aborted: a well-behaved source ends at once and the client gets a jittered retry
|
|
328
|
+
if (this.draining && target.kind === 'route' && target.plan.definition.sse !== undefined) ctx.abort('shutdown')
|
|
329
|
+
event = {
|
|
330
|
+
method: request.method,
|
|
331
|
+
path: ctx.path,
|
|
332
|
+
route: route?.path,
|
|
333
|
+
requestId,
|
|
334
|
+
headers: request.headers,
|
|
335
|
+
context: ctx,
|
|
336
|
+
startTime,
|
|
337
|
+
responseHeaders: {},
|
|
338
|
+
}
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// failed before its scope opened (a request id generator that throws...)
|
|
341
|
+
this.#leave()
|
|
342
|
+
return Promise.reject(error)
|
|
327
343
|
}
|
|
328
344
|
let outcome: Outcome | undefined
|
|
329
|
-
let reply: AdapterReply | undefined
|
|
330
345
|
let cors: CorsDecision | undefined
|
|
331
346
|
const preflight = target.kind === 'preflight'
|
|
332
|
-
let
|
|
333
|
-
|
|
334
|
-
|
|
347
|
+
let resolve: (reply: AdapterReply) => void = () => undefined
|
|
348
|
+
let reject: (error: unknown) => void = () => undefined
|
|
349
|
+
const replied = new Promise<AdapterReply>((onReply, onError) => {
|
|
350
|
+
resolve = onReply
|
|
351
|
+
reject = onError
|
|
335
352
|
})
|
|
353
|
+
let answered = false
|
|
354
|
+
const answer = (reply: AdapterReply): void => {
|
|
355
|
+
answered = true
|
|
356
|
+
resolve(reply)
|
|
357
|
+
}
|
|
358
|
+
/** The request is over (response body sent or abandoned, scope closed). */
|
|
359
|
+
const over = (): void => {
|
|
360
|
+
ctx.complete()
|
|
361
|
+
try {
|
|
362
|
+
if (!answered) {
|
|
363
|
+
const failure = new Error('Request not handled')
|
|
364
|
+
const reply = serializeError(500, this.#internalBody(failure), ctx.method)
|
|
365
|
+
const completed = this.#complete(reply, ctx, event, requestId, cors, preflight, true)
|
|
366
|
+
event.status = 500
|
|
367
|
+
answer(completed)
|
|
368
|
+
}
|
|
369
|
+
} catch (error) {
|
|
370
|
+
reject(error)
|
|
371
|
+
} finally {
|
|
372
|
+
this.#leave()
|
|
373
|
+
}
|
|
374
|
+
}
|
|
336
375
|
// the scope (request-scoped instances) stays open until a streamed or file body has been sent or abandoned
|
|
337
|
-
|
|
376
|
+
this.#container
|
|
338
377
|
.runInScope(async () => {
|
|
339
378
|
let hookError: unknown
|
|
340
379
|
let failed = false
|
|
341
380
|
let run: ContextRunner | undefined
|
|
342
381
|
if (this.#options.cors !== undefined) cors = await this.#corsDecision(request, requestId)
|
|
382
|
+
// only a wrapper can change a reply between its checks below and the adapter
|
|
383
|
+
let recheck = this.#hooks.hasWrappers('http:request')
|
|
343
384
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
385
|
+
if (recheck) {
|
|
386
|
+
await this.#hooks.run('http:request', event, async () => {
|
|
387
|
+
outcome = this.#checked(await this.#process(ctx, target, event, cors), ctx, event)
|
|
388
|
+
// stream producers run in the context of the handler (scope, wrappers)
|
|
389
|
+
if (outcome !== RAW && isStreamed(outcome.body)) run = captureContext()
|
|
390
|
+
return outcome
|
|
391
|
+
})
|
|
392
|
+
} else {
|
|
393
|
+
// what hooks.run() does without wrappers, in the same async context
|
|
394
|
+
outcome = this.#checked(await this.#process(ctx, target, event, cors), ctx, event)
|
|
395
|
+
}
|
|
350
396
|
} catch (error) {
|
|
351
397
|
failed = true
|
|
352
398
|
hookError = error
|
|
@@ -359,6 +405,7 @@ export class Pipeline {
|
|
|
359
405
|
const fallback = await this.#errorReply(error, ctx)
|
|
360
406
|
outcome = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
|
|
361
407
|
event.status = outcome.status
|
|
408
|
+
recheck = true
|
|
362
409
|
} else if (failed) {
|
|
363
410
|
this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
|
|
364
411
|
err: hookError,
|
|
@@ -367,28 +414,30 @@ export class Pipeline {
|
|
|
367
414
|
}
|
|
368
415
|
if (outcome === RAW) {
|
|
369
416
|
ctx.commit()
|
|
370
|
-
|
|
417
|
+
const status = request.rawStatus?.() ?? 200
|
|
418
|
+
event.status ??= status
|
|
419
|
+
answer({ status, headers: {}, body: null, raw: true })
|
|
371
420
|
return
|
|
372
421
|
}
|
|
373
|
-
const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight)
|
|
422
|
+
const serialized = this.#complete(outcome, ctx, event, requestId, cors, preflight, recheck)
|
|
374
423
|
const body = serialized.body
|
|
375
|
-
if (!(body
|
|
376
|
-
reply = adapterReply(serialized, body)
|
|
424
|
+
if (!isStreamed(body)) {
|
|
425
|
+
const reply = adapterReply(serialized, body)
|
|
377
426
|
ctx.commit()
|
|
378
|
-
replied()
|
|
379
427
|
ctx.releaseBody()
|
|
428
|
+
answer(reply)
|
|
380
429
|
return
|
|
381
430
|
}
|
|
382
431
|
// files too: a file-backed Blob is read lazily, after the handler returned
|
|
383
432
|
const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
|
|
384
433
|
const session = serialized.sse
|
|
385
|
-
reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
|
|
434
|
+
const reply = adapterReply(serialized, tracked.stream, body instanceof Blob ? body : undefined)
|
|
386
435
|
if (session !== undefined) {
|
|
387
436
|
this.#streams.add(session)
|
|
388
437
|
if (this.draining) session.close('shutdown')
|
|
389
438
|
}
|
|
390
439
|
ctx.commit()
|
|
391
|
-
|
|
440
|
+
answer(reply)
|
|
392
441
|
const failure = await tracked.done
|
|
393
442
|
if (session !== undefined) this.#streams.delete(session)
|
|
394
443
|
if (failure !== undefined) {
|
|
@@ -399,42 +448,24 @@ export class Pipeline {
|
|
|
399
448
|
[HttpTokens.Context, ctx],
|
|
400
449
|
[HttpTokens.RequestId, requestId],
|
|
401
450
|
])
|
|
402
|
-
.then(
|
|
403
|
-
()
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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 }
|
|
451
|
+
.then(over, (error: unknown) => {
|
|
452
|
+
over()
|
|
453
|
+
// the callback above never throws: only the disposal of request-scoped instances can fail
|
|
454
|
+
this.#logger.error('Failed to dispose request-scoped providers', { err: error, requestId })
|
|
455
|
+
})
|
|
456
|
+
// listeners run once the reply is ready, before the adapter gets it, out of the request scope
|
|
457
|
+
if (!this.#hooks.hasListeners('http:request')) return replied
|
|
458
|
+
return replied.then(reply => {
|
|
459
|
+
this.#hooks.emit('http:request', event)
|
|
460
|
+
return reply
|
|
461
|
+
})
|
|
433
462
|
}
|
|
434
463
|
|
|
435
464
|
/**
|
|
436
465
|
* Adds the headers of the `http:request` wrappers, then the framework headers (see
|
|
437
|
-
* {@link #frameworkHeaders}),
|
|
466
|
+
* {@link #frameworkHeaders}). When wrappers ran (`recheck`), checks the final headers again (see
|
|
467
|
+
* {@link #checkHeaders}): the reply was checked before, and the cookies and framework headers are
|
|
468
|
+
* valid by construction.
|
|
438
469
|
*/
|
|
439
470
|
#complete(
|
|
440
471
|
reply: CheckedReply,
|
|
@@ -443,10 +474,12 @@ export class Pipeline {
|
|
|
443
474
|
requestId: RequestId,
|
|
444
475
|
cors: CorsDecision | undefined,
|
|
445
476
|
preflight: boolean,
|
|
477
|
+
recheck: boolean,
|
|
446
478
|
): SerializedReply {
|
|
447
479
|
addMissingHeaders(reply.headers, event.responseHeaders)
|
|
448
480
|
applyCookies(reply, ctx)
|
|
449
481
|
this.#frameworkHeaders(reply.headers, requestId, cors, preflight)
|
|
482
|
+
if (!recheck) return reply
|
|
450
483
|
const checked = this.#checkHeaders(reply, ctx, event)
|
|
451
484
|
if (checked !== reply) {
|
|
452
485
|
applyCookies(checked, ctx)
|
|
@@ -455,6 +488,13 @@ export class Pipeline {
|
|
|
455
488
|
return checked
|
|
456
489
|
}
|
|
457
490
|
|
|
491
|
+
/** Checks the headers (unless only the serialiser set them), then the cookies of a reply. */
|
|
492
|
+
#checked(outcome: Outcome, ctx: RequestContext, event: HttpRequestEvent): Outcome {
|
|
493
|
+
if (outcome === RAW) return outcome
|
|
494
|
+
const checked = outcome.ownHeaders === true ? outcome : this.#checkHeaders(outcome, ctx, event)
|
|
495
|
+
return this.#checkCookies(checked, ctx, event)
|
|
496
|
+
}
|
|
497
|
+
|
|
458
498
|
/**
|
|
459
499
|
* Serialises the cookies of an `HttpResponse` (`withCookie`) with the plugin settings (defaults,
|
|
460
500
|
* signing). A cookie breaking a rule that depends on them (a prefix with the `secure` default,
|
|
@@ -492,7 +532,9 @@ export class Pipeline {
|
|
|
492
532
|
preflight: boolean,
|
|
493
533
|
): void {
|
|
494
534
|
const header = this.#options.requestIdHeader
|
|
495
|
-
if (header !== undefined && headers[header] === undefined && isHeaderValue(requestId))
|
|
535
|
+
if (header !== undefined && headers[header] === undefined && (this.#validIds || isHeaderValue(requestId))) {
|
|
536
|
+
headers[header] = requestId
|
|
537
|
+
}
|
|
496
538
|
const policy = this.#options.cors
|
|
497
539
|
if (policy !== undefined) {
|
|
498
540
|
// a preflight answer carries its own CORS headers; other replies get them for allowed origins,
|
|
@@ -503,8 +545,7 @@ export class Pipeline {
|
|
|
503
545
|
}
|
|
504
546
|
if (policy.varyOrigin) mergeVary(headers, 'origin')
|
|
505
547
|
}
|
|
506
|
-
const
|
|
507
|
-
if (security !== undefined) addMissingHeaders(headers, security)
|
|
548
|
+
for (const [name, value] of this.#securityHeaders) if (headers[name] === undefined) headers[name] = value
|
|
508
549
|
if (this.draining) headers['connection'] = 'close'
|
|
509
550
|
}
|
|
510
551
|
|
|
@@ -542,12 +583,23 @@ export class Pipeline {
|
|
|
542
583
|
return serializeError(500, this.#internalBody(error), ctx.method)
|
|
543
584
|
}
|
|
544
585
|
|
|
545
|
-
|
|
586
|
+
/** The outcome of a request: its route run, or a reply of the framework. Never rejects (errors are mapped). */
|
|
587
|
+
#process(
|
|
546
588
|
ctx: RequestContext,
|
|
547
589
|
target: Target,
|
|
548
590
|
event: HttpRequestEvent,
|
|
549
591
|
cors: CorsDecision | undefined,
|
|
550
592
|
): Promise<Outcome> {
|
|
593
|
+
return target.kind === 'route' ? this.#invoke(ctx, target.plan, event) : this.#answer(ctx, target, event, cors)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Answers a request without running a route. Never rejects. */
|
|
597
|
+
async #answer(
|
|
598
|
+
ctx: RequestContext,
|
|
599
|
+
target: Exclude<Target, { readonly kind: 'route' }>,
|
|
600
|
+
event: HttpRequestEvent,
|
|
601
|
+
cors: CorsDecision | undefined,
|
|
602
|
+
): Promise<SerializedReply> {
|
|
551
603
|
try {
|
|
552
604
|
switch (target.kind) {
|
|
553
605
|
case 'error':
|
|
@@ -577,59 +629,71 @@ export class Pipeline {
|
|
|
577
629
|
headers: { location: target.location },
|
|
578
630
|
}),
|
|
579
631
|
)
|
|
580
|
-
default:
|
|
581
|
-
return await this.#invoke(ctx, target.plan, event)
|
|
582
632
|
}
|
|
583
633
|
} catch (error) {
|
|
584
|
-
|
|
585
|
-
const reply = await this.#errorReply(error, ctx)
|
|
586
|
-
event.status = reply.status
|
|
587
|
-
return reply
|
|
634
|
+
return await this.#failed(error, ctx, event)
|
|
588
635
|
}
|
|
589
636
|
}
|
|
590
637
|
|
|
638
|
+
/** The reply of an error (see {@link #errorReply}), recorded on the event. */
|
|
639
|
+
async #failed(error: unknown, ctx: RequestContext, event: HttpRequestEvent): Promise<SerializedReply> {
|
|
640
|
+
event.error = error
|
|
641
|
+
const reply = await this.#errorReply(error, ctx)
|
|
642
|
+
event.status = reply.status
|
|
643
|
+
return reply
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Runs a route: guards, body, interceptors, handler, serialisation. Never rejects (errors are mapped). */
|
|
591
647
|
async #invoke(ctx: RequestContext, plan: RoutePlan, event: HttpRequestEvent): Promise<Outcome> {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
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))
|
|
648
|
+
try {
|
|
649
|
+
const controller = this.#resolve(plan.controller)
|
|
650
|
+
for (const guard of plan.guards) {
|
|
651
|
+
const allowed = await this.#resolve(guard).canActivate(ctx)
|
|
652
|
+
if (allowed !== true) throw new ForbiddenException('Forbidden resource')
|
|
608
653
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
)
|
|
654
|
+
if (plan.parseBody) await ctx.parseBody()
|
|
655
|
+
const definition = plan.definition
|
|
656
|
+
const interceptors = plan.interceptors
|
|
657
|
+
let result: unknown
|
|
658
|
+
if (interceptors.length === 0) {
|
|
659
|
+
result = definition.invoke(controller, ctx)
|
|
660
|
+
if (isThenable(result)) result = await result
|
|
661
|
+
} else {
|
|
662
|
+
const chain = async (index: number): Promise<unknown> => {
|
|
663
|
+
if (index === interceptors.length) return await definition.invoke(controller, ctx)
|
|
664
|
+
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
665
|
+
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
666
|
+
}
|
|
667
|
+
result = await chain(0)
|
|
627
668
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
669
|
+
if (definition.rawResponse === true && !(result instanceof Response)) return RAW
|
|
670
|
+
const options: SerializeOptions = {
|
|
671
|
+
method: ctx.method,
|
|
672
|
+
routeMethod: definition.method,
|
|
673
|
+
status: definition.status,
|
|
674
|
+
headers: definition.headers,
|
|
675
|
+
context: ctx.hasResponseHeaders ? ctx.responseHeaders : undefined,
|
|
676
|
+
}
|
|
677
|
+
const sse = definition.sse
|
|
678
|
+
let reply: SerializedReply
|
|
679
|
+
if (sse !== undefined && !(result instanceof Response) && !isHttpResponse(result)) {
|
|
680
|
+
// an @Sse route: the (awaited) result is the event source
|
|
681
|
+
if (!isSseSource(result)) {
|
|
682
|
+
throw new TypeError(
|
|
683
|
+
`@Sse handler ${displayName(plan.info.controller)}.${definition.handler} must return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (received ${describeResult(result)})`,
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
reply = serializeSseReply(this.#openStream(result, sseOptions(sse), ctx), undefined, options)
|
|
687
|
+
} else {
|
|
688
|
+
// only an HttpResponse may need an asynchronous step (a file to open)
|
|
689
|
+
reply = isHttpResponse(result)
|
|
690
|
+
? await this.#serialize(result, ctx, options, sse)
|
|
691
|
+
: serializeResult(result, options)
|
|
692
|
+
}
|
|
693
|
+
return this.#finish(event, reply)
|
|
694
|
+
} catch (error) {
|
|
695
|
+
return await this.#failed(error, ctx, event)
|
|
631
696
|
}
|
|
632
|
-
return this.#finish(event, reply)
|
|
633
697
|
}
|
|
634
698
|
|
|
635
699
|
/** Serialises a handler (or exception mapper) result; file bodies are resolved, event streams opened. */
|
|
@@ -790,20 +854,10 @@ export class Pipeline {
|
|
|
790
854
|
return this.#options.generateRequestId() as RequestId
|
|
791
855
|
}
|
|
792
856
|
|
|
793
|
-
#emit(event: HttpRequestEvent): void {
|
|
794
|
-
if (this.#hooks.hasListeners('http:request')) this.#hooks.emit('http:request', event)
|
|
795
|
-
}
|
|
796
|
-
|
|
797
857
|
#enter(): void {
|
|
798
858
|
this.#inflight++
|
|
799
859
|
}
|
|
800
860
|
|
|
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
861
|
#leave(): void {
|
|
808
862
|
this.#inflight--
|
|
809
863
|
if (this.#inflight === 0 && this.#idle.length > 0) {
|
|
@@ -821,6 +875,8 @@ export class Pipeline {
|
|
|
821
875
|
* before a quiet stream's first byte, and lifts idle timeouts.
|
|
822
876
|
*/
|
|
823
877
|
function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob): AdapterReply {
|
|
878
|
+
const text = serialized.text
|
|
879
|
+
if (text !== undefined) return { status: serialized.status, headers: serialized.headers, body, text }
|
|
824
880
|
const reply: AdapterReply = { status: serialized.status, headers: serialized.headers, body }
|
|
825
881
|
if (blob !== undefined) return { ...reply, blob }
|
|
826
882
|
const eventStream =
|
|
@@ -836,7 +892,7 @@ function adapterReply(serialized: SerializedReply, body: ReplyBody, blob?: Blob)
|
|
|
836
892
|
* one in place. One line per cookie.
|
|
837
893
|
*/
|
|
838
894
|
function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
|
|
839
|
-
const own = reply.cookieLines ??
|
|
895
|
+
const own = reply.cookieLines ?? NO_COOKIE_LINES
|
|
840
896
|
const context = ctx.cookieLines
|
|
841
897
|
if (own.length === 0 && context.length === 0) return
|
|
842
898
|
const merged = new Map<string, string>()
|
|
@@ -849,6 +905,13 @@ function applyCookies(reply: CheckedReply, ctx: RequestContext): void {
|
|
|
849
905
|
reply.headers['set-cookie'] = [...raw, ...merged.values()]
|
|
850
906
|
}
|
|
851
907
|
|
|
908
|
+
const NO_COOKIE_LINES: readonly SerializedCookie[] = Object.freeze([])
|
|
909
|
+
|
|
910
|
+
/** Whether a reply body is streamed (a file is read through a stream too). */
|
|
911
|
+
function isStreamed(body: ReplyBody): body is ReadableStream<Uint8Array> | Blob {
|
|
912
|
+
return body instanceof ReadableStream || body instanceof Blob
|
|
913
|
+
}
|
|
914
|
+
|
|
852
915
|
/** `SseOptions` of the `sse` metadata of a route (`heartbeat: 0` disables the heartbeat). */
|
|
853
916
|
function sseOptions(definition: HttpSseDefinition): SseOptions {
|
|
854
917
|
return {
|
package/src/plugin.ts
CHANGED
|
@@ -49,7 +49,14 @@ import { openInjectSse } from './inject-sse.ts'
|
|
|
49
49
|
import { isMediaTypePattern } from './mime.ts'
|
|
50
50
|
import { invalidOption } from './option-error.ts'
|
|
51
51
|
import { compareRoutePaths, compareStrings, joinPaths, parseRoutePath, type RoutePath, RoutePathError } from './path.ts'
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
DEFAULT_SSE_SETTINGS,
|
|
54
|
+
Pipeline,
|
|
55
|
+
type Resolvable,
|
|
56
|
+
type RoutePlan,
|
|
57
|
+
randomRequestId,
|
|
58
|
+
type SseSettings,
|
|
59
|
+
} from './pipeline.ts'
|
|
53
60
|
import { collectPluginRoutes } from './plugin-routes.ts'
|
|
54
61
|
import { resolveTrustProxy } from './proxy.ts'
|
|
55
62
|
import { RouteTable } from './router.ts'
|
|
@@ -649,7 +656,7 @@ function resolve(options: HttpPluginOptions): Resolved {
|
|
|
649
656
|
if (requestIdHeader !== false && (typeof requestIdHeader !== 'string' || !isHeaderName(requestIdHeader))) {
|
|
650
657
|
throw invalidOption('requestIdHeader', 'a header name or false', requestIdHeader)
|
|
651
658
|
}
|
|
652
|
-
const generateRequestId = options.generateRequestId ??
|
|
659
|
+
const generateRequestId = options.generateRequestId ?? randomRequestId
|
|
653
660
|
if (typeof generateRequestId !== 'function') throw invalidOption('generateRequestId', 'a function', generateRequestId)
|
|
654
661
|
const adapter = options.adapter ?? nodeAdapter()
|
|
655
662
|
if (
|