@znight/http 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -10
- package/dist/compiler.d.mts +77 -2
- package/dist/compiler.mjs +177 -88
- package/dist/compiler.mjs.map +1 -1
- package/dist/index.d.mts +27 -3
- package/dist/index.mjs +230 -40
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/compiler/analyze.ts +3 -3
- package/src/compiler/index.ts +13 -0
- package/src/compiler/route-decorators.ts +45 -2
- package/src/compiler/route-groups.ts +216 -0
- package/src/compiler/validate.ts +15 -126
- package/src/decorators.ts +19 -3
- package/src/index.ts +1 -0
- package/src/pipeline.ts +279 -58
- package/src/plugin-routes.ts +19 -0
- package/src/plugin.ts +6 -1
package/src/pipeline.ts
CHANGED
|
@@ -43,9 +43,9 @@ import {
|
|
|
43
43
|
} from './serialize.ts'
|
|
44
44
|
import { isSseBody, isSseSource, type SseOptions, SseSession, type SseSource } from './sse.ts'
|
|
45
45
|
import { isBodylessStatus } from './status.ts'
|
|
46
|
-
import { type ContextRunner, captureContext, discardStream, trackStream } from './stream.ts'
|
|
46
|
+
import { type ContextRunner, captureContext, discardStream, type TrackedStream, trackStream } from './stream.ts'
|
|
47
47
|
import { HttpTokens } from './tokens.ts'
|
|
48
|
-
import { emptyRecord, errorMessage, isBun, isThenable } from './utils.ts'
|
|
48
|
+
import { emptyRecord, errorMessage, fastRecord, isBun, isThenable } from './utils.ts'
|
|
49
49
|
|
|
50
50
|
/** A dependency of the pipeline: an instance resolved at bootstrap, or a token resolved per request. */
|
|
51
51
|
export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
|
|
@@ -339,27 +339,24 @@ export class Pipeline {
|
|
|
339
339
|
|
|
340
340
|
/**
|
|
341
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
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* final order (a template precomputed at boot).
|
|
342
|
+
* of per request, without promises while the route stays synchronous. A request takes it when the
|
|
343
|
+
* server is not draining and its method is the route's (not a `HEAD` served by a `GET` route); a
|
|
344
|
+
* plain object or array returned by the handler is serialised as JSON, and `undefined` answered
|
|
345
|
+
* without body, with the headers in their final order (a template precomputed at boot).
|
|
347
346
|
* A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
|
|
348
347
|
* path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
|
|
349
348
|
* or any other result, headers or cookies set through the context — continues in the general
|
|
350
349
|
* pipeline, in the same request scope, with the same outcome.
|
|
350
|
+
* With `http:request` wrappers, the handler runs inside them (see {@link #fastWrapped}); listeners
|
|
351
|
+
* get the event once the reply is ready, as in the general pipeline.
|
|
351
352
|
*/
|
|
352
353
|
#fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
|
|
353
354
|
const plan = route.plan
|
|
355
|
+
if (this.draining || request.method !== route.method) return this.handle(plan, request)
|
|
354
356
|
const hooks = this.#hooks
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
hooks.hasWrappers('http:request') ||
|
|
359
|
-
hooks.hasListeners('http:request')
|
|
360
|
-
) {
|
|
361
|
-
return this.handle(plan, request)
|
|
362
|
-
}
|
|
357
|
+
const wrapped = hooks.hasWrappers('http:request')
|
|
358
|
+
// a hooked request starts now (see handle): its event is created with the exchange
|
|
359
|
+
const startTime = wrapped || hooks.hasListeners('http:request') ? performance.now() : undefined
|
|
363
360
|
const url = request.url
|
|
364
361
|
const mark = url.indexOf('?')
|
|
365
362
|
const path = mark === -1 ? url : url.slice(0, mark)
|
|
@@ -379,16 +376,13 @@ export class Pipeline {
|
|
|
379
376
|
[HttpTokens.Context, ctx],
|
|
380
377
|
[HttpTokens.RequestId, ctx.requestId],
|
|
381
378
|
])
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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)
|
|
379
|
+
if (wrapped) {
|
|
380
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
381
|
+
x.done = scope.run(this.#fastWrapped, x, route)
|
|
382
|
+
return this.#fastExchange(x, scope)
|
|
391
383
|
}
|
|
384
|
+
const done = scope.run(this.#fastRun, route, request, ctx, startTime)
|
|
385
|
+
if (done instanceof Exchange) return this.#fastExchange(done, scope)
|
|
392
386
|
const closing = scope.close()
|
|
393
387
|
if (closing === undefined) {
|
|
394
388
|
ctx.complete()
|
|
@@ -401,9 +395,9 @@ export class Pipeline {
|
|
|
401
395
|
(error: unknown) => this.#disposalFailed(x, error),
|
|
402
396
|
)
|
|
403
397
|
}
|
|
404
|
-
//
|
|
398
|
+
// listeners are checked once the scope callback paused (one may have been registered by the handler)
|
|
405
399
|
if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
|
|
406
|
-
const event = requestEvent(request, ctx, performance.now())
|
|
400
|
+
const event = requestEvent(request, ctx, startTime ?? performance.now())
|
|
407
401
|
event.status = done.status
|
|
408
402
|
return Promise.resolve(done).then(reply => {
|
|
409
403
|
hooks.emit('http:request', event)
|
|
@@ -411,21 +405,41 @@ export class Pipeline {
|
|
|
411
405
|
})
|
|
412
406
|
}
|
|
413
407
|
|
|
408
|
+
/**
|
|
409
|
+
* The reply of a fast-lane request whose asynchronous part runs in `x.done`: the request ends once
|
|
410
|
+
* it settled (see {@link #end}); listeners get the event once the reply is ready.
|
|
411
|
+
*/
|
|
412
|
+
#fastExchange(x: Exchange, scope: OpenedScope): Promise<AdapterReply> {
|
|
413
|
+
;(x.done as Promise<void>).then(
|
|
414
|
+
() => this.#end(x, scope, undefined),
|
|
415
|
+
(error: unknown) => this.#end(x, scope, error),
|
|
416
|
+
)
|
|
417
|
+
if (!this.#hooks.hasListeners('http:request')) return x.replied
|
|
418
|
+
x.tracked = true
|
|
419
|
+
return this.#emitted(x)
|
|
420
|
+
}
|
|
421
|
+
|
|
414
422
|
/**
|
|
415
423
|
* The fast lane in the request scope: the reply when the handler answered synchronously with a
|
|
416
424
|
* plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
|
|
425
|
+
* `startTime`: the request has `http:request` listeners, its event starts with the exchange.
|
|
417
426
|
*/
|
|
418
|
-
readonly #fastRun = (
|
|
427
|
+
readonly #fastRun = (
|
|
428
|
+
route: FastRoute,
|
|
429
|
+
request: AdapterRequest,
|
|
430
|
+
ctx: RequestContext,
|
|
431
|
+
startTime: number | undefined,
|
|
432
|
+
): AdapterReply | Exchange => {
|
|
419
433
|
const plan = route.plan
|
|
420
|
-
if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT)
|
|
434
|
+
if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT, startTime)
|
|
421
435
|
let result: unknown
|
|
422
436
|
let reply: AdapterReply | undefined
|
|
423
437
|
try {
|
|
424
438
|
result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
|
|
425
|
-
if (isThenable(result)) return this.#fastLater(route, request, ctx, result)
|
|
439
|
+
if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
|
|
426
440
|
reply = this.#fastReply(route, ctx, result)
|
|
427
441
|
} catch (error) {
|
|
428
|
-
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
442
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
429
443
|
x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
|
|
430
444
|
return x
|
|
431
445
|
}
|
|
@@ -434,14 +448,20 @@ export class Pipeline {
|
|
|
434
448
|
ctx.releaseBody()
|
|
435
449
|
return reply
|
|
436
450
|
}
|
|
437
|
-
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
451
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
438
452
|
x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
|
|
439
453
|
return x
|
|
440
454
|
}
|
|
441
455
|
|
|
442
456
|
/** The fast lane of a route with a body to read or a handler that returned a thenable. */
|
|
443
|
-
#fastLater(
|
|
444
|
-
|
|
457
|
+
#fastLater(
|
|
458
|
+
route: FastRoute,
|
|
459
|
+
request: AdapterRequest,
|
|
460
|
+
ctx: RequestContext,
|
|
461
|
+
result: unknown,
|
|
462
|
+
startTime: number | undefined,
|
|
463
|
+
): Exchange {
|
|
464
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
445
465
|
x.done = this.#fastAsync(x, route, result)
|
|
446
466
|
return x
|
|
447
467
|
}
|
|
@@ -492,22 +512,171 @@ export class Pipeline {
|
|
|
492
512
|
const requestId = ctx.requestId
|
|
493
513
|
// a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
|
|
494
514
|
if (!this.#validIds && !isHeaderValue(requestId)) return undefined
|
|
495
|
-
if (result === undefined)
|
|
515
|
+
if (result === undefined) return this.#templateReply(status, null, undefined, requestId)
|
|
516
|
+
if (!isPlainJson(result)) return undefined
|
|
517
|
+
const json = JSON.stringify(result)
|
|
518
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
|
|
519
|
+
// encoded once, its length from the bytes (see serialize.ts encodeText)
|
|
520
|
+
return this.#templateReply(status, utf8(json), json, requestId)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The reply of a JSON body (`text` set) or of no body, its headers copied from the template of
|
|
525
|
+
* the fast lane (see {@link #jsonHeaders}), completed with the length and the request id (one that
|
|
526
|
+
* can be sent).
|
|
527
|
+
*/
|
|
528
|
+
#templateReply(
|
|
529
|
+
status: number,
|
|
530
|
+
body: Uint8Array | null,
|
|
531
|
+
text: string | undefined,
|
|
532
|
+
requestId: RequestId,
|
|
533
|
+
): AdapterReply {
|
|
534
|
+
if (text === undefined) {
|
|
496
535
|
const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
|
|
497
536
|
const idHeader = this.#emptyIdHeader
|
|
498
537
|
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
499
538
|
return { status, headers, body: null }
|
|
500
539
|
}
|
|
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
540
|
const headers: Record<string, HeaderValue> = { ...this.#jsonHeaders }
|
|
505
|
-
|
|
506
|
-
const body = utf8(json)
|
|
507
|
-
headers['content-length'] = String(body.byteLength)
|
|
541
|
+
headers['content-length'] = String((body as Uint8Array).byteLength)
|
|
508
542
|
const idHeader = this.#jsonIdHeader
|
|
509
543
|
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
510
|
-
return { status, headers, body, text
|
|
544
|
+
return { status, headers, body, text }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The fast lane through the `http:request` wrappers (registered when the request started), in the
|
|
549
|
+
* request scope: what {@link #respond} does for the route, the handler run inside `next()` like
|
|
550
|
+
* {@link #fastRun}. A JSON value or `undefined` is serialised there as `serializeResult` does, and
|
|
551
|
+
* the reply completed from the template of the fast lane when the wrappers added no response header
|
|
552
|
+
* (`event.responseHeaders`) nor cookie (see {@link #fastDeliver}). Never rejects.
|
|
553
|
+
*/
|
|
554
|
+
readonly #fastWrapped = (x: Exchange, route: FastRoute): Promise<void> =>
|
|
555
|
+
this.#hooks
|
|
556
|
+
.run('http:request', x.event, () => this.#fastOperation(x, route))
|
|
557
|
+
.then(
|
|
558
|
+
() => this.#fastDeliver(x, false, undefined),
|
|
559
|
+
(error: unknown) => this.#fastDeliver(x, true, error),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
/** The operation wrapped by the `http:request` wrappers in the fast lane: the route run, its outcome checked. */
|
|
563
|
+
#fastOperation(x: Exchange, route: FastRoute): Promise<Outcome> {
|
|
564
|
+
const ctx = x.ctx
|
|
565
|
+
const plan = route.plan
|
|
566
|
+
if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
|
|
567
|
+
let result: unknown
|
|
568
|
+
try {
|
|
569
|
+
result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
|
|
570
|
+
} catch (error) {
|
|
571
|
+
return this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
572
|
+
}
|
|
573
|
+
if (isThenable(result)) return this.#fastOperationLater(x, route, result)
|
|
574
|
+
return this.#fastChecked(x, this.#fastOutcome(x, route, result))
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** {@link #fastOperation} with a body to read (`pending` is {@link NO_RESULT}) or a thenable result. */
|
|
578
|
+
async #fastOperationLater(x: Exchange, route: FastRoute, pending: unknown): Promise<Outcome> {
|
|
579
|
+
const ctx = x.ctx
|
|
580
|
+
const plan = route.plan
|
|
581
|
+
let result = pending
|
|
582
|
+
try {
|
|
583
|
+
if (result === NO_RESULT) {
|
|
584
|
+
const controller = this.#resolve(plan.controller)
|
|
585
|
+
await ctx.parseBody()
|
|
586
|
+
result = plan.definition.invoke(controller, ctx)
|
|
587
|
+
}
|
|
588
|
+
if (isThenable(result)) result = await result
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return await this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
591
|
+
}
|
|
592
|
+
return await this.#fastChecked(x, this.#fastOutcome(x, route, result))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The outcome of a handler result in {@link #fastOperation}: for a plain JSON value or `undefined`,
|
|
597
|
+
* the reply of `serializeResult` built directly (`x.fast` set); anything else as in the general
|
|
598
|
+
* pipeline (see {@link #settle}). Never throws nor rejects (errors are mapped).
|
|
599
|
+
*/
|
|
600
|
+
#fastOutcome(x: Exchange, route: FastRoute, result: unknown): Outcome | Promise<Outcome> {
|
|
601
|
+
const ctx = x.ctx
|
|
602
|
+
const event = x.event
|
|
603
|
+
const status = result === undefined ? route.emptyStatus : route.jsonStatus
|
|
604
|
+
if (status === undefined || ctx.hasResponseHeaders || (result !== undefined && !isPlainJson(result))) {
|
|
605
|
+
return this.#settle(ctx, route.plan, event, result)
|
|
606
|
+
}
|
|
607
|
+
let reply: SerializedReply
|
|
608
|
+
if (result === undefined) {
|
|
609
|
+
reply = { status, headers: fastRecord<HeaderValue>(), body: null }
|
|
610
|
+
} else {
|
|
611
|
+
let json: string | undefined
|
|
612
|
+
try {
|
|
613
|
+
json = JSON.stringify(result)
|
|
614
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
|
|
615
|
+
} catch (error) {
|
|
616
|
+
return this.#failed(error, ctx, event)
|
|
617
|
+
}
|
|
618
|
+
const headers = fastRecord<HeaderValue>()
|
|
619
|
+
headers['content-type'] = ContentType.json
|
|
620
|
+
const body = utf8(json)
|
|
621
|
+
headers['content-length'] = String(body.byteLength)
|
|
622
|
+
reply = { status, headers, body, text: json, ownHeaders: true }
|
|
623
|
+
}
|
|
624
|
+
event.status = status
|
|
625
|
+
x.fast = true
|
|
626
|
+
return reply
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* What the general pipeline does with an outcome inside the wrappers (see {@link #respond}): its
|
|
631
|
+
* headers and cookies checked, the async context of a streamed body captured; kept in `x.outcome`.
|
|
632
|
+
*/
|
|
633
|
+
#fastChecked(x: Exchange, pending: Outcome | Promise<Outcome>): Promise<Outcome> {
|
|
634
|
+
if (pending instanceof Promise) return pending.then(outcome => this.#fastKeep(x, outcome))
|
|
635
|
+
return Promise.resolve(this.#fastKeep(x, pending))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#fastKeep(x: Exchange, outcome: Outcome): Outcome {
|
|
639
|
+
// a reply of #fastOutcome is valid by construction (what #checked returns for it)
|
|
640
|
+
const checked = x.fast ? outcome : this.#checked(outcome, x.ctx, x.event)
|
|
641
|
+
if (checked !== RAW && isStreamed(checked.body)) x.run = captureContext()
|
|
642
|
+
x.outcome = checked
|
|
643
|
+
return checked
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Answers a fast-lane request once its wrappers settled: a reply of {@link #fastOutcome} gets the
|
|
648
|
+
* headers of the template when nothing was added to it (no response header from the wrappers, no
|
|
649
|
+
* cookie, not draining, a request id that can be sent), which is what {@link #complete} gives;
|
|
650
|
+
* anything else is completed by {@link #deliver}. Never rejects.
|
|
651
|
+
*/
|
|
652
|
+
#fastDeliver(x: Exchange, failed: boolean, hookError: unknown): Promise<void> | undefined {
|
|
653
|
+
const outcome = x.outcome
|
|
654
|
+
const ctx = x.ctx
|
|
655
|
+
if (outcome === undefined) return this.#fastUnhandled(x, failed ? hookError : NOT_CALLED)
|
|
656
|
+
if (failed) this.#wrapperFailed(x, hookError)
|
|
657
|
+
else if (
|
|
658
|
+
x.fast &&
|
|
659
|
+
outcome !== RAW &&
|
|
660
|
+
!hasOwnKeys(x.event.responseHeaders) &&
|
|
661
|
+
!ctx.hasCookies &&
|
|
662
|
+
!this.draining &&
|
|
663
|
+
(this.#validIds || isHeaderValue(x.requestId))
|
|
664
|
+
) {
|
|
665
|
+
const reply = this.#templateReply(outcome.status, outcome.body as Uint8Array | null, outcome.text, x.requestId)
|
|
666
|
+
ctx.commit()
|
|
667
|
+
ctx.releaseBody()
|
|
668
|
+
x.answer(reply)
|
|
669
|
+
return undefined
|
|
670
|
+
}
|
|
671
|
+
const streaming = this.#send(x, outcome, true, x.run)
|
|
672
|
+
if (streaming === undefined) return undefined
|
|
673
|
+
return streaming.tracked.done.then(failure => this.#streamed(x, streaming, failure))
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** {@link #fastDeliver} when a wrapper failed before the request was handled or did not call `next()`. */
|
|
677
|
+
async #fastUnhandled(x: Exchange, failure: unknown): Promise<void> {
|
|
678
|
+
const streaming = this.#send(x, await this.#unhandled(x, failure), true, undefined)
|
|
679
|
+
if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
|
|
511
680
|
}
|
|
512
681
|
|
|
513
682
|
/** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
|
|
@@ -752,35 +921,61 @@ export class Pipeline {
|
|
|
752
921
|
hookError = error
|
|
753
922
|
}
|
|
754
923
|
if (outcome === undefined) {
|
|
755
|
-
|
|
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
|
|
924
|
+
outcome = await this.#unhandled(x, failed ? hookError : NOT_CALLED)
|
|
762
925
|
recheck = true
|
|
763
926
|
} else if (failed) {
|
|
764
|
-
this.#
|
|
765
|
-
err: hookError,
|
|
766
|
-
requestId,
|
|
767
|
-
})
|
|
927
|
+
this.#wrapperFailed(x, hookError)
|
|
768
928
|
}
|
|
929
|
+
const streaming = this.#send(x, outcome, recheck, run)
|
|
930
|
+
if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
|
|
935
|
+
* did not call `next()` ({@link NOT_CALLED}): the error mapped and checked like a handled reply (an
|
|
936
|
+
* exception mapper's `HttpResponse` may carry cookies). Never rejects.
|
|
937
|
+
*/
|
|
938
|
+
async #unhandled(x: Exchange, failure: unknown): Promise<CheckedReply> {
|
|
939
|
+
const ctx = x.ctx
|
|
940
|
+
const event = x.event
|
|
941
|
+
const error = failure === NOT_CALLED ? new Error('An "http:request" hook wrapper did not call next()') : failure
|
|
942
|
+
event.error ??= error
|
|
943
|
+
const fallback = await this.#errorReply(error, ctx)
|
|
944
|
+
const reply = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
|
|
945
|
+
event.status = reply.status
|
|
946
|
+
return reply
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
#wrapperFailed(x: Exchange, error: unknown): void {
|
|
950
|
+
this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
|
|
951
|
+
err: error,
|
|
952
|
+
requestId: x.requestId,
|
|
953
|
+
})
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Completes the outcome of a request (`recheck`: wrappers ran) and hands its reply to the adapter.
|
|
958
|
+
* Returns the tracking of a streamed body (read in `run`, the context of the handler, when it was
|
|
959
|
+
* captured), whose end {@link #streamed} records.
|
|
960
|
+
*/
|
|
961
|
+
#send(x: Exchange, outcome: Outcome, recheck: boolean, run: ContextRunner | undefined): Streaming | undefined {
|
|
962
|
+
const ctx = x.ctx
|
|
963
|
+
const event = x.event
|
|
769
964
|
if (outcome === RAW) {
|
|
770
965
|
ctx.commit()
|
|
771
966
|
const status = x.request.rawStatus?.() ?? 200
|
|
772
967
|
event.status ??= status
|
|
773
968
|
x.answer({ status, headers: {}, body: null, raw: true })
|
|
774
|
-
return
|
|
969
|
+
return undefined
|
|
775
970
|
}
|
|
776
|
-
const serialized = this.#complete(outcome, ctx, event, requestId, x.cors, x.preflight, recheck)
|
|
971
|
+
const serialized = this.#complete(outcome, ctx, event, x.requestId, x.cors, x.preflight, recheck)
|
|
777
972
|
const body = serialized.body
|
|
778
973
|
if (!isStreamed(body)) {
|
|
779
974
|
const reply = adapterReply(serialized, body)
|
|
780
975
|
ctx.commit()
|
|
781
976
|
ctx.releaseBody()
|
|
782
977
|
x.answer(reply)
|
|
783
|
-
return
|
|
978
|
+
return undefined
|
|
784
979
|
}
|
|
785
980
|
// files too: a file-backed Blob is read lazily, after the handler returned
|
|
786
981
|
const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
|
|
@@ -792,10 +987,15 @@ export class Pipeline {
|
|
|
792
987
|
}
|
|
793
988
|
ctx.commit()
|
|
794
989
|
x.answer(reply)
|
|
795
|
-
|
|
796
|
-
|
|
990
|
+
return { tracked, session }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** A streamed body was sent or abandoned (`failure`: the stream failed). */
|
|
994
|
+
#streamed(x: Exchange, streaming: Streaming, failure: unknown): void {
|
|
995
|
+
const ctx = x.ctx
|
|
996
|
+
if (streaming.session !== undefined) this.#streams.delete(streaming.session)
|
|
797
997
|
if (failure !== undefined) {
|
|
798
|
-
this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
|
|
998
|
+
this.#logger.error('The response stream failed', { err: failure, requestId: x.requestId, path: ctx.path })
|
|
799
999
|
}
|
|
800
1000
|
ctx.releaseBody()
|
|
801
1001
|
}
|
|
@@ -1276,6 +1476,15 @@ interface FastRoute {
|
|
|
1276
1476
|
/** `#fastAsync` reads the body and runs the handler. */
|
|
1277
1477
|
const NO_RESULT: unique symbol = Symbol('no result')
|
|
1278
1478
|
|
|
1479
|
+
/** An `http:request` wrapper returned without calling `next()` (see `Pipeline.#unhandled`). */
|
|
1480
|
+
const NOT_CALLED: unique symbol = Symbol('next() not called')
|
|
1481
|
+
|
|
1482
|
+
/** A streamed body handed to the adapter, until it was sent or abandoned (see `Pipeline.#send`). */
|
|
1483
|
+
interface Streaming {
|
|
1484
|
+
readonly tracked: TrackedStream
|
|
1485
|
+
readonly session: SseSession | undefined
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1279
1488
|
/** A request being answered (internal): its context, its target and the promise of its reply. */
|
|
1280
1489
|
class Exchange {
|
|
1281
1490
|
readonly request: AdapterRequest
|
|
@@ -1290,6 +1499,12 @@ class Exchange {
|
|
|
1290
1499
|
tracked = false
|
|
1291
1500
|
/** The asynchronous part of a fast-lane request (never rejects). */
|
|
1292
1501
|
done: Promise<void> | undefined = undefined
|
|
1502
|
+
/** Fast lane with wrappers: the outcome of the route once checked (see `Pipeline.#fastKeep`). */
|
|
1503
|
+
outcome: Outcome | undefined = undefined
|
|
1504
|
+
/** Fast lane with wrappers: `outcome` is a JSON reply or a reply without body built by the lane. */
|
|
1505
|
+
fast = false
|
|
1506
|
+
/** Fast lane with wrappers: the async context of the handler, for a streamed body. */
|
|
1507
|
+
run: ContextRunner | undefined = undefined
|
|
1293
1508
|
readonly #resolve: (reply: AdapterReply) => void
|
|
1294
1509
|
readonly #reject: (error: unknown) => void
|
|
1295
1510
|
#event: HttpRequestEvent | undefined
|
|
@@ -1345,6 +1560,12 @@ function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: n
|
|
|
1345
1560
|
}
|
|
1346
1561
|
}
|
|
1347
1562
|
|
|
1563
|
+
/** Whether a record has an own enumerable property (`event.responseHeaders` was written to). */
|
|
1564
|
+
function hasOwnKeys(record: object): boolean {
|
|
1565
|
+
for (const key in record) if (Object.hasOwn(record, key)) return true
|
|
1566
|
+
return false
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1348
1569
|
/**
|
|
1349
1570
|
* The decoded parameters of a raw request path matched against a route, in one pass: what
|
|
1350
1571
|
* `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
|
package/src/plugin-routes.ts
CHANGED
|
@@ -8,11 +8,13 @@ import type { HttpContext } from './context.ts'
|
|
|
8
8
|
import type { RouteMetaEntry } from './decorators.ts'
|
|
9
9
|
import type { HttpMethod } from './metadata.ts'
|
|
10
10
|
import type { GuardRef } from './plugin.ts'
|
|
11
|
+
import type { ByteSize } from './size.ts'
|
|
11
12
|
|
|
12
13
|
import { ErrorCode, ZnightError } from '@znight/core'
|
|
13
14
|
|
|
14
15
|
import { isGroupOf } from './decorators.ts'
|
|
15
16
|
import { joinPaths, normalizePath, parseRoutePath, type RoutePath, RoutePathError } from './path.ts'
|
|
17
|
+
import { parseByteSize } from './size.ts'
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* A route served by `HttpPlugin` on behalf of another plugin. It runs through the same pipeline as
|
|
@@ -35,6 +37,13 @@ export interface PluginRoute {
|
|
|
35
37
|
readonly meta?: readonly RouteMetaEntry[]
|
|
36
38
|
/** Guards of the route (provider classes or instances), run after the global guards. */
|
|
37
39
|
readonly guards?: readonly GuardRef[]
|
|
40
|
+
/**
|
|
41
|
+
* Maximum body size of this route (`'100kb'`, `1048576`, `Infinity`), with the grammar of the
|
|
42
|
+
* `HttpPlugin` option `bodyLimit`, which it replaces for this route only (lower or higher, like
|
|
43
|
+
* `@BodyLimit` on a controller route). Past it, reading the body answers 413. Default: the
|
|
44
|
+
* `HttpPlugin` option.
|
|
45
|
+
*/
|
|
46
|
+
readonly bodyLimit?: ByteSize
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/** What {@link PluginHttpRoutes.httpRoutes} receives. */
|
|
@@ -78,6 +87,8 @@ export interface CollectedPluginRoute {
|
|
|
78
87
|
readonly handler: (ctx: HttpContext) => unknown
|
|
79
88
|
readonly meta: readonly NormalizedMetaEntry[]
|
|
80
89
|
readonly guards: readonly unknown[]
|
|
90
|
+
/** Parsed `bodyLimit` in bytes, `undefined`: the plugin limit. */
|
|
91
|
+
readonly bodyLimit: number | undefined
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
/** A plugin route metadata entry with its group (internal). */
|
|
@@ -147,6 +158,13 @@ function validateRoute(instance: object, plugin: string, value: unknown, prefix:
|
|
|
147
158
|
}
|
|
148
159
|
const guards = route.guards ?? []
|
|
149
160
|
if (!Array.isArray(guards)) throw invalid(plugin, `the guards of the route ${where} must be an array`)
|
|
161
|
+
const bodyLimit = route.bodyLimit === undefined ? undefined : parseByteSize(route.bodyLimit)
|
|
162
|
+
if (route.bodyLimit !== undefined && bodyLimit === undefined) {
|
|
163
|
+
throw invalid(
|
|
164
|
+
plugin,
|
|
165
|
+
`invalid bodyLimit ${describe(route.bodyLimit)} of the route ${where}: expected a size in bytes (1048576, '100kb', Infinity)`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
150
168
|
return {
|
|
151
169
|
plugin,
|
|
152
170
|
instance,
|
|
@@ -157,6 +175,7 @@ function validateRoute(instance: object, plugin: string, value: unknown, prefix:
|
|
|
157
175
|
handler: route.handler,
|
|
158
176
|
meta: entries,
|
|
159
177
|
guards,
|
|
178
|
+
bodyLimit,
|
|
160
179
|
}
|
|
161
180
|
}
|
|
162
181
|
|
package/src/plugin.ts
CHANGED
|
@@ -1035,7 +1035,12 @@ function buildRegistrations(
|
|
|
1035
1035
|
invoke: (_plugin: unknown, http: HttpContext): unknown => handler(http),
|
|
1036
1036
|
})
|
|
1037
1037
|
const instance: Resolvable<object> = { instance: route.instance }
|
|
1038
|
-
|
|
1038
|
+
// the route's own limit (`bodyLimit`), otherwise the plugin limit
|
|
1039
|
+
const body = bodyPlan(
|
|
1040
|
+
route.bodyLimit === undefined ? {} : { bodyLimit: route.bodyLimit },
|
|
1041
|
+
options.bodyLimit,
|
|
1042
|
+
options.uploads,
|
|
1043
|
+
)
|
|
1039
1044
|
const chain = guards.map(guard => guard.resolvable)
|
|
1040
1045
|
const path = route.path
|
|
1041
1046
|
declared.push({
|