@nifrajs/core 2.10.0 → 2.12.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/dist/assurance.d.ts +61 -3
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +152 -5
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +9 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +5 -1
- 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 +3 -1
- package/dist/diff.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 +89 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +170 -0
- package/dist/evidence.js.map +1 -0
- package/dist/execution-policy.d.ts +23 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +28 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-assurance.d.ts +19 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +38 -5
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-execution.d.ts +18 -0
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +67 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +22 -4
- package/dist/manifest.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +13 -4
- package/dist/reflection.js.map +1 -1
- package/dist/replay.d.ts +26 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +63 -0
- package/dist/replay.js.map +1 -0
- package/dist/schema/standard.d.ts +2 -0
- package/dist/schema/standard.d.ts.map +1 -1
- package/dist/schema/standard.js +13 -0
- package/dist/schema/standard.js.map +1 -1
- package/dist/server/body.d.ts +49 -6
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +226 -8
- package/dist/server/body.js.map +1 -1
- package/dist/server/context.d.ts +20 -3
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -3
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +22 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +26 -5
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +76 -9
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +11 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/header-case.d.ts +9 -0
- package/dist/server/header-case.d.ts.map +1 -0
- package/dist/server/header-case.js +54 -0
- package/dist/server/header-case.js.map +1 -0
- package/dist/server/headers.d.ts +8 -0
- package/dist/server/headers.d.ts.map +1 -0
- package/dist/server/headers.js +13 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +7 -11
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +8 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +10 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +66 -7
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +14 -10
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +30 -0
- package/dist/server/proto-guard.d.ts.map +1 -0
- package/dist/server/proto-guard.js +116 -0
- package/dist/server/proto-guard.js.map +1 -0
- package/dist/server/registry.d.ts +6 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +24 -6
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +157 -30
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -2
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +34 -8
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +45 -13
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-hooks.d.ts +10 -0
- package/dist/server/response-hooks.d.ts.map +1 -0
- package/dist/server/response-hooks.js +52 -0
- package/dist/server/response-hooks.js.map +1 -0
- package/dist/server/runtime-core.d.ts +12 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +60 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +80 -23
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +424 -213
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +14 -3
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +28 -6
- 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 +12 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tool-catalog.d.ts +21 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +16 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/tool-contract.d.ts +238 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +569 -0
- package/dist/tool-contract.js.map +1 -0
- package/package.json +26 -1
- package/src/assurance.ts +236 -6
- package/src/capabilities.ts +14 -1
- package/src/diff.ts +5 -2
- package/src/errors.ts +1 -0
- package/src/evidence.ts +319 -0
- package/src/execution-policy.ts +57 -0
- package/src/index.ts +1 -1
- package/src/internal/route-assurance.ts +67 -4
- package/src/internal/route-execution.ts +276 -1
- package/src/manifest.ts +27 -5
- package/src/reflection.ts +24 -4
- package/src/replay.ts +96 -0
- package/src/schema/standard.ts +16 -0
- package/src/server/body.ts +275 -7
- package/src/server/context.ts +23 -3
- package/src/server/contract.ts +43 -3
- package/src/server/cookies.ts +80 -10
- package/src/server/edge.ts +10 -0
- package/src/server/header-case.ts +54 -0
- package/src/server/headers.ts +11 -0
- package/src/server/node-outcome-hook.ts +6 -11
- package/src/server/node-outcome.ts +10 -2
- package/src/server/plugin.ts +87 -8
- package/src/server/proto-guard.ts +118 -0
- package/src/server/registry.ts +6 -2
- package/src/server/request-context.ts +242 -26
- package/src/server/respond.ts +35 -4
- package/src/server/response-contract-lane.ts +6 -17
- package/src/server/response-hooks.ts +51 -0
- package/src/server/runtime-core.ts +14 -0
- package/src/server/server-types.ts +66 -6
- package/src/server/server.ts +667 -287
- package/src/server/static-headers.ts +27 -3
- package/src/server/transport-lane.ts +19 -8
- package/src/server/websocket.ts +18 -2
- package/src/server.ts +7 -0
- package/src/tool-catalog.ts +40 -0
- package/src/tool-contract.ts +896 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The all-lowercase header-name proof: one answer, computed once per request, read by everyone who
|
|
3
|
+
* would otherwise re-derive it.
|
|
4
|
+
*
|
|
5
|
+
* "Are these header names already the lowercase wire spelling?" is asked on every Node-direct
|
|
6
|
+
* response by the implicit-Content-Type lookup in the native response walk, by the portable header
|
|
7
|
+
* view's alias index, and by the `@nifrajs/node` writer's normalization gate. Each used to answer it
|
|
8
|
+
* by walking the same keys. Instead, whichever stage has ALREADY looked at the names - the static
|
|
9
|
+
* merge, which lowercases every own key anyway, or the native walk - records the answer on the
|
|
10
|
+
* record, and the readers take it.
|
|
11
|
+
*
|
|
12
|
+
* The mark is a plain enumerable symbol assignment on purpose. `Object.keys`, `Object.entries` and
|
|
13
|
+
* `for...in` never see symbol keys, so it cannot reach the wire, while a non-enumerable
|
|
14
|
+
* `defineProperty` would demote the record to V8's dictionary mode - the exact cost the record's `{}`
|
|
15
|
+
* literal shape exists to avoid. Spread and `Object.assign` DO copy it, which is what the clone paths
|
|
16
|
+
* want: a clone of an all-lowercase record is still all-lowercase.
|
|
17
|
+
*
|
|
18
|
+
* It is only ever SET where its truth is provable, and only where every writer that can still touch
|
|
19
|
+
* the record afterwards is case-normalizing. An app registering a raw `onNodeResponse` twin - handed
|
|
20
|
+
* the record itself rather than the case-normalizing view - is left unmarked, and every reader
|
|
21
|
+
* re-derives the answer exactly as it did before the mark existed.
|
|
22
|
+
*
|
|
23
|
+
* Declared with `Symbol.for` so the `@nifrajs/node` adapter can read it by key rather than importing
|
|
24
|
+
* across the package boundary - the same cross-package convention as the body and result marks.
|
|
25
|
+
*/
|
|
26
|
+
const LOWERCASE_HEADER_KEYS = Symbol.for("nifra.headers.lowercase")
|
|
27
|
+
|
|
28
|
+
/** True when the name contains an ASCII `A`-`Z`, i.e. is not already the wire spelling. */
|
|
29
|
+
export function hasUpperAscii(name: string): boolean {
|
|
30
|
+
for (let i = 0; i < name.length; i++) {
|
|
31
|
+
const code = name.charCodeAt(i)
|
|
32
|
+
if (code >= 65 && code <= 90) return true
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when every name in the record is already the lowercase wire spelling. */
|
|
38
|
+
export function headerKeysAllLowercase(record: Readonly<Record<string, unknown>>): boolean {
|
|
39
|
+
const keys = Object.keys(record)
|
|
40
|
+
for (let i = 0; i < keys.length; i++) {
|
|
41
|
+
if (hasUpperAscii(keys[i] as string)) return false
|
|
42
|
+
}
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Record the {@link LOWERCASE_HEADER_KEYS} proof on a record the caller has just verified. */
|
|
47
|
+
export function markLowercaseHeaderKeys(record: Record<string, string | readonly string[]>): void {
|
|
48
|
+
;(record as Record<symbol, unknown>)[LOWERCASE_HEADER_KEYS] = true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when this record carries the proof - never a claim the reader has to re-check. */
|
|
52
|
+
export function hasLowercaseHeaderKeysMark(record: Readonly<Record<string, unknown>>): boolean {
|
|
53
|
+
return (record as Record<symbol, unknown>)[LOWERCASE_HEADER_KEYS] === true
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Materialize request headers for a Standard Schema boundary.
|
|
2
|
+
*
|
|
3
|
+
* HTTP field names are case-insensitive and `Headers` already combines repeated fields using the
|
|
4
|
+
* platform's defined comma-join behavior. A null-prototype record prevents a hostile field name
|
|
5
|
+
* such as `__proto__` or `constructor` from interacting with Object.prototype before validation.
|
|
6
|
+
*/
|
|
7
|
+
export function headerObjectOf(headers: Headers): Record<string, string> {
|
|
8
|
+
const out: Record<string, string> = Object.create(null) as Record<string, string>
|
|
9
|
+
for (const [name, value] of headers) out[name.toLowerCase()] = value
|
|
10
|
+
return out
|
|
11
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Lazy Node-direct renderer seam. The base server retains the typed method but not its renderer. */
|
|
2
2
|
|
|
3
3
|
import type { Platform } from "./context.ts"
|
|
4
|
+
import { hasLowercaseHeaderKeysMark, hasUpperAscii } from "./header-case.ts"
|
|
4
5
|
import type { NodeServeOutcome } from "./node-outcome.ts"
|
|
5
6
|
import type { CtxSet, MaybePromise } from "./server.ts"
|
|
6
7
|
|
|
@@ -126,6 +127,9 @@ class RecordHeadersView implements ResponseHeadersView {
|
|
|
126
127
|
|
|
127
128
|
#prepare(record: Record<string, string | readonly string[]>): void {
|
|
128
129
|
this.#prepared = true
|
|
130
|
+
// The native response walk proved this record's names before any twin ran (and every write this
|
|
131
|
+
// view makes stores the wire spelling), so the index is known-empty without touching a key.
|
|
132
|
+
if (hasLowercaseHeaderKeysMark(record)) return
|
|
129
133
|
// `Object.keys`, not `for...in`: the record is a `{}`-prototype literal on the render paths, and
|
|
130
134
|
// a for-in would let an enumerable `Object.prototype` property become a header on the wire.
|
|
131
135
|
const keys = Object.keys(record)
|
|
@@ -136,24 +140,15 @@ class RecordHeadersView implements ResponseHeadersView {
|
|
|
136
140
|
// framework, `c.set`, and middleware twins all write the wire spelling. An inline ASCII scan
|
|
137
141
|
// answers the common case without leaving the loop, and only a genuinely mixed-case key (a
|
|
138
142
|
// hand-written `X-Foo`) pays for the lowercase copy and the alias entry.
|
|
139
|
-
if (!
|
|
143
|
+
if (!hasUpperAscii(key)) continue
|
|
140
144
|
if (this.#alias === undefined) this.#alias = new Map()
|
|
141
145
|
this.#alias.set(key.toLowerCase(), key)
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
145
|
-
/** True when the name contains an ASCII `A`-`Z`, i.e. is not already the wire spelling. */
|
|
146
|
-
static #hasUpperAscii(name: string): boolean {
|
|
147
|
-
for (let i = 0; i < name.length; i++) {
|
|
148
|
-
const code = name.charCodeAt(i)
|
|
149
|
-
if (code >= 65 && code <= 90) return true
|
|
150
|
-
}
|
|
151
|
-
return false
|
|
152
|
-
}
|
|
153
|
-
|
|
154
149
|
/** The wire spelling of a header name, without paying `toLowerCase` when it already is one. */
|
|
155
150
|
static #lower(name: string): string {
|
|
156
|
-
return
|
|
151
|
+
return hasUpperAscii(name) ? name.toLowerCase() : name
|
|
157
152
|
}
|
|
158
153
|
|
|
159
154
|
/** The stored key for an already-lowercased name. Resolution is the prepared index ONLY - a
|
|
@@ -138,10 +138,18 @@ export function nodeOutcomeToResponse(outcome: NodeServeOutcome): Response {
|
|
|
138
138
|
* The record handed over is always a fresh copy: the Node writers mutate the outcome's record in
|
|
139
139
|
* place (content-type, content-length, cookies), and the static record is shared by every request.
|
|
140
140
|
* Its names are already lowercase, so the writer's all-lowercase fast path still holds.
|
|
141
|
+
*
|
|
142
|
+
* `markLowercase` publishes that fast path as a proof on the record instead of leaving each reader to
|
|
143
|
+
* re-derive it. This stage can answer it for free - the static names were lowercased at registration
|
|
144
|
+
* and the merge lowercases every own name anyway - and it runs BEFORE the response hooks, so it
|
|
145
|
+
* covers the hookless lane too, which never reaches the native walk that would otherwise mark. The
|
|
146
|
+
* caller passes `false` for an app carrying a raw `onNodeResponse` twin: that twin writes the record
|
|
147
|
+
* directly, past the case-normalizing view, after this point.
|
|
141
148
|
*/
|
|
142
149
|
export function withStaticNodeHeaders(
|
|
143
150
|
outcome: NodeServeOutcome,
|
|
144
151
|
statics: StaticResponseHeaders,
|
|
152
|
+
markLowercase = false,
|
|
145
153
|
): NodeServeOutcome {
|
|
146
154
|
if (outcome.kind === "response") {
|
|
147
155
|
return { kind: "response", response: applyStaticResponseHeaders(outcome.response, statics) }
|
|
@@ -149,8 +157,8 @@ export function withStaticNodeHeaders(
|
|
|
149
157
|
const own = outcome.headers
|
|
150
158
|
const headers =
|
|
151
159
|
own === undefined
|
|
152
|
-
? staticHeaderRecordCopy(statics)
|
|
153
|
-
: mergeStaticHeaderRecord(statics.record, own)
|
|
160
|
+
? staticHeaderRecordCopy(statics, markLowercase)
|
|
161
|
+
: mergeStaticHeaderRecord(statics.record, own, markLowercase)
|
|
154
162
|
return { ...outcome, headers }
|
|
155
163
|
}
|
|
156
164
|
|
package/src/server/plugin.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
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 { Registry } from "./registry.ts"
|
|
7
|
+
import type { AnyServer, Server } from "./server.ts"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* A nifra **plugin**: a function that augments an app - calling `use`/`derive`/`decorate` and/or
|
|
@@ -26,26 +27,104 @@ export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
|
|
|
26
27
|
readonly pluginName?: string
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A named plugin built with {@link defineContextPlugin}: it adds the context `D` to every handler
|
|
32
|
+
* downstream while threading the caller's route registry and existing context UNCHANGED. This is the
|
|
33
|
+
* type {@link definePlugin} cannot express - `definePlugin` infers its parameter from an untyped
|
|
34
|
+
* `(app) => ...` arrow, which lands on `AnyServer`, so `use` returns `Server<any, any>`.
|
|
35
|
+
*/
|
|
36
|
+
export type ContextPlugin<D extends object> = (<R extends Registry, Ctx>(
|
|
37
|
+
app: Server<R, Ctx>,
|
|
38
|
+
) => Server<R, Ctx & D>) & { readonly pluginName?: string }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Define a named plugin that **adds typed context and nothing else** - the `derive` case - without
|
|
42
|
+
* collapsing the caller's types. `use` instantiates the generic signature against the concrete
|
|
43
|
+
* receiver, so `R` and `Ctx` come back unchanged with `D` intersected onto the context.
|
|
44
|
+
*
|
|
45
|
+
* Use this instead of {@link definePlugin} for every `(app) => app.derive(...)` plugin. `definePlugin`
|
|
46
|
+
* is only safe when the caller supplies `In`/`Out` explicitly; with the usual untyped arrow both
|
|
47
|
+
* default to `AnyServer` and the typed client silently collapses to `any`.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* export const requestId = defineContextPlugin<{ requestId: string }>("requestId", (app) =>
|
|
51
|
+
* app.derive(() => ({ requestId: crypto.randomUUID() })),
|
|
52
|
+
* )
|
|
53
|
+
* const api = server().get("/a", h).use(requestId).get("/b", h) // /a AND /b stay typed; c.requestId typed
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function defineContextPlugin<D extends object>(
|
|
57
|
+
name: string,
|
|
58
|
+
apply: <R extends Registry, Ctx>(app: Server<R, Ctx>) => Server<R, Ctx & D>,
|
|
59
|
+
): ContextPlugin<D> {
|
|
60
|
+
return Object.assign(apply, { pluginName: name }) as ContextPlugin<D>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
declare const COLLAPSED: unique symbol
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What {@link definePlugin} returns when its `apply` argument never pinned the input server type -
|
|
67
|
+
* the `definePlugin("x", (app) => ...)` arrow, where `app` falls back to `AnyServer`.
|
|
68
|
+
*
|
|
69
|
+
* It is deliberately **not callable**, so `app.use(thatPlugin)` is a type error at the call site
|
|
70
|
+
* instead of a silent widening to `Server<any, any>` that surfaces hundreds of lines away as
|
|
71
|
+
* `Property 'x' does not exist on type 'Promise<Result<unknown, unknown>>'` in the typed client.
|
|
72
|
+
*
|
|
73
|
+
* The plugin still works at runtime. To fix the type, pick the definer that matches what it does:
|
|
74
|
+
*
|
|
75
|
+
* - adds context via `derive`/`decorate` -> {@link defineContextPlugin}
|
|
76
|
+
* - mounts routes/hooks and adds NO context -> {@link defineRouterPlugin} ({@link defineIdentityPlugin})
|
|
77
|
+
* - genuinely needs `definePlugin`: annotate the parameter (`(app: typeof api) => ...`) or pass
|
|
78
|
+
* explicit type arguments (`definePlugin<typeof api, typeof api>(...)`).
|
|
79
|
+
*/
|
|
80
|
+
export interface PluginTypeCollapsed {
|
|
81
|
+
readonly [COLLAPSED]: "definePlugin erased the caller's server type: use defineContextPlugin (adds context) or defineRouterPlugin (routes/hooks only), or pin the input type"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* True when `S`'s route registry is `any` - i.e. nothing pinned the plugin's input server type, so
|
|
86
|
+
* every route type flowing through it is about to be widened away.
|
|
87
|
+
*/
|
|
88
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches any Server shape to inspect its Registry alone
|
|
89
|
+
type ServerTypeUnpinned<S> = [S] extends [Server<infer R, any>]
|
|
90
|
+
? 0 extends 1 & R
|
|
91
|
+
? true
|
|
92
|
+
: false
|
|
93
|
+
: false
|
|
94
|
+
|
|
95
|
+
export type DefinePluginResult<In extends AnyServer, Out extends AnyServer> =
|
|
96
|
+
ServerTypeUnpinned<In> extends true ? PluginTypeCollapsed : NifraPlugin<In, Out>
|
|
97
|
+
|
|
29
98
|
/**
|
|
30
99
|
* Name + ergonomics for a plugin that **adds typed context** (`derive`/`decorate`). `app.use(myPlugin)`
|
|
31
100
|
* applies it once; a second `use` of the same name is skipped (idempotent), so plugins can depend on each
|
|
32
101
|
* other without double-registering hooks.
|
|
33
102
|
*
|
|
34
103
|
* ```ts
|
|
35
|
-
* export const requestId = definePlugin("requestId", (app
|
|
104
|
+
* export const requestId = definePlugin("requestId", (app: typeof api) =>
|
|
105
|
+
* app.derive(() => ({ requestId: uuid() })),
|
|
106
|
+
* )
|
|
36
107
|
* app.use(requestId) // downstream handlers see c.requestId
|
|
37
108
|
* ```
|
|
38
109
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* `
|
|
42
|
-
*
|
|
110
|
+
* This helper only threads types when the caller pins the input server type - by annotating the
|
|
111
|
+
* parameter as above, or with explicit type arguments. With a bare `(app) => ...` arrow `app` infers as
|
|
112
|
+
* `AnyServer`, which would widen the caller's whole route registry to `any`; that case now returns
|
|
113
|
+
* {@link PluginTypeCollapsed}, so `.use()` fails to compile with one error at the call site rather than
|
|
114
|
+
* producing a typed client full of `any`. Prefer a definer that cannot collapse at all:
|
|
115
|
+
*
|
|
116
|
+
* - adds context via `derive`/`decorate` -> {@link defineContextPlugin}
|
|
117
|
+
* - mounts routes/hooks and adds NO context -> {@link defineRouterPlugin} ({@link defineIdentityPlugin})
|
|
43
118
|
*/
|
|
44
119
|
export function definePlugin<In extends AnyServer, Out extends AnyServer>(
|
|
45
120
|
name: string,
|
|
46
121
|
apply: (app: In) => Out,
|
|
47
|
-
):
|
|
48
|
-
|
|
122
|
+
): DefinePluginResult<In, Out>
|
|
123
|
+
export function definePlugin(
|
|
124
|
+
name: string,
|
|
125
|
+
apply: (app: AnyServer) => AnyServer,
|
|
126
|
+
): NifraPlugin<AnyServer, AnyServer> {
|
|
127
|
+
return Object.assign(apply, { pluginName: name })
|
|
49
128
|
}
|
|
50
129
|
|
|
51
130
|
/**
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prototype-poisoning guard for the JSON body lane - the check behind `c.boundedJson` and the
|
|
3
|
+
* schema path. A single walk of the parsed value, never a reviver (a reviver taxes every key of
|
|
4
|
+
* every parse, including the parses that carry no object at all).
|
|
5
|
+
*
|
|
6
|
+
* No raw-text pre-scan: the Fastify-style `text.includes('"__proto__"')` filter is a pessimization
|
|
7
|
+
* on 2026 engines. A quoted-name search runs a general substring algorithm over the whole body,
|
|
8
|
+
* while the walk only touches the nodes the parse already built. Measured on Node 26 and Bun 1.3,
|
|
9
|
+
* three `includes()` calls cost 2-4x the walk on every body shape an API actually receives
|
|
10
|
+
* (records, strings, nested objects); the scan only wins on a body that is mostly a flat array of
|
|
11
|
+
* numbers, where it saves ~1.5us on a 9KB payload that spends 20us in `JSON.parse` regardless.
|
|
12
|
+
* Dropping the tier also removes an escape-analysis obligation - the scan had to route every `\u`
|
|
13
|
+
* to the walk, because `_` spells `_`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ProtoPoisoning = "reject" | "strip" | "ignore"
|
|
17
|
+
|
|
18
|
+
/** Thrown (as a reused singleton - never surfaced, always mapped to a flat 400) on `"reject"`. */
|
|
19
|
+
const POISONED = new Error("json_proto_poisoning")
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `JSON.parse` + the poisoning policy. Returns the parsed value (stripped in place under
|
|
23
|
+
* `"strip"`); throws on invalid JSON or - via the same catch path - on a rejected poisoning, so
|
|
24
|
+
* a poisoned payload is indistinguishable from malformed JSON to the caller.
|
|
25
|
+
*/
|
|
26
|
+
export function parseJsonGuarded(text: string, policy: ProtoPoisoning): unknown {
|
|
27
|
+
return guardParsedValue(JSON.parse(text), policy)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The policy applied to an already-parsed value - the native-`json()` lane, which never holds the
|
|
32
|
+
* raw text, and the transport codecs, which parse with their own decoder. Escapes are resolved by
|
|
33
|
+
* the time a key is an own property, so a `\u`-spelled `__proto__` and a literal one look identical
|
|
34
|
+
* here. Cost is one iterative pass over the value's object nodes; `"ignore"` skips even that.
|
|
35
|
+
* Throws the reject singleton; callers map it to their lane's flat error.
|
|
36
|
+
*/
|
|
37
|
+
export function guardParsedValue(value: unknown, policy: ProtoPoisoning): unknown {
|
|
38
|
+
if (policy === "ignore") return value
|
|
39
|
+
return sweep(value, policy)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Iterative deep-walk (adversarial nesting must not blow the call stack). An own key
|
|
44
|
+
* `__proto__`, or an own `constructor` whose value carries an own `prototype`, is the poisoning
|
|
45
|
+
* shape - `JSON.parse` creates these as plain data properties, and the blast radius is whatever
|
|
46
|
+
* downstream merge/assign later copies them onto a real prototype. A string *value* of
|
|
47
|
+
* `"__proto__"` is legal data and never triggers.
|
|
48
|
+
*/
|
|
49
|
+
/** Reused walk stack - `sweep` is synchronous and single-threaded, and a thrown rejection can
|
|
50
|
+
* leave residue, so each entry clears it. Saves one array allocation per swept request. */
|
|
51
|
+
const STACK: unknown[] = []
|
|
52
|
+
|
|
53
|
+
function sweep(root: unknown, policy: "reject" | "strip"): unknown {
|
|
54
|
+
if (root === null || typeof root !== "object") return root
|
|
55
|
+
const stack = STACK
|
|
56
|
+
stack.length = 0
|
|
57
|
+
stack.push(root)
|
|
58
|
+
while (stack.length > 0) {
|
|
59
|
+
const node = stack.pop() as object
|
|
60
|
+
if (Array.isArray(node)) {
|
|
61
|
+
// Only objects are stacked, here and below: a scalar carries no keys, so pushing it just to
|
|
62
|
+
// pop and type-test it is pure stack traffic. Filtering at the push site measured 1.5-3x
|
|
63
|
+
// faster on every body shape, both engines - the win grows with how scalar-heavy the body is.
|
|
64
|
+
//
|
|
65
|
+
// Indexed, not `for...of`: JSC allocates an array iterator per loop and calls `next()` per
|
|
66
|
+
// element, and it does not escape either. Measured on Bun 1.3 the iterator form costs 4x on
|
|
67
|
+
// an array of records, 8x on strings, and 12x on numbers - a 9KB numeric body walked in 27us
|
|
68
|
+
// instead of 2.2us, which at the 1MB default cap is milliseconds of CPU an attacker picks.
|
|
69
|
+
// V8 optimizes the iterator away, so on Node the two forms measure identical; the indexed
|
|
70
|
+
// loop is simply the form that is fast on both.
|
|
71
|
+
for (let i = 0; i < node.length; i++) {
|
|
72
|
+
const item = node[i]
|
|
73
|
+
if (item !== null && typeof item === "object") stack.push(item)
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const record = node as Record<string, unknown>
|
|
78
|
+
// One pass, not two `Object.hasOwn` probes plus a walk: the suspect names are checked against
|
|
79
|
+
// the keys the walk already enumerates, so a clean node pays two pointer-comparisons per key
|
|
80
|
+
// instead of two hash lookups per node (measured ~30ns/request cheaper on a typical API body).
|
|
81
|
+
// `for-in`, not `Object.keys()`: the keys array would be an allocation per node (~2x the whole
|
|
82
|
+
// walk on measured bodies, both engines, at realistic widths; V8 only prefers `Object.keys` on
|
|
83
|
+
// 200+-property dictionary-mode objects). On JSON.parse output the two enumerate identically -
|
|
84
|
+
// own enumerable string keys, nothing enumerable on the prototype chain. A custom transport
|
|
85
|
+
// codec could hand `guardParsedValue` an object with enumerable INHERITED properties; `for-in`
|
|
86
|
+
// sweeps those too, which only over-sweeps - a poisoned inherited subtree rejects rather than
|
|
87
|
+
// slips through. A *non-enumerable* own `__proto__` is the one shape this misses, and it is
|
|
88
|
+
// not the poisoning shape: `JSON.parse` never produces one, and the merges that carry the
|
|
89
|
+
// payload onward (spread, `Object.assign`, key loops) copy enumerable own keys only.
|
|
90
|
+
for (const key in record) {
|
|
91
|
+
const value = record[key]
|
|
92
|
+
const nested = value !== null && typeof value === "object"
|
|
93
|
+
// Keep the hot clean-key check branch-local and cheap. JSON keys are strings, and the length
|
|
94
|
+
// + first-code-unit guards avoid a full string comparison for the overwhelming majority of
|
|
95
|
+
// fields while preserving exact equality for the two security-sensitive names.
|
|
96
|
+
if (key.length === 9 && key.charCodeAt(0) === 95 /* _ */ && key === "__proto__") {
|
|
97
|
+
if (policy === "reject") throw POISONED
|
|
98
|
+
// biome-ignore lint/complexity/useLiteralKeys: bracket access keeps the guarded key an explicit string, never a prototype walk
|
|
99
|
+
delete record["__proto__"]
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
key.length === 11 &&
|
|
104
|
+
key.charCodeAt(0) === 99 /* c */ &&
|
|
105
|
+
key === "constructor" &&
|
|
106
|
+
nested &&
|
|
107
|
+
Object.hasOwn(value, "prototype")
|
|
108
|
+
) {
|
|
109
|
+
if (policy === "reject") throw POISONED
|
|
110
|
+
// biome-ignore lint/complexity/useLiteralKeys: bracket access keeps the guarded key an explicit string, never a prototype walk
|
|
111
|
+
delete record["constructor"]
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (nested) stack.push(value)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return root
|
|
118
|
+
}
|
package/src/server/registry.ts
CHANGED
|
@@ -35,12 +35,16 @@ export type Registry = Record<string, Record<string, RouteInfo>>
|
|
|
35
35
|
/** The empty registry (no routes). `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
|
|
36
36
|
export type EmptyRegistry = NonNullable<unknown>
|
|
37
37
|
|
|
38
|
+
/** The client-visible request inputs use the schema's INPUT side: the registry describes what a
|
|
39
|
+
* caller must SEND over the wire (pre-validation), not what the handler receives after parsing. With
|
|
40
|
+
* a Zod `.default()`/transform the two differ - `InferOutput` would force the client to supply
|
|
41
|
+
* fields the schema fills in. (Same rule as the WS `in` channel below.) */
|
|
38
42
|
type RegistryBody<S extends RouteSchema> = S extends { body: infer B extends StandardSchemaV1 }
|
|
39
|
-
?
|
|
43
|
+
? InferInput<B>
|
|
40
44
|
: never
|
|
41
45
|
|
|
42
46
|
type RegistryQuery<S extends RouteSchema> = S extends { query: infer Q extends StandardSchemaV1 }
|
|
43
|
-
?
|
|
47
|
+
? InferInput<Q>
|
|
44
48
|
: never
|
|
45
49
|
|
|
46
50
|
/** The client-visible output: the declared `response` contract when present (so the client sees the
|