@znight/http 0.1.3 → 0.2.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/README.md +85 -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 +264 -46
- 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 +327 -63
- 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 }
|
|
@@ -334,32 +334,30 @@ export class Pipeline {
|
|
|
334
334
|
// a HEAD reply has no body: JSON results of an explicit `@Head` route take the general pipeline
|
|
335
335
|
jsonStatus: definition.method === 'HEAD' || isBodylessStatus(json) ? undefined : json,
|
|
336
336
|
emptyStatus: definition.status ?? (post ? 201 : 204),
|
|
337
|
+
responses: definition.method !== 'HEAD',
|
|
337
338
|
}
|
|
338
339
|
}
|
|
339
340
|
|
|
340
341
|
/**
|
|
341
342
|
* 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).
|
|
343
|
+
* of per request, without promises while the route stays synchronous. A request takes it when the
|
|
344
|
+
* server is not draining and its method is the route's (not a `HEAD` served by a `GET` route); a
|
|
345
|
+
* plain object or array returned by the handler is serialised as JSON, and `undefined` answered
|
|
346
|
+
* without body, with the headers in their final order (a template precomputed at boot).
|
|
347
347
|
* A thenable result is awaited in the lane (see {@link #fastAsync}). Anything else — a malformed
|
|
348
348
|
* path (before the request starts), a thrown error or a rejection, an `HttpResponse`, a `Response`
|
|
349
349
|
* or any other result, headers or cookies set through the context — continues in the general
|
|
350
350
|
* pipeline, in the same request scope, with the same outcome.
|
|
351
|
+
* With `http:request` wrappers, the handler runs inside them (see {@link #fastWrapped}); listeners
|
|
352
|
+
* get the event once the reply is ready, as in the general pipeline.
|
|
351
353
|
*/
|
|
352
354
|
#fast(route: FastRoute, request: AdapterRequest): Promise<AdapterReply> {
|
|
353
355
|
const plan = route.plan
|
|
356
|
+
if (this.draining || request.method !== route.method) return this.handle(plan, request)
|
|
354
357
|
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
|
-
}
|
|
358
|
+
const wrapped = hooks.hasWrappers('http:request')
|
|
359
|
+
// a hooked request starts now (see handle): its event is created with the exchange
|
|
360
|
+
const startTime = wrapped || hooks.hasListeners('http:request') ? performance.now() : undefined
|
|
363
361
|
const url = request.url
|
|
364
362
|
const mark = url.indexOf('?')
|
|
365
363
|
const path = mark === -1 ? url : url.slice(0, mark)
|
|
@@ -379,16 +377,13 @@ export class Pipeline {
|
|
|
379
377
|
[HttpTokens.Context, ctx],
|
|
380
378
|
[HttpTokens.RequestId, ctx.requestId],
|
|
381
379
|
])
|
|
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)
|
|
380
|
+
if (wrapped) {
|
|
381
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
382
|
+
x.done = scope.run(this.#fastWrapped, x, route)
|
|
383
|
+
return this.#fastExchange(x, scope)
|
|
391
384
|
}
|
|
385
|
+
const done = scope.run(this.#fastRun, route, request, ctx, startTime)
|
|
386
|
+
if (done instanceof Exchange) return this.#fastExchange(done, scope)
|
|
392
387
|
const closing = scope.close()
|
|
393
388
|
if (closing === undefined) {
|
|
394
389
|
ctx.complete()
|
|
@@ -401,9 +396,9 @@ export class Pipeline {
|
|
|
401
396
|
(error: unknown) => this.#disposalFailed(x, error),
|
|
402
397
|
)
|
|
403
398
|
}
|
|
404
|
-
//
|
|
399
|
+
// listeners are checked once the scope callback paused (one may have been registered by the handler)
|
|
405
400
|
if (!hooks.hasListeners('http:request')) return Promise.resolve(done)
|
|
406
|
-
const event = requestEvent(request, ctx, performance.now())
|
|
401
|
+
const event = requestEvent(request, ctx, startTime ?? performance.now())
|
|
407
402
|
event.status = done.status
|
|
408
403
|
return Promise.resolve(done).then(reply => {
|
|
409
404
|
hooks.emit('http:request', event)
|
|
@@ -411,21 +406,41 @@ export class Pipeline {
|
|
|
411
406
|
})
|
|
412
407
|
}
|
|
413
408
|
|
|
409
|
+
/**
|
|
410
|
+
* The reply of a fast-lane request whose asynchronous part runs in `x.done`: the request ends once
|
|
411
|
+
* it settled (see {@link #end}); listeners get the event once the reply is ready.
|
|
412
|
+
*/
|
|
413
|
+
#fastExchange(x: Exchange, scope: OpenedScope): Promise<AdapterReply> {
|
|
414
|
+
;(x.done as Promise<void>).then(
|
|
415
|
+
() => this.#end(x, scope, undefined),
|
|
416
|
+
(error: unknown) => this.#end(x, scope, error),
|
|
417
|
+
)
|
|
418
|
+
if (!this.#hooks.hasListeners('http:request')) return x.replied
|
|
419
|
+
x.tracked = true
|
|
420
|
+
return this.#emitted(x)
|
|
421
|
+
}
|
|
422
|
+
|
|
414
423
|
/**
|
|
415
424
|
* The fast lane in the request scope: the reply when the handler answered synchronously with a
|
|
416
425
|
* plain JSON value or `undefined`, otherwise the exchange finishing the request (see {@link #fast}).
|
|
426
|
+
* `startTime`: the request has `http:request` listeners, its event starts with the exchange.
|
|
417
427
|
*/
|
|
418
|
-
readonly #fastRun = (
|
|
428
|
+
readonly #fastRun = (
|
|
429
|
+
route: FastRoute,
|
|
430
|
+
request: AdapterRequest,
|
|
431
|
+
ctx: RequestContext,
|
|
432
|
+
startTime: number | undefined,
|
|
433
|
+
): AdapterReply | Exchange => {
|
|
419
434
|
const plan = route.plan
|
|
420
|
-
if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT)
|
|
435
|
+
if (plan.parseBody) return this.#fastLater(route, request, ctx, NO_RESULT, startTime)
|
|
421
436
|
let result: unknown
|
|
422
437
|
let reply: AdapterReply | undefined
|
|
423
438
|
try {
|
|
424
439
|
result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
|
|
425
|
-
if (isThenable(result)) return this.#fastLater(route, request, ctx, result)
|
|
440
|
+
if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
|
|
426
441
|
reply = this.#fastReply(route, ctx, result)
|
|
427
442
|
} catch (error) {
|
|
428
|
-
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
443
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
429
444
|
x.done = this.#respond(x, () => this.#failed(error, ctx, x.event))
|
|
430
445
|
return x
|
|
431
446
|
}
|
|
@@ -434,14 +449,20 @@ export class Pipeline {
|
|
|
434
449
|
ctx.releaseBody()
|
|
435
450
|
return reply
|
|
436
451
|
}
|
|
437
|
-
const x = new Exchange(request, ctx, ctx.requestId, route.target)
|
|
452
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
438
453
|
x.done = this.#respond(x, () => this.#settle(ctx, plan, x.event, result))
|
|
439
454
|
return x
|
|
440
455
|
}
|
|
441
456
|
|
|
442
457
|
/** The fast lane of a route with a body to read or a handler that returned a thenable. */
|
|
443
|
-
#fastLater(
|
|
444
|
-
|
|
458
|
+
#fastLater(
|
|
459
|
+
route: FastRoute,
|
|
460
|
+
request: AdapterRequest,
|
|
461
|
+
ctx: RequestContext,
|
|
462
|
+
result: unknown,
|
|
463
|
+
startTime: number | undefined,
|
|
464
|
+
): Exchange {
|
|
465
|
+
const x = new Exchange(request, ctx, ctx.requestId, route.target, startTime)
|
|
445
466
|
x.done = this.#fastAsync(x, route, result)
|
|
446
467
|
return x
|
|
447
468
|
}
|
|
@@ -480,34 +501,206 @@ export class Pipeline {
|
|
|
480
501
|
}
|
|
481
502
|
|
|
482
503
|
/**
|
|
483
|
-
* The reply of a plain JSON value (see {@link isPlainJson})
|
|
484
|
-
*
|
|
485
|
-
* `undefined` when the result or the context
|
|
486
|
-
* `JSON.stringify`.
|
|
504
|
+
* The reply of a plain JSON value (see {@link isPlainJson}), of an `HttpResponse` carrying one
|
|
505
|
+
* (see {@link fastResponseType}) or of `undefined`: what `serializeResult` and {@link #complete}
|
|
506
|
+
* produce for it, the headers in their final order. `undefined` when the result or the context
|
|
507
|
+
* needs the general pipeline. Throws like `JSON.stringify`.
|
|
487
508
|
*/
|
|
488
509
|
#fastReply(route: FastRoute, ctx: RequestContext, result: unknown): AdapterReply | undefined {
|
|
489
|
-
|
|
510
|
+
let payload = result
|
|
511
|
+
let status = result === undefined ? route.emptyStatus : route.jsonStatus
|
|
512
|
+
let type: string | null = null
|
|
513
|
+
if (route.responses && result instanceof HttpResponse) {
|
|
514
|
+
const own = fastResponseType(result)
|
|
515
|
+
if (own === undefined) return undefined
|
|
516
|
+
type = own
|
|
517
|
+
payload = result.body
|
|
518
|
+
// `@HttpCode` > `HttpResponse.status` > the default of the route (see serializeResult)
|
|
519
|
+
if (route.plan.definition.status === undefined && result.status !== undefined) {
|
|
520
|
+
status = isBodylessStatus(result.status) ? undefined : result.status
|
|
521
|
+
}
|
|
522
|
+
// an HttpResponse without body takes the general pipeline
|
|
523
|
+
if (payload === undefined) return undefined
|
|
524
|
+
}
|
|
490
525
|
// draining since the request started (`connection: close`, see #frameworkHeaders)
|
|
491
526
|
if (status === undefined || ctx.hasResponseHeaders || ctx.hasCookies || this.draining) return undefined
|
|
492
527
|
const requestId = ctx.requestId
|
|
493
528
|
// a custom generator's id that cannot be sent is left out (see #frameworkHeaders)
|
|
494
529
|
if (!this.#validIds && !isHeaderValue(requestId)) return undefined
|
|
495
|
-
if (
|
|
530
|
+
if (payload === undefined) return this.#templateReply(status, null, undefined, requestId, null)
|
|
531
|
+
if (!isPlainJson(payload)) return undefined
|
|
532
|
+
const json = JSON.stringify(payload)
|
|
533
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof payload} as a response body`)
|
|
534
|
+
// encoded once, its length from the bytes (see serialize.ts encodeText)
|
|
535
|
+
return this.#templateReply(status, utf8(json), json, requestId, type)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The reply of a JSON body (`text` set) or of no body, its headers copied from the template of
|
|
540
|
+
* the fast lane (see {@link #jsonHeaders}), completed with the length and the request id (one that
|
|
541
|
+
* can be sent). `type` replaces the JSON content type in place (the `HttpResponse`'s own).
|
|
542
|
+
*/
|
|
543
|
+
#templateReply(
|
|
544
|
+
status: number,
|
|
545
|
+
body: Uint8Array | null,
|
|
546
|
+
text: string | undefined,
|
|
547
|
+
requestId: RequestId,
|
|
548
|
+
type: string | null,
|
|
549
|
+
): AdapterReply {
|
|
550
|
+
if (text === undefined) {
|
|
496
551
|
const headers: Record<string, HeaderValue> = { ...this.#emptyHeaders }
|
|
497
552
|
const idHeader = this.#emptyIdHeader
|
|
498
553
|
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
499
554
|
return { status, headers, body: null }
|
|
500
555
|
}
|
|
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
556
|
const headers: Record<string, HeaderValue> = { ...this.#jsonHeaders }
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
headers['content-length'] = String(body.byteLength)
|
|
557
|
+
// assignHeaders keeps the position of a key: the content type stays in place
|
|
558
|
+
if (type !== null) headers['content-type'] = type
|
|
559
|
+
headers['content-length'] = String((body as Uint8Array).byteLength)
|
|
508
560
|
const idHeader = this.#jsonIdHeader
|
|
509
561
|
if (idHeader !== undefined) headers[idHeader] = requestId
|
|
510
|
-
return { status, headers, body, text
|
|
562
|
+
return { status, headers, body, text }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The fast lane through the `http:request` wrappers (registered when the request started), in the
|
|
567
|
+
* request scope: what {@link #respond} does for the route, the handler run inside `next()` like
|
|
568
|
+
* {@link #fastRun}. A JSON value or `undefined` is serialised there as `serializeResult` does, and
|
|
569
|
+
* the reply completed from the template of the fast lane when the wrappers added no response header
|
|
570
|
+
* (`event.responseHeaders`) nor cookie (see {@link #fastDeliver}). Never rejects.
|
|
571
|
+
*/
|
|
572
|
+
readonly #fastWrapped = (x: Exchange, route: FastRoute): Promise<void> =>
|
|
573
|
+
this.#hooks
|
|
574
|
+
.run('http:request', x.event, () => this.#fastOperation(x, route))
|
|
575
|
+
.then(
|
|
576
|
+
() => this.#fastDeliver(x, false, undefined),
|
|
577
|
+
(error: unknown) => this.#fastDeliver(x, true, error),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
/** The operation wrapped by the `http:request` wrappers in the fast lane: the route run, its outcome checked. */
|
|
581
|
+
#fastOperation(x: Exchange, route: FastRoute): Promise<Outcome> {
|
|
582
|
+
const ctx = x.ctx
|
|
583
|
+
const plan = route.plan
|
|
584
|
+
if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
|
|
585
|
+
let result: unknown
|
|
586
|
+
try {
|
|
587
|
+
result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
return this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
590
|
+
}
|
|
591
|
+
if (isThenable(result)) return this.#fastOperationLater(x, route, result)
|
|
592
|
+
return this.#fastChecked(x, this.#fastOutcome(x, route, result))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** {@link #fastOperation} with a body to read (`pending` is {@link NO_RESULT}) or a thenable result. */
|
|
596
|
+
async #fastOperationLater(x: Exchange, route: FastRoute, pending: unknown): Promise<Outcome> {
|
|
597
|
+
const ctx = x.ctx
|
|
598
|
+
const plan = route.plan
|
|
599
|
+
let result = pending
|
|
600
|
+
try {
|
|
601
|
+
if (result === NO_RESULT) {
|
|
602
|
+
const controller = this.#resolve(plan.controller)
|
|
603
|
+
await ctx.parseBody()
|
|
604
|
+
result = plan.definition.invoke(controller, ctx)
|
|
605
|
+
}
|
|
606
|
+
if (isThenable(result)) result = await result
|
|
607
|
+
} catch (error) {
|
|
608
|
+
return await this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
609
|
+
}
|
|
610
|
+
return await this.#fastChecked(x, this.#fastOutcome(x, route, result))
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The outcome of a handler result in {@link #fastOperation}: for a plain JSON value or `undefined`,
|
|
615
|
+
* the reply of `serializeResult` built directly (`x.fast` set); anything else as in the general
|
|
616
|
+
* pipeline (see {@link #settle}). Never throws nor rejects (errors are mapped).
|
|
617
|
+
*/
|
|
618
|
+
#fastOutcome(x: Exchange, route: FastRoute, result: unknown): Outcome | Promise<Outcome> {
|
|
619
|
+
const ctx = x.ctx
|
|
620
|
+
const event = x.event
|
|
621
|
+
const status = result === undefined ? route.emptyStatus : route.jsonStatus
|
|
622
|
+
if (status === undefined || ctx.hasResponseHeaders || (result !== undefined && !isPlainJson(result))) {
|
|
623
|
+
return this.#settle(ctx, route.plan, event, result)
|
|
624
|
+
}
|
|
625
|
+
let reply: SerializedReply
|
|
626
|
+
if (result === undefined) {
|
|
627
|
+
reply = { status, headers: fastRecord<HeaderValue>(), body: null }
|
|
628
|
+
} else {
|
|
629
|
+
let json: string | undefined
|
|
630
|
+
try {
|
|
631
|
+
json = JSON.stringify(result)
|
|
632
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof result} as a response body`)
|
|
633
|
+
} catch (error) {
|
|
634
|
+
return this.#failed(error, ctx, event)
|
|
635
|
+
}
|
|
636
|
+
const headers = fastRecord<HeaderValue>()
|
|
637
|
+
headers['content-type'] = ContentType.json
|
|
638
|
+
const body = utf8(json)
|
|
639
|
+
headers['content-length'] = String(body.byteLength)
|
|
640
|
+
reply = { status, headers, body, text: json, ownHeaders: true }
|
|
641
|
+
}
|
|
642
|
+
event.status = status
|
|
643
|
+
x.fast = true
|
|
644
|
+
return reply
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* What the general pipeline does with an outcome inside the wrappers (see {@link #respond}): its
|
|
649
|
+
* headers and cookies checked, the async context of a streamed body captured; kept in `x.outcome`.
|
|
650
|
+
*/
|
|
651
|
+
#fastChecked(x: Exchange, pending: Outcome | Promise<Outcome>): Promise<Outcome> {
|
|
652
|
+
if (pending instanceof Promise) return pending.then(outcome => this.#fastKeep(x, outcome))
|
|
653
|
+
return Promise.resolve(this.#fastKeep(x, pending))
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
#fastKeep(x: Exchange, outcome: Outcome): Outcome {
|
|
657
|
+
// a reply of #fastOutcome is valid by construction (what #checked returns for it)
|
|
658
|
+
const checked = x.fast ? outcome : this.#checked(outcome, x.ctx, x.event)
|
|
659
|
+
if (checked !== RAW && isStreamed(checked.body)) x.run = captureContext()
|
|
660
|
+
x.outcome = checked
|
|
661
|
+
return checked
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Answers a fast-lane request once its wrappers settled: a reply of {@link #fastOutcome} gets the
|
|
666
|
+
* headers of the template when nothing was added to it (no response header from the wrappers, no
|
|
667
|
+
* cookie, not draining, a request id that can be sent), which is what {@link #complete} gives;
|
|
668
|
+
* anything else is completed by {@link #deliver}. Never rejects.
|
|
669
|
+
*/
|
|
670
|
+
#fastDeliver(x: Exchange, failed: boolean, hookError: unknown): Promise<void> | undefined {
|
|
671
|
+
const outcome = x.outcome
|
|
672
|
+
const ctx = x.ctx
|
|
673
|
+
if (outcome === undefined) return this.#fastUnhandled(x, failed ? hookError : NOT_CALLED)
|
|
674
|
+
if (failed) this.#wrapperFailed(x, hookError)
|
|
675
|
+
else if (
|
|
676
|
+
x.fast &&
|
|
677
|
+
outcome !== RAW &&
|
|
678
|
+
!hasOwnKeys(x.event.responseHeaders) &&
|
|
679
|
+
!ctx.hasCookies &&
|
|
680
|
+
!this.draining &&
|
|
681
|
+
(this.#validIds || isHeaderValue(x.requestId))
|
|
682
|
+
) {
|
|
683
|
+
const reply = this.#templateReply(
|
|
684
|
+
outcome.status,
|
|
685
|
+
outcome.body as Uint8Array | null,
|
|
686
|
+
outcome.text,
|
|
687
|
+
x.requestId,
|
|
688
|
+
null,
|
|
689
|
+
)
|
|
690
|
+
ctx.commit()
|
|
691
|
+
ctx.releaseBody()
|
|
692
|
+
x.answer(reply)
|
|
693
|
+
return undefined
|
|
694
|
+
}
|
|
695
|
+
const streaming = this.#send(x, outcome, true, x.run)
|
|
696
|
+
if (streaming === undefined) return undefined
|
|
697
|
+
return streaming.tracked.done.then(failure => this.#streamed(x, streaming, failure))
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** {@link #fastDeliver} when a wrapper failed before the request was handled or did not call `next()`. */
|
|
701
|
+
async #fastUnhandled(x: Exchange, failure: unknown): Promise<void> {
|
|
702
|
+
const streaming = this.#send(x, await this.#unhandled(x, failure), true, undefined)
|
|
703
|
+
if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
|
|
511
704
|
}
|
|
512
705
|
|
|
513
706
|
/** Ends a fast-lane request once its asynchronous part settled: its scope is closed, then it is over. */
|
|
@@ -752,35 +945,61 @@ export class Pipeline {
|
|
|
752
945
|
hookError = error
|
|
753
946
|
}
|
|
754
947
|
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
|
|
948
|
+
outcome = await this.#unhandled(x, failed ? hookError : NOT_CALLED)
|
|
762
949
|
recheck = true
|
|
763
950
|
} else if (failed) {
|
|
764
|
-
this.#
|
|
765
|
-
err: hookError,
|
|
766
|
-
requestId,
|
|
767
|
-
})
|
|
951
|
+
this.#wrapperFailed(x, hookError)
|
|
768
952
|
}
|
|
953
|
+
const streaming = this.#send(x, outcome, recheck, run)
|
|
954
|
+
if (streaming !== undefined) this.#streamed(x, streaming, await streaming.tracked.done)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The reply of a request whose `http:request` wrapper failed before it was handled (`error`), or
|
|
959
|
+
* did not call `next()` ({@link NOT_CALLED}): the error mapped and checked like a handled reply (an
|
|
960
|
+
* exception mapper's `HttpResponse` may carry cookies). Never rejects.
|
|
961
|
+
*/
|
|
962
|
+
async #unhandled(x: Exchange, failure: unknown): Promise<CheckedReply> {
|
|
963
|
+
const ctx = x.ctx
|
|
964
|
+
const event = x.event
|
|
965
|
+
const error = failure === NOT_CALLED ? new Error('An "http:request" hook wrapper did not call next()') : failure
|
|
966
|
+
event.error ??= error
|
|
967
|
+
const fallback = await this.#errorReply(error, ctx)
|
|
968
|
+
const reply = this.#checkCookies(this.#checkHeaders(fallback, ctx, event), ctx, event)
|
|
969
|
+
event.status = reply.status
|
|
970
|
+
return reply
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
#wrapperFailed(x: Exchange, error: unknown): void {
|
|
974
|
+
this.#logger.error('An "http:request" hook wrapper failed after the request was handled', {
|
|
975
|
+
err: error,
|
|
976
|
+
requestId: x.requestId,
|
|
977
|
+
})
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Completes the outcome of a request (`recheck`: wrappers ran) and hands its reply to the adapter.
|
|
982
|
+
* Returns the tracking of a streamed body (read in `run`, the context of the handler, when it was
|
|
983
|
+
* captured), whose end {@link #streamed} records.
|
|
984
|
+
*/
|
|
985
|
+
#send(x: Exchange, outcome: Outcome, recheck: boolean, run: ContextRunner | undefined): Streaming | undefined {
|
|
986
|
+
const ctx = x.ctx
|
|
987
|
+
const event = x.event
|
|
769
988
|
if (outcome === RAW) {
|
|
770
989
|
ctx.commit()
|
|
771
990
|
const status = x.request.rawStatus?.() ?? 200
|
|
772
991
|
event.status ??= status
|
|
773
992
|
x.answer({ status, headers: {}, body: null, raw: true })
|
|
774
|
-
return
|
|
993
|
+
return undefined
|
|
775
994
|
}
|
|
776
|
-
const serialized = this.#complete(outcome, ctx, event, requestId, x.cors, x.preflight, recheck)
|
|
995
|
+
const serialized = this.#complete(outcome, ctx, event, x.requestId, x.cors, x.preflight, recheck)
|
|
777
996
|
const body = serialized.body
|
|
778
997
|
if (!isStreamed(body)) {
|
|
779
998
|
const reply = adapterReply(serialized, body)
|
|
780
999
|
ctx.commit()
|
|
781
1000
|
ctx.releaseBody()
|
|
782
1001
|
x.answer(reply)
|
|
783
|
-
return
|
|
1002
|
+
return undefined
|
|
784
1003
|
}
|
|
785
1004
|
// files too: a file-backed Blob is read lazily, after the handler returned
|
|
786
1005
|
const tracked = trackStream(body, run ?? captureContext(), () => ctx.abort('client'))
|
|
@@ -792,10 +1011,15 @@ export class Pipeline {
|
|
|
792
1011
|
}
|
|
793
1012
|
ctx.commit()
|
|
794
1013
|
x.answer(reply)
|
|
795
|
-
|
|
796
|
-
|
|
1014
|
+
return { tracked, session }
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** A streamed body was sent or abandoned (`failure`: the stream failed). */
|
|
1018
|
+
#streamed(x: Exchange, streaming: Streaming, failure: unknown): void {
|
|
1019
|
+
const ctx = x.ctx
|
|
1020
|
+
if (streaming.session !== undefined) this.#streams.delete(streaming.session)
|
|
797
1021
|
if (failure !== undefined) {
|
|
798
|
-
this.#logger.error('The response stream failed', { err: failure, requestId, path: ctx.path })
|
|
1022
|
+
this.#logger.error('The response stream failed', { err: failure, requestId: x.requestId, path: ctx.path })
|
|
799
1023
|
}
|
|
800
1024
|
ctx.releaseBody()
|
|
801
1025
|
}
|
|
@@ -1271,11 +1495,22 @@ interface FastRoute {
|
|
|
1271
1495
|
readonly jsonStatus: number | undefined
|
|
1272
1496
|
/** The status of a reply without value (`undefined` returned). */
|
|
1273
1497
|
readonly emptyStatus: number
|
|
1498
|
+
/** An `HttpResponse` carrying a plain JSON body may be served (not on a `@Head` route: no body). */
|
|
1499
|
+
readonly responses: boolean
|
|
1274
1500
|
}
|
|
1275
1501
|
|
|
1276
1502
|
/** `#fastAsync` reads the body and runs the handler. */
|
|
1277
1503
|
const NO_RESULT: unique symbol = Symbol('no result')
|
|
1278
1504
|
|
|
1505
|
+
/** An `http:request` wrapper returned without calling `next()` (see `Pipeline.#unhandled`). */
|
|
1506
|
+
const NOT_CALLED: unique symbol = Symbol('next() not called')
|
|
1507
|
+
|
|
1508
|
+
/** A streamed body handed to the adapter, until it was sent or abandoned (see `Pipeline.#send`). */
|
|
1509
|
+
interface Streaming {
|
|
1510
|
+
readonly tracked: TrackedStream
|
|
1511
|
+
readonly session: SseSession | undefined
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1279
1514
|
/** A request being answered (internal): its context, its target and the promise of its reply. */
|
|
1280
1515
|
class Exchange {
|
|
1281
1516
|
readonly request: AdapterRequest
|
|
@@ -1290,6 +1525,12 @@ class Exchange {
|
|
|
1290
1525
|
tracked = false
|
|
1291
1526
|
/** The asynchronous part of a fast-lane request (never rejects). */
|
|
1292
1527
|
done: Promise<void> | undefined = undefined
|
|
1528
|
+
/** Fast lane with wrappers: the outcome of the route once checked (see `Pipeline.#fastKeep`). */
|
|
1529
|
+
outcome: Outcome | undefined = undefined
|
|
1530
|
+
/** Fast lane with wrappers: `outcome` is a JSON reply or a reply without body built by the lane. */
|
|
1531
|
+
fast = false
|
|
1532
|
+
/** Fast lane with wrappers: the async context of the handler, for a streamed body. */
|
|
1533
|
+
run: ContextRunner | undefined = undefined
|
|
1293
1534
|
readonly #resolve: (reply: AdapterReply) => void
|
|
1294
1535
|
readonly #reject: (error: unknown) => void
|
|
1295
1536
|
#event: HttpRequestEvent | undefined
|
|
@@ -1345,6 +1586,12 @@ function requestEvent(request: AdapterRequest, ctx: RequestContext, startTime: n
|
|
|
1345
1586
|
}
|
|
1346
1587
|
}
|
|
1347
1588
|
|
|
1589
|
+
/** Whether a record has an own enumerable property (`event.responseHeaders` was written to). */
|
|
1590
|
+
function hasOwnKeys(record: object): boolean {
|
|
1591
|
+
for (const key in record) if (Object.hasOwn(record, key)) return true
|
|
1592
|
+
return false
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1348
1595
|
/**
|
|
1349
1596
|
* The decoded parameters of a raw request path matched against a route, in one pass: what
|
|
1350
1597
|
* `matchRoutePath` then `decodePathValue` (see `Pipeline.#route`) give for a path they accept.
|
|
@@ -1401,6 +1648,23 @@ function isPlainJson(value: unknown): value is object {
|
|
|
1401
1648
|
return typeof (value as { pipe?: unknown }).pipe !== 'function'
|
|
1402
1649
|
}
|
|
1403
1650
|
|
|
1651
|
+
/**
|
|
1652
|
+
* The content type of an `HttpResponse` the fast lane can serve: `null` without headers, its
|
|
1653
|
+
* `content-type` when that is its only header (lower-case name, a value every adapter can send);
|
|
1654
|
+
* `undefined` when it needs the general pipeline (other headers, cookies).
|
|
1655
|
+
*/
|
|
1656
|
+
function fastResponseType(response: HttpResponse): string | null | undefined {
|
|
1657
|
+
if (response.cookies.length > 0) return undefined
|
|
1658
|
+
const headers = response.headers
|
|
1659
|
+
let type: string | null = null
|
|
1660
|
+
for (const name of Object.keys(headers)) {
|
|
1661
|
+
const value = headers[name]
|
|
1662
|
+
if (name !== 'content-type' || typeof value !== 'string' || !isHeaderValue(value)) return undefined
|
|
1663
|
+
type = value
|
|
1664
|
+
}
|
|
1665
|
+
return type
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1404
1668
|
const ENCODER = new TextEncoder()
|
|
1405
1669
|
|
|
1406
1670
|
/** UTF-8 encoding of a text body, like the serialiser (a pooled `Buffer` on Node.js, `TextEncoder` on Bun). */
|
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({
|