@nifrajs/core 3.5.0 → 4.0.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/README.md +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +26 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +24 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +168 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +40 -8
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +41 -37
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +53 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +191 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1206 -218
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +7 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +31 -6
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +237 -12
- package/src/idempotency.ts +72 -8
- package/src/index.ts +40 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +41 -36
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +59 -2
- package/src/server/server.ts +1613 -269
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +38 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `notFound()` handler's rules:
|
|
3
|
+
* - Only for a `404`: a wrong method stays a `405`, a malformed path a `400`, a mount's `404` its own.
|
|
4
|
+
* - It sees the request line and headers, never the body: an unrouted request has no body limit.
|
|
5
|
+
* - A method token no route could take gets the default `404`, as it skips `onRequest` hooks.
|
|
6
|
+
* - A `2xx` is sent as a `404`; a `3xx`, `410` or other error status is sent as built.
|
|
7
|
+
* - Fails closed: a throw or an unsendable value is logged and answered with the plain `500`.
|
|
8
|
+
*/
|
|
9
|
+
import { type ErrorLogDetail, emitRequestErrorLog } from "./bare-error-lane.ts"
|
|
10
|
+
import type { Platform } from "./context.ts"
|
|
11
|
+
import { plainError } from "./http.ts"
|
|
12
|
+
import { INSTALL_NOT_FOUND } from "./install.ts"
|
|
13
|
+
import type { Logger } from "./logger.ts"
|
|
14
|
+
import {
|
|
15
|
+
answerNotFound,
|
|
16
|
+
type NotFoundHandler,
|
|
17
|
+
type NotFoundLane,
|
|
18
|
+
notFoundInput,
|
|
19
|
+
} from "./not-found-answer.ts"
|
|
20
|
+
import type { IdentityPlugin } from "./plugin.ts"
|
|
21
|
+
import type { ResponseResult } from "./runtime-core.ts"
|
|
22
|
+
import type { AnyServer, MaybePromise, RequestSource } from "./server.ts"
|
|
23
|
+
|
|
24
|
+
export type { NotFoundHandler, NotFoundInput } from "./not-found-answer.ts"
|
|
25
|
+
|
|
26
|
+
/** The kernel state the lane reads. Fixed at construction, so reading it per miss is three loads. */
|
|
27
|
+
interface NotFoundHost {
|
|
28
|
+
readonly requestTimeoutMs: number
|
|
29
|
+
readonly logger: Logger
|
|
30
|
+
readonly errorLogDetail: ErrorLogDetail
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The install seam a server exposes so the `notFound()` plugin can hand it the lane. */
|
|
34
|
+
interface NotFoundInstallable {
|
|
35
|
+
[INSTALL_NOT_FOUND](lane: NotFoundLane): void
|
|
36
|
+
readonly notFoundLane?: NotFoundLane
|
|
37
|
+
readonly routePrefix: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Answer requests no route matched with a handler of your own: `app.use(notFound(handler))`.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { notFound } from "@nifrajs/core/not-found"
|
|
45
|
+
*
|
|
46
|
+
* const app = server().use(
|
|
47
|
+
* notFound(({ pathname }) => Response.json({ ok: false, error: "no_such_route", pathname })),
|
|
48
|
+
* )
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* The answer is a `404` whatever `2xx` status the handler gives it; see the module notes for the
|
|
52
|
+
* full rules. The server's `requestTimeoutMs` bounds an async handler and aborts `input.signal`.
|
|
53
|
+
*
|
|
54
|
+
* A server takes one handler, and it belongs to the server that listens: applying `notFound()` a
|
|
55
|
+
* second time throws, and so does applying it inside a `group()` (a miss has no prefix to scope it
|
|
56
|
+
* to). A server composed into another with `merge()` does not carry its handler along.
|
|
57
|
+
*/
|
|
58
|
+
export function notFound<Env = unknown>(handler: NotFoundHandler<Env>): IdentityPlugin {
|
|
59
|
+
if (typeof handler !== "function") throw new TypeError("notFound() needs a handler function")
|
|
60
|
+
const lane: NotFoundLane = <T>(
|
|
61
|
+
host: unknown,
|
|
62
|
+
source: RequestSource,
|
|
63
|
+
pathname: string,
|
|
64
|
+
platform: Platform<unknown> | undefined,
|
|
65
|
+
wrap: (response: Response | ResponseResult) => T,
|
|
66
|
+
onTimeout: () => T,
|
|
67
|
+
): MaybePromise<T> => {
|
|
68
|
+
const { requestTimeoutMs, logger, errorLogDetail } = host as NotFoundHost
|
|
69
|
+
const env = platform as Platform<Env> | undefined
|
|
70
|
+
const failed = (error: unknown): T => {
|
|
71
|
+
// A logger that throws must not decide the answer: the fault is still the plain `500`.
|
|
72
|
+
try {
|
|
73
|
+
emitRequestErrorLog(logger, errorLogDetail, error, { req: source })
|
|
74
|
+
} catch {}
|
|
75
|
+
return wrap(plainError(500, "internal_error"))
|
|
76
|
+
}
|
|
77
|
+
if (requestTimeoutMs === 0) {
|
|
78
|
+
return answerNotFound(handler, notFoundInput(source, pathname, env), wrap, failed)
|
|
79
|
+
}
|
|
80
|
+
const controller = new AbortController()
|
|
81
|
+
const answer = answerNotFound(
|
|
82
|
+
handler,
|
|
83
|
+
notFoundInput(source, pathname, env, controller.signal),
|
|
84
|
+
wrap,
|
|
85
|
+
failed,
|
|
86
|
+
)
|
|
87
|
+
if (!(answer instanceof Promise)) return answer
|
|
88
|
+
// The same bound a matched route gets: abort the signal, answer the timeout, discard the late
|
|
89
|
+
// result. Both arms are attached, so a handler that settles after the deadline is still observed.
|
|
90
|
+
return new Promise<T>((resolve, reject) => {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
controller.abort()
|
|
93
|
+
resolve(onTimeout())
|
|
94
|
+
}, requestTimeoutMs)
|
|
95
|
+
answer.then(
|
|
96
|
+
(value) => {
|
|
97
|
+
clearTimeout(timer)
|
|
98
|
+
resolve(value)
|
|
99
|
+
},
|
|
100
|
+
(error) => {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
reject(error)
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
108
|
+
const host = app as unknown as NotFoundInstallable
|
|
109
|
+
if (host.notFoundLane !== undefined) {
|
|
110
|
+
throw new TypeError("notFound() is already applied to this server")
|
|
111
|
+
}
|
|
112
|
+
if (host.routePrefix !== "") {
|
|
113
|
+
throw new TypeError(
|
|
114
|
+
"notFound() inside a group answers nothing - apply it to the parent server",
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
host[INSTALL_NOT_FOUND](lane)
|
|
118
|
+
return app
|
|
119
|
+
}
|
|
120
|
+
return apply as IdentityPlugin
|
|
121
|
+
}
|
package/src/server/plugin.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* `use`/`derive`/`decorate` and/or registering routes) and returns it; these helpers attach a name
|
|
4
4
|
* for idempotent dedupe and pin the type-threading so `.use()` preserves the caller's typed server.
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type { Method } from "../router/router.ts"
|
|
7
|
+
import type { RouteSchema } from "./context.ts"
|
|
8
|
+
import type { AddRoute, Registry, RouteInfoFor } from "./registry.ts"
|
|
7
9
|
import type { AnyServer, Server } from "./server.ts"
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -62,6 +64,48 @@ export function defineContextPlugin<D extends object>(
|
|
|
62
64
|
return Object.assign(apply, { pluginName: name }) as ContextPlugin<D>
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
declare const METHOD_ROUTES: unique symbol
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What `all()` and `method()` from `@nifrajs/core/methods` return: a plugin for `app.use()` that
|
|
71
|
+
* registers one handler under the methods `M` of `Path`. It is opaque - apply it with `use`, which
|
|
72
|
+
* reads the route's types from it and adds them to the app's registry.
|
|
73
|
+
*/
|
|
74
|
+
export interface MethodRoutesPlugin<
|
|
75
|
+
M extends string,
|
|
76
|
+
Path extends string,
|
|
77
|
+
S extends RouteSchema,
|
|
78
|
+
Output,
|
|
79
|
+
Ctx,
|
|
80
|
+
> {
|
|
81
|
+
readonly [METHOD_ROUTES]: {
|
|
82
|
+
readonly methods: M
|
|
83
|
+
readonly path: Path
|
|
84
|
+
readonly schema: S
|
|
85
|
+
readonly output: Output
|
|
86
|
+
/** The context the handler was written against; the app must provide at least this. */
|
|
87
|
+
readonly context: (context: Ctx) => void
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The standard methods among `M`, as the registry keys them. A custom token contributes none. */
|
|
92
|
+
type StandardMethodOf<M extends string> = Extract<Uppercase<M>, Method>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The registry after a {@link MethodRoutesPlugin} is applied: `Path` gains the standard methods
|
|
96
|
+
* among `M`. A method outside the standard set is served but not typed, so it adds nothing.
|
|
97
|
+
*/
|
|
98
|
+
export type WithMethodRoutes<
|
|
99
|
+
R extends Registry,
|
|
100
|
+
M extends string,
|
|
101
|
+
Path extends string,
|
|
102
|
+
S extends RouteSchema,
|
|
103
|
+
Output,
|
|
104
|
+
HookOutput,
|
|
105
|
+
> = [StandardMethodOf<M>] extends [never]
|
|
106
|
+
? R
|
|
107
|
+
: AddRoute<R, StandardMethodOf<M>, Path, RouteInfoFor<Path, S, Output, HookOutput>>
|
|
108
|
+
|
|
65
109
|
declare const COLLAPSED: unique symbol
|
|
66
110
|
|
|
67
111
|
/**
|
|
@@ -28,11 +28,12 @@ export function parseJsonGuarded(text: string, policy: ProtoPoisoning): unknown
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* The policy applied to an already-parsed value - the native-`json()` lane, which never holds
|
|
32
|
-
* raw text
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
31
|
+
* The policy applied to an already-parsed JSON value - the native-`json()` lane, which never holds
|
|
32
|
+
* the raw text. Escapes are resolved by the time a key is an own property, so a `\u`-spelled
|
|
33
|
+
* `__proto__` and a literal one look identical here. Cost is one iterative pass over the value's
|
|
34
|
+
* object nodes; `"ignore"` skips even that. The walk assumes a tree, which JSON always is; a
|
|
35
|
+
* transport codec's output goes through {@link guardDecodedValue}. Throws the reject singleton;
|
|
36
|
+
* callers map it to their lane's flat error.
|
|
36
37
|
*/
|
|
37
38
|
export function guardParsedValue(value: unknown, policy: ProtoPoisoning): unknown {
|
|
38
39
|
if (policy === "ignore") return value
|
|
@@ -81,10 +82,10 @@ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
|
|
|
81
82
|
// `for-in`, not `Object.keys()`: the keys array would be an allocation per node (~2x the whole
|
|
82
83
|
// walk on measured bodies, both engines, at realistic widths; V8 only prefers `Object.keys` on
|
|
83
84
|
// 200+-property dictionary-mode objects). On JSON.parse output the two enumerate identically -
|
|
84
|
-
// own enumerable string keys, nothing enumerable on the prototype chain.
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
85
|
+
// own enumerable string keys, nothing enumerable on the prototype chain. Were a value with
|
|
86
|
+
// enumerable INHERITED properties handed in, `for-in` would sweep those too, which only
|
|
87
|
+
// over-sweeps - a poisoned inherited subtree rejects rather than slips through. A
|
|
88
|
+
// *non-enumerable* own `__proto__` is the one shape this misses, and it is
|
|
88
89
|
// not the poisoning shape: `JSON.parse` never produces one, and the merges that carry the
|
|
89
90
|
// payload onward (spread, `Object.assign`, key loops) copy enumerable own keys only.
|
|
90
91
|
for (const key in record) {
|
|
@@ -116,3 +117,121 @@ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
|
|
|
116
117
|
}
|
|
117
118
|
return root
|
|
118
119
|
}
|
|
120
|
+
|
|
121
|
+
/** How many nodes shared references may add to a decoded value, counted as a tree walk visits it. */
|
|
122
|
+
const MAX_SHARED_EXPANSION = 100_000
|
|
123
|
+
|
|
124
|
+
/** Thrown (reused singleton, mapped like {@link POISONED}) for a decoded value no tree walk can finish,
|
|
125
|
+
* or one holding a pattern the caller did not allow. */
|
|
126
|
+
const NOT_A_TREE = /* @__PURE__ */ new Error("decoded_value_not_a_tree")
|
|
127
|
+
|
|
128
|
+
/** Marks a node whose subtree is still being walked: meeting it again then is a cycle. */
|
|
129
|
+
const ON_PATH = -1
|
|
130
|
+
|
|
131
|
+
interface DecodedFrame {
|
|
132
|
+
readonly node: object
|
|
133
|
+
readonly children: readonly object[]
|
|
134
|
+
next: number
|
|
135
|
+
size: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The policy for a value a transport codec decoded. Unlike `JSON.parse` output that can be a graph -
|
|
140
|
+
* a codec that keeps references, as the rich wire codec does, returns shared nodes and cycles - while
|
|
141
|
+
* everything downstream (validators, handlers, serializers) walks a body as a tree. So a cycle is
|
|
142
|
+
* refused, and shared nodes may add at most {@link MAX_SHARED_EXPANSION} nodes to the tree they expand
|
|
143
|
+
* to: forty levels of doubled references fit in 2KB and expand to 2^40 paths. Map and Set members are
|
|
144
|
+
* walked like properties. These shape rules hold under every policy, `"ignore"` included.
|
|
145
|
+
*
|
|
146
|
+
* A `RegExp` is refused unless `patterns` allows it: a pattern from the client is code, and one with
|
|
147
|
+
* catastrophic backtracking stalls the event loop the moment anything runs it.
|
|
148
|
+
*/
|
|
149
|
+
export function guardDecodedValue(
|
|
150
|
+
value: unknown,
|
|
151
|
+
policy: ProtoPoisoning,
|
|
152
|
+
patterns = false,
|
|
153
|
+
): unknown {
|
|
154
|
+
if (value === null || typeof value !== "object") return value
|
|
155
|
+
const sizes = new Map<object, number>()
|
|
156
|
+
const frames: DecodedFrame[] = [openDecoded(value, policy, patterns, sizes)]
|
|
157
|
+
let expanded = 0
|
|
158
|
+
for (let frame = frames.at(-1); frame !== undefined; frame = frames.at(-1)) {
|
|
159
|
+
const child = frame.children[frame.next]
|
|
160
|
+
if (child !== undefined) {
|
|
161
|
+
frame.next++
|
|
162
|
+
const known = sizes.get(child)
|
|
163
|
+
if (known === undefined) frames.push(openDecoded(child, policy, patterns, sizes))
|
|
164
|
+
else if (known === ON_PATH) throw NOT_A_TREE
|
|
165
|
+
else frame.size += known
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
frames.pop()
|
|
169
|
+
sizes.set(frame.node, frame.size)
|
|
170
|
+
const parent = frames.at(-1)
|
|
171
|
+
if (parent === undefined) expanded = frame.size
|
|
172
|
+
else parent.size += frame.size
|
|
173
|
+
}
|
|
174
|
+
if (expanded - sizes.size > MAX_SHARED_EXPANSION) throw NOT_A_TREE
|
|
175
|
+
return value
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function openDecoded(
|
|
179
|
+
node: object,
|
|
180
|
+
policy: ProtoPoisoning,
|
|
181
|
+
patterns: boolean,
|
|
182
|
+
sizes: Map<object, number>,
|
|
183
|
+
): DecodedFrame {
|
|
184
|
+
if (!patterns && node instanceof RegExp) throw NOT_A_TREE
|
|
185
|
+
sizes.set(node, ON_PATH)
|
|
186
|
+
return { node, children: decodedChildren(node, policy), next: 0, size: 1 }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function decodedChildren(node: object, policy: ProtoPoisoning): object[] {
|
|
190
|
+
const children: object[] = []
|
|
191
|
+
const add = (value: unknown): void => {
|
|
192
|
+
if (value !== null && typeof value === "object") children.push(value)
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(node)) {
|
|
195
|
+
for (let i = 0; i < node.length; i++) add(node[i])
|
|
196
|
+
return children
|
|
197
|
+
}
|
|
198
|
+
if (node instanceof Map) {
|
|
199
|
+
for (const [key, value] of node) {
|
|
200
|
+
add(key)
|
|
201
|
+
add(value)
|
|
202
|
+
}
|
|
203
|
+
return children
|
|
204
|
+
}
|
|
205
|
+
if (node instanceof Set) {
|
|
206
|
+
for (const item of node) add(item)
|
|
207
|
+
return children
|
|
208
|
+
}
|
|
209
|
+
// Binary and scalar wrappers carry no keys to poison; `for-in` over a typed array would enumerate
|
|
210
|
+
// every index.
|
|
211
|
+
if (
|
|
212
|
+
ArrayBuffer.isView(node) ||
|
|
213
|
+
node instanceof ArrayBuffer ||
|
|
214
|
+
node instanceof Date ||
|
|
215
|
+
node instanceof RegExp ||
|
|
216
|
+
node instanceof URL
|
|
217
|
+
) {
|
|
218
|
+
return children
|
|
219
|
+
}
|
|
220
|
+
for (const key in node) {
|
|
221
|
+
const value: unknown = Reflect.get(node, key)
|
|
222
|
+
if (
|
|
223
|
+
policy !== "ignore" &&
|
|
224
|
+
(key === "__proto__" ||
|
|
225
|
+
(key === "constructor" &&
|
|
226
|
+
value !== null &&
|
|
227
|
+
typeof value === "object" &&
|
|
228
|
+
Object.hasOwn(value, "prototype")))
|
|
229
|
+
) {
|
|
230
|
+
if (policy === "reject") throw POISONED
|
|
231
|
+
Reflect.deleteProperty(node, key)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
add(value)
|
|
235
|
+
}
|
|
236
|
+
return children
|
|
237
|
+
}
|
package/src/server/query.ts
CHANGED
|
@@ -110,6 +110,55 @@ export function queryObjectOf(search: string): Record<string, QueryValue> {
|
|
|
110
110
|
return out
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* `application/json` or an `application/*+json` type, with or without parameters, compared as a media
|
|
115
|
+
* type rather than searched as a substring. A substring test also matches `text/plain; x=application/json`,
|
|
116
|
+
* whose essence is `text/plain` - a type a browser sends cross-origin with credentials and no preflight,
|
|
117
|
+
* so a JSON route would parse a body no CORS policy ever saw.
|
|
118
|
+
*/
|
|
119
|
+
export function isJsonMediaType(contentType: string): boolean {
|
|
120
|
+
// The spellings common clients send, by exact match.
|
|
121
|
+
if (
|
|
122
|
+
contentType === "application/json" ||
|
|
123
|
+
contentType === "application/json; charset=utf-8" ||
|
|
124
|
+
contentType === "application/json;charset=UTF-8"
|
|
125
|
+
) {
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
// Every other lowercase `application/json` spelling, parameters or not, without slicing or lowercasing.
|
|
129
|
+
if (contentType.startsWith("application/json")) {
|
|
130
|
+
let delimiter = 16
|
|
131
|
+
while (delimiter < contentType.length) {
|
|
132
|
+
const char = contentType.charCodeAt(delimiter)
|
|
133
|
+
if (char !== 32 /* SP */ && char !== 9 /* HTAB */) break
|
|
134
|
+
delimiter++
|
|
135
|
+
}
|
|
136
|
+
if (delimiter === contentType.length || contentType.charCodeAt(delimiter) === 59 /* ; */) {
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Lowercase `application/*+json` spellings by the same rules as the parse below, without allocating.
|
|
141
|
+
if (contentType.startsWith("application/")) {
|
|
142
|
+
const params = contentType.indexOf(";", 12)
|
|
143
|
+
let end = params === -1 ? contentType.length : params
|
|
144
|
+
while (end > 12) {
|
|
145
|
+
const char = contentType.charCodeAt(end - 1)
|
|
146
|
+
if (char !== 32 /* SP */ && char !== 9 /* HTAB */) break
|
|
147
|
+
end--
|
|
148
|
+
}
|
|
149
|
+
if (end >= 17 && contentType.startsWith("+json", end - 5)) {
|
|
150
|
+
const space = contentType.indexOf(" ", 12)
|
|
151
|
+
if (space === -1 || space >= end) return true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const semi = contentType.indexOf(";")
|
|
155
|
+
const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
|
|
156
|
+
return (
|
|
157
|
+
essence === "application/json" ||
|
|
158
|
+
(essence.startsWith("application/") && essence.endsWith("+json") && !essence.includes(" "))
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
113
162
|
/** `application/x-www-form-urlencoded`, with or without a charset suffix. */
|
|
114
163
|
export function isUrlEncodedForm(contentType: string): boolean {
|
|
115
164
|
return (
|
package/src/server/registry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BinaryResponse, RawResponse } from "../binary.ts"
|
|
2
2
|
import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
|
|
3
|
-
import type { Params, RouteSchema } from "./context.ts"
|
|
3
|
+
import type { Params, RoutePaths, RouteSchema } from "./context.ts"
|
|
4
4
|
import type { StatusResponse } from "./runtime-core.ts"
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -196,10 +196,25 @@ export type WsRouteInfoFor<
|
|
|
196
196
|
* `.merge()` them (each group is a short chain; a merge is one `R & R2` intersection with no
|
|
197
197
|
* per-call context work), or contract-first `implement()` (the registry is one object type declared
|
|
198
198
|
* upfront - no grow-R-per-call, so no ceiling at all).
|
|
199
|
+
*
|
|
200
|
+
* A path ending in optional parameters is keyed once per concrete path it serves (`RoutePaths`), all
|
|
201
|
+
* sharing the one route info, so `/users/:id?` adds `/users` and `/users/:id`.
|
|
199
202
|
*/
|
|
200
203
|
export type AddRoute<
|
|
201
204
|
R extends Registry,
|
|
202
205
|
Method extends string,
|
|
203
206
|
Path extends string,
|
|
204
207
|
Info extends RouteInfo,
|
|
205
|
-
> = R & { [P in Path]: { [M in Method]: Info } }
|
|
208
|
+
> = R & { [P in RoutePaths<Path>]: { [M in Method]: Info } }
|
|
209
|
+
|
|
210
|
+
/** Join a group prefix and a route path the way `Server.group` does at runtime: a group's `/` route
|
|
211
|
+
* serves the prefix itself (no trailing slash), every other path is appended verbatim. */
|
|
212
|
+
export type JoinRoutePath<Prefix extends string, Path extends string> = Path extends "/"
|
|
213
|
+
? Prefix
|
|
214
|
+
: `${Prefix}${Path}`
|
|
215
|
+
|
|
216
|
+
/** Re-key a registry under a static path prefix. Route info (params, schemas, responses) is carried
|
|
217
|
+
* unchanged: a prefix is static text, so it adds no params and cannot change any route's contract. */
|
|
218
|
+
export type PrefixRegistry<Prefix extends string, R extends Registry> = {
|
|
219
|
+
[Path in keyof R & string as JoinRoutePath<Prefix, Path>]: R[Path]
|
|
220
|
+
}
|
|
@@ -157,6 +157,15 @@ function responseTextContentType(): string {
|
|
|
157
157
|
* passes no headers of their own. */
|
|
158
158
|
const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
159
159
|
|
|
160
|
+
/** Where a serving adapter that shares one platform across requests leaves its socket-peer lookup.
|
|
161
|
+
* `c.clientIp` calls it with the request when a handler reads the address, so a request whose
|
|
162
|
+
* handler never reads it costs the adapter neither a lookup nor a platform object of its own. */
|
|
163
|
+
export const PLATFORM_PEER = Symbol()
|
|
164
|
+
|
|
165
|
+
export type PeerPlatform = Platform & {
|
|
166
|
+
readonly [PLATFORM_PEER]?: (source: RequestSource) => string | undefined
|
|
167
|
+
}
|
|
168
|
+
|
|
160
169
|
export class RequestContext implements RawContext {
|
|
161
170
|
// `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
|
|
162
171
|
// constructor initializes only the eager request state, while lazy fields remain absent until used.
|
|
@@ -167,13 +176,16 @@ export class RequestContext implements RawContext {
|
|
|
167
176
|
private declare searchValue: string | undefined
|
|
168
177
|
private declare signalValue: AbortSignal | undefined
|
|
169
178
|
private declare budgetValue: RequestBudget | undefined
|
|
170
|
-
private declare platformValue:
|
|
179
|
+
private declare platformValue: PeerPlatform | undefined
|
|
180
|
+
private declare peerValue: string | undefined
|
|
171
181
|
|
|
172
182
|
private declare setValue: CtxSet | undefined
|
|
173
183
|
private declare queryValue: unknown
|
|
174
184
|
private declare queryReady: boolean
|
|
175
185
|
private declare headersValue: Record<string, string> | undefined
|
|
176
186
|
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
187
|
+
declare jsonReply: Response | undefined
|
|
188
|
+
declare jsonBody: unknown
|
|
177
189
|
private declare readonly source: RequestSource
|
|
178
190
|
private declare readonly maxBodyBytes: number
|
|
179
191
|
private declare readonly protoPoisoning: ProtoPoisoning
|
|
@@ -268,8 +280,12 @@ export class RequestContext implements RawContext {
|
|
|
268
280
|
|
|
269
281
|
get clientIp(): string | undefined {
|
|
270
282
|
// The server resolves the trust declaration into `platform.clientIp` before the context is built,
|
|
271
|
-
// so this getter just surfaces the already-derived value (raw socket peer by default).
|
|
272
|
-
|
|
283
|
+
// so this getter just surfaces the already-derived value (raw socket peer by default). A platform
|
|
284
|
+
// shared across requests holds no address of its own and is asked for this request's peer, once:
|
|
285
|
+
// the answer is kept, so a read after the socket closed agrees with one before it.
|
|
286
|
+
const platform = this.platformValue
|
|
287
|
+
this.peerValue ??= platform?.clientIp ?? platform?.[PLATFORM_PEER]?.(this.source)
|
|
288
|
+
return this.peerValue
|
|
273
289
|
}
|
|
274
290
|
|
|
275
291
|
get waitUntil(): (promise: Promise<unknown>) => void {
|
|
@@ -312,19 +328,27 @@ export class RequestContext implements RawContext {
|
|
|
312
328
|
*/
|
|
313
329
|
json(body: unknown, init?: ResponseInit | number): Response {
|
|
314
330
|
const i = statusInit(init)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
331
|
+
let reply: Response | undefined
|
|
332
|
+
if (DEFERS_RESPONSE) {
|
|
333
|
+
const defer = deferredResponderFor()
|
|
334
|
+
// The same bytes `Response.json` would produce, kept one step short of it so the direct
|
|
335
|
+
// writer can have them. `JSON.stringify` returns `undefined` for a value with no JSON form
|
|
336
|
+
// (`undefined`, a function, a symbol) - exactly the case where `Response.json` throws, so it
|
|
337
|
+
// gets the throw rather than an invented body. The content-type is the runtime's own, read
|
|
338
|
+
// off `Response.json` once, so a deferred response carries the same one byte for byte.
|
|
339
|
+
const text = defer === undefined ? undefined : (JSON.stringify(body) as string | undefined)
|
|
340
|
+
const headers =
|
|
341
|
+
text === undefined ? undefined : ownHeaderRecord(i?.headers, responseJsonContentType())
|
|
342
|
+
if (defer !== undefined && text !== undefined && headers !== undefined) {
|
|
343
|
+
reply = defer(text, i?.status ?? 200, headers)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
reply ??= Response.json(body, i)
|
|
347
|
+
// Remembered so a response contract holds the value to the route's schema: this is the
|
|
348
|
+
// framework's JSON helper, not a raw-Response escape hatch.
|
|
349
|
+
this.jsonBody = body
|
|
350
|
+
this.jsonReply = reply
|
|
351
|
+
return reply
|
|
328
352
|
}
|
|
329
353
|
|
|
330
354
|
/**
|
|
@@ -370,6 +394,10 @@ export class RequestContext implements RawContext {
|
|
|
370
394
|
return this.cookiesValue
|
|
371
395
|
}
|
|
372
396
|
|
|
397
|
+
set cookies(value: Readonly<Record<string, string>>) {
|
|
398
|
+
this.cookiesValue = value
|
|
399
|
+
}
|
|
400
|
+
|
|
373
401
|
boundedBody(maxBytes?: number): Promise<Uint8Array> {
|
|
374
402
|
return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
|
|
375
403
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Each request an `onRequest` hook returned in place of the one it was handed, mapped to that one. */
|
|
2
|
+
const REPLACED = new WeakMap<Request, Request>()
|
|
3
|
+
|
|
4
|
+
export function recordRequestReplacement(replacement: Request, replaced: Request): void {
|
|
5
|
+
REPLACED.set(replacement, replaced)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The request an `onRequest` hook returned this one in place of, or `undefined` for the request the
|
|
10
|
+
* exchange began with. Response hooks receive the request the route ran with, so a middleware that
|
|
11
|
+
* keyed state on the request its `onRequest` saw walks back from there to find it.
|
|
12
|
+
*/
|
|
13
|
+
export function replacedRequestOf(request: Request): Request | undefined {
|
|
14
|
+
return REPLACED.get(request)
|
|
15
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dot segments in a request target, resolved the way a WHATWG URL parser resolves them.
|
|
3
|
+
*
|
|
4
|
+
* Bun and workerd hand an app a request whose URL is already parsed: a client that sends
|
|
5
|
+
* `/users/../posts`, `/users/%2e%2e/posts` or `/users/..\posts` is asking for `/posts`, and that
|
|
6
|
+
* is the URL the app sees. Node's `IncomingMessage.url` and Deno's `Request.url` carry the target
|
|
7
|
+
* exactly as it was sent. Routing the raw text on those runtimes and the parsed text on the others
|
|
8
|
+
* sends one request to two different handlers - with two different sets of guards - depending on
|
|
9
|
+
* where the app runs. So a raw target is resolved before anything reads it, and every runtime routes
|
|
10
|
+
* the same path.
|
|
11
|
+
*
|
|
12
|
+
* No imports: `@nifrajs/node` carries a generated copy of this module, since it never depends on
|
|
13
|
+
* core at runtime.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// A `.` or `..` segment (`%2e` counts as a dot, in either case), or a backslash, which a WHATWG
|
|
17
|
+
// parser reads as a `/` in an http(s) path.
|
|
18
|
+
const DOT_SEGMENT = /\\|\/(?:\.|%2e){1,2}(?:[/?#]|$)/i
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether `target` - an absolute URL or an origin-form `/path?query` - may hold a `.` or `..`
|
|
22
|
+
* segment, raw or percent-encoded, or a backslash: what a WHATWG parser would rewrite. Never false
|
|
23
|
+
* when the path has one. It can be true when only the query looks like one (`?next=/../x`):
|
|
24
|
+
* resolving that target leaves its path and query as they are, apart from the characters a parser
|
|
25
|
+
* always percent-encodes.
|
|
26
|
+
*/
|
|
27
|
+
export function hasDotSegment(target: string): boolean {
|
|
28
|
+
// One regex pass: measured faster on Node and Deno than substring scans that gate it.
|
|
29
|
+
return DOT_SEGMENT.test(target)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `target` with its dot segments resolved and its backslashes read as `/`, by the platform's own
|
|
34
|
+
* WHATWG parser. An origin-form target stays origin-form (the fragment, which no client sends,
|
|
35
|
+
* dropped); an absolute URL comes back absolute. The parser also percent-encodes what it always
|
|
36
|
+
* encodes, which is what Bun and workerd deliver for the same request. A target that is neither -
|
|
37
|
+
* `*`, or text no URL parser accepts - comes back as it arrived: there is no path to resolve.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveDotSegments(target: string): string {
|
|
40
|
+
if (target.charCodeAt(0) === 47 /* / */) {
|
|
41
|
+
// Appended to an origin, not resolved against one: `//host/..` must stay a path on this origin.
|
|
42
|
+
const url = new URL(`http://localhost${target}`)
|
|
43
|
+
return url.pathname + url.search
|
|
44
|
+
}
|
|
45
|
+
return URL.canParse(target) ? new URL(target).href : target
|
|
46
|
+
}
|
package/src/server/respond.ts
CHANGED
|
@@ -52,6 +52,7 @@ const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
|
52
52
|
*/
|
|
53
53
|
export function buildStaticResponseHeaders(
|
|
54
54
|
record: Readonly<Record<string, string>>,
|
|
55
|
+
inherited?: Readonly<Record<string, string>>,
|
|
55
56
|
): StaticResponseHeaders {
|
|
56
57
|
const frozen = Object.freeze({ ...record })
|
|
57
58
|
const jsonHeaders = new Headers({ ...frozen, "content-type": JSON_CONTENT_TYPE })
|
|
@@ -61,6 +62,7 @@ export function buildStaticResponseHeaders(
|
|
|
61
62
|
entries: Object.freeze(Object.entries(frozen)),
|
|
62
63
|
jsonHeaders,
|
|
63
64
|
jsonInit200: { status: 200, headers: jsonHeaders },
|
|
65
|
+
inherited,
|
|
64
66
|
responseJsonInit200: () => {
|
|
65
67
|
responseJson ??= {
|
|
66
68
|
status: 200,
|
|
@@ -138,7 +140,9 @@ function applyStaticDefaults(headers: Headers, statics: StaticResponseHeaders):
|
|
|
138
140
|
const entries = statics.entries
|
|
139
141
|
for (let i = 0; i < entries.length; i++) {
|
|
140
142
|
const entry = entries[i]!
|
|
141
|
-
|
|
143
|
+
// Absent, or still an enclosing scope's value rather than one the request set: this one applies.
|
|
144
|
+
if (headers.get(entry[0]) === (statics.inherited?.[entry[0]] ?? null))
|
|
145
|
+
headers.set(entry[0], entry[1])
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -555,8 +559,15 @@ export function appendCookiesToResponse(response: Response, set: CtxSet): Respon
|
|
|
555
559
|
// returned Response - otherwise the canonical set-session-then-redirect pattern would silently drop
|
|
556
560
|
// the cookie. (Other `c.set` fields stay the returned Response's own concern.)
|
|
557
561
|
const cookies = set._cookies
|
|
558
|
-
if (cookies
|
|
562
|
+
if (cookies === undefined || cookies.length === 0) return response
|
|
563
|
+
try {
|
|
559
564
|
for (const cookie of cookies) response.headers.append("set-cookie", cookie)
|
|
565
|
+
return response
|
|
566
|
+
} catch {
|
|
567
|
+
// Guarded `Headers` (`Response.redirect()`, a raw `fetch()` result) reject every write, so the
|
|
568
|
+
// first append throws with nothing applied and the cookies land on a mutable copy instead.
|
|
569
|
+
const clone = new Response(response.body, response)
|
|
570
|
+
for (const cookie of cookies) clone.headers.append("set-cookie", cookie)
|
|
571
|
+
return stamped(clone)
|
|
560
572
|
}
|
|
561
|
-
return response
|
|
562
573
|
}
|