@nifrajs/core 1.12.0 → 2.0.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 +10 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +8 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -30
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +42 -0
- package/dist/router/pattern.d.ts.map +1 -0
- package/dist/router/pattern.js +127 -0
- package/dist/router/pattern.js.map +1 -0
- package/dist/router/router.d.ts +2 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +11 -48
- package/dist/router/router.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +37 -10
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +33 -8
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +44 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +181 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +135 -358
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +476 -1065
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/websocket.d.ts +17 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +82 -6
- package/src/assurance.ts +36 -1
- package/src/effect-ledger.ts +9 -0
- package/src/idempotency-plugin.ts +7 -0
- package/src/index.ts +6 -132
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/router/pattern.ts +176 -0
- package/src/router/router.ts +11 -69
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +68 -25
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +287 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +93 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +863 -1657
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/websocket.ts +20 -0
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-in MCP runtime plugin.
|
|
3
|
+
*
|
|
4
|
+
* `.use(mcp())` enables `.tool()`, `.resource()`, and `.prompt()`. Without it those methods throw at
|
|
5
|
+
* registration (fail-closed), so an ordinary HTTP app never evaluates or bundles the MCP wiring. This
|
|
6
|
+
* is the same install seam as `.use(idempotency())` / `.use(effectLedger())` - one consistent `.use()`
|
|
7
|
+
* opt-in, not a side-effect import.
|
|
8
|
+
*/
|
|
9
|
+
import { INSTALL_MCP } from "./server/install.ts"
|
|
10
|
+
import type { McpRuntime } from "./server/mcp-hook.ts"
|
|
11
|
+
import type { IdentityPlugin } from "./server/plugin.ts"
|
|
12
|
+
import type { AnyServer } from "./server/server.ts"
|
|
13
|
+
|
|
14
|
+
const MCP_RUNTIME: McpRuntime = {
|
|
15
|
+
tool(name, config, handler) {
|
|
16
|
+
const path = `/_nifra/tool/${name}`
|
|
17
|
+
return {
|
|
18
|
+
path,
|
|
19
|
+
schema:
|
|
20
|
+
config.output !== undefined
|
|
21
|
+
? { body: config.input, response: config.output }
|
|
22
|
+
: { body: config.input },
|
|
23
|
+
run: (context) => handler(context.body, context),
|
|
24
|
+
descriptor: {
|
|
25
|
+
name,
|
|
26
|
+
description: config.description,
|
|
27
|
+
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
resource(uri, config, read) {
|
|
32
|
+
return {
|
|
33
|
+
uri,
|
|
34
|
+
name: config.name,
|
|
35
|
+
...(config.description !== undefined ? { description: config.description } : {}),
|
|
36
|
+
...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
|
|
37
|
+
read,
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
prompt(name, config, handler) {
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
description: config.description,
|
|
44
|
+
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
45
|
+
handler,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The install seam a server exposes so the `mcp()` plugin can hand it the runtime. */
|
|
51
|
+
interface McpInstallable {
|
|
52
|
+
[INSTALL_MCP](runtime: McpRuntime): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Enable MCP declarations on a server: `.use(mcp())` turns on `.tool()`, `.resource()`, and
|
|
57
|
+
* `.prompt()`. Applying it twice is a no-op (named plugin dedupe).
|
|
58
|
+
*/
|
|
59
|
+
export function mcp(): IdentityPlugin {
|
|
60
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
61
|
+
;(app as unknown as McpInstallable)[INSTALL_MCP](MCP_RUNTIME)
|
|
62
|
+
return app
|
|
63
|
+
}
|
|
64
|
+
return Object.assign(apply, { pluginName: "nifra:mcp" }) as IdentityPlugin
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-in Node-direct renderer plugin.
|
|
3
|
+
*
|
|
4
|
+
* `.use(nodeDirect())` enables `app.resolveNode()` for callers that invoke it themselves. Normal
|
|
5
|
+
* `@nifrajs/node` serving supplies the renderer directly, so it never needs this; only direct
|
|
6
|
+
* `resolveNode()` callers do. Same `.use()` install seam as `mcp()` / `idempotency()`.
|
|
7
|
+
*/
|
|
8
|
+
import { INSTALL_NODE_DIRECT } from "./server/install.ts"
|
|
9
|
+
import { nodeOutcomeFromResponse, toNodeOutcome } from "./server/node-outcome.ts"
|
|
10
|
+
import type { NodeOutcomeRuntime } from "./server/node-outcome-hook.ts"
|
|
11
|
+
import type { IdentityPlugin } from "./server/plugin.ts"
|
|
12
|
+
import type { AnyServer } from "./server/server.ts"
|
|
13
|
+
|
|
14
|
+
const NODE_OUTCOME_RUNTIME: NodeOutcomeRuntime = {
|
|
15
|
+
toOutcome: toNodeOutcome,
|
|
16
|
+
fromResponse: nodeOutcomeFromResponse,
|
|
17
|
+
timeout: () => ({
|
|
18
|
+
kind: "response",
|
|
19
|
+
response: Response.json({ ok: false, error: "request_timeout" }, { status: 503 }),
|
|
20
|
+
}),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The install seam a server exposes so the `nodeDirect()` plugin can hand it the renderer. */
|
|
24
|
+
interface NodeDirectInstallable {
|
|
25
|
+
[INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Enable `app.resolveNode()` for direct callers. Applying it twice is a no-op (named plugin dedupe). */
|
|
29
|
+
export function nodeDirect(): IdentityPlugin {
|
|
30
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
31
|
+
;(app as unknown as NodeDirectInstallable)[INSTALL_NODE_DIRECT](NODE_OUTCOME_RUNTIME)
|
|
32
|
+
return app
|
|
33
|
+
}
|
|
34
|
+
return Object.assign(apply, { pluginName: "nifra:node-direct" }) as IdentityPlugin
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type { NodeServeOutcome } from "./server/node-outcome.ts"
|
package/src/reflection.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface SchemaReflection {
|
|
|
40
40
|
export interface ReflectedRouteSchema {
|
|
41
41
|
readonly body?: SchemaReflection
|
|
42
42
|
readonly query?: SchemaReflection
|
|
43
|
+
/** Path-params schema — constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
|
|
44
|
+
readonly params?: SchemaReflection
|
|
43
45
|
readonly response?: SchemaReflection
|
|
44
46
|
readonly errors?: Readonly<Record<string, SchemaReflection>>
|
|
45
47
|
/** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
|
|
@@ -52,6 +54,9 @@ export interface ReflectedRoute {
|
|
|
52
54
|
readonly schema?: ReflectedRouteSchema
|
|
53
55
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
54
56
|
readonly capabilities?: readonly string[]
|
|
57
|
+
/** Set when the route is a declared dynamic route family (a runtime-resolved template like
|
|
58
|
+
* `/api/:slug/:resource`) - so the assurance gate treats it as one classified family, not a single route. */
|
|
59
|
+
readonly family?: boolean
|
|
55
60
|
/** Field-level response classification plus the highest sensitivity present. */
|
|
56
61
|
readonly classification?: ResponseClassification
|
|
57
62
|
readonly tool?: {
|
|
@@ -167,6 +172,7 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
|
|
|
167
172
|
return {
|
|
168
173
|
...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
|
|
169
174
|
...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
|
|
175
|
+
...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
|
|
170
176
|
...(schema.response !== undefined ? { response: reflectSchema(schema.response) } : {}),
|
|
171
177
|
...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
|
|
172
178
|
...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
|
|
@@ -218,6 +224,7 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
|
|
|
218
224
|
...(schema !== undefined ? { schema } : {}),
|
|
219
225
|
...(assurance !== undefined ? { assurance } : {}),
|
|
220
226
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
227
|
+
...(route.family === true ? { family: true } : {}),
|
|
221
228
|
...(classification !== undefined ? { classification } : {}),
|
|
222
229
|
...(tool !== undefined ? { tool } : {}),
|
|
223
230
|
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { RouteConfigError } from "../errors.ts"
|
|
2
|
+
|
|
3
|
+
const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
4
|
+
const RESERVED_PARAM_NAMES = new Set(["__proto__", "constructor", "prototype"])
|
|
5
|
+
const COMPILED_ROUTE_PATTERN: unique symbol = Symbol("nifra.compiled-route-pattern")
|
|
6
|
+
|
|
7
|
+
export type RoutePatternSegment =
|
|
8
|
+
| { readonly kind: "static"; readonly value: string }
|
|
9
|
+
| { readonly kind: "param"; readonly name: string }
|
|
10
|
+
| { readonly kind: "wildcard"; readonly name: string }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compiled route grammar shared by runtime routers, browser navigation, mocks, and adapters.
|
|
14
|
+
*
|
|
15
|
+
* The result, its two arrays, and every segment are frozen because consumers share one instance. In
|
|
16
|
+
* particular, a segment must not diverge from the lazily cached regex after first match. The measured
|
|
17
|
+
* sub-microsecond cost per route is paid only at registration and preserves that runtime invariant.
|
|
18
|
+
*/
|
|
19
|
+
export interface CompiledRoutePattern {
|
|
20
|
+
readonly [COMPILED_ROUTE_PATTERN]: true
|
|
21
|
+
readonly pattern: string
|
|
22
|
+
readonly segments: readonly RoutePatternSegment[]
|
|
23
|
+
readonly paramNames: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RoutePatternMatch =
|
|
27
|
+
| { readonly matched: true; readonly params: Record<string, string> }
|
|
28
|
+
| { readonly matched: false; readonly reason: "not-found" | "malformed" }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Regex per compiled pattern, derived on first use. The core trie matches by descending segments and
|
|
32
|
+
* never asks for one, so building it during {@link compileRoutePattern} would charge every server's
|
|
33
|
+
* boot for the browser/mock adapters alone. Keyed by the frozen pattern, so the cache dies with it.
|
|
34
|
+
*/
|
|
35
|
+
const REGEX_CACHE = new WeakMap<CompiledRoutePattern, RegExp>()
|
|
36
|
+
|
|
37
|
+
function regexOf(compiled: CompiledRoutePattern): RegExp {
|
|
38
|
+
let regex = REGEX_CACHE.get(compiled)
|
|
39
|
+
if (regex === undefined) {
|
|
40
|
+
if (compiled[COMPILED_ROUTE_PATTERN] !== true) {
|
|
41
|
+
throw new TypeError("route pattern was not produced by compileRoutePattern()")
|
|
42
|
+
}
|
|
43
|
+
const parts = compiled.segments.map((segment) =>
|
|
44
|
+
segment.kind === "static"
|
|
45
|
+
? escapeRegex(segment.value)
|
|
46
|
+
: segment.kind === "param"
|
|
47
|
+
? "([^/]+)"
|
|
48
|
+
: "(.+)",
|
|
49
|
+
)
|
|
50
|
+
regex = new RegExp(parts.length === 0 ? "^/$" : `^/${parts.join("/")}$`)
|
|
51
|
+
REGEX_CACHE.set(compiled, regex)
|
|
52
|
+
}
|
|
53
|
+
return regex
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validParamName(name: string): boolean {
|
|
57
|
+
return PARAM_NAME.test(name) && !RESERVED_PARAM_NAMES.has(name)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeRegex(value: string): string {
|
|
61
|
+
return value.replace(/[.*+?^()|[\]\\{}$]/g, "\\$&")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parse and validate Nifra's strict route grammar once. Trailing slashes remain significant. */
|
|
65
|
+
export function compileRoutePattern(pattern: string): CompiledRoutePattern {
|
|
66
|
+
if (pattern.length === 0 || pattern.charCodeAt(0) !== 47 /* / */) {
|
|
67
|
+
throw new RouteConfigError("INVALID_PATH", `path must start with "/": "${pattern}"`)
|
|
68
|
+
}
|
|
69
|
+
const raw = pattern === "/" ? [] : pattern.slice(1).split("/")
|
|
70
|
+
const segments: RoutePatternSegment[] = []
|
|
71
|
+
const paramNames: string[] = []
|
|
72
|
+
for (let i = 0; i < raw.length; i++) {
|
|
73
|
+
const value = raw[i]!
|
|
74
|
+
if (value.charCodeAt(0) === 58 /* : */) {
|
|
75
|
+
const name = value.slice(1)
|
|
76
|
+
if (!validParamName(name)) {
|
|
77
|
+
throw new RouteConfigError(
|
|
78
|
+
"INVALID_PARAM_NAME",
|
|
79
|
+
`invalid parameter ":${name}" in "${pattern}"`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
if (paramNames.includes(name)) {
|
|
83
|
+
throw new RouteConfigError(
|
|
84
|
+
"DUPLICATE_PARAM",
|
|
85
|
+
`duplicate parameter ":${name}" in "${pattern}"`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
paramNames.push(name)
|
|
89
|
+
segments.push(Object.freeze({ kind: "param", name }))
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (value.charCodeAt(0) === 42 /* * */) {
|
|
93
|
+
if (i !== raw.length - 1) {
|
|
94
|
+
throw new RouteConfigError(
|
|
95
|
+
"WILDCARD_NOT_LAST",
|
|
96
|
+
`wildcard must be the final segment in "${pattern}"`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
const name = value.length === 1 ? "*" : value.slice(1)
|
|
100
|
+
if (name !== "*" && !validParamName(name)) {
|
|
101
|
+
throw new RouteConfigError(
|
|
102
|
+
"INVALID_PARAM_NAME",
|
|
103
|
+
`invalid wildcard "*${name}" in "${pattern}"`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
if (paramNames.includes(name)) {
|
|
107
|
+
throw new RouteConfigError(
|
|
108
|
+
"DUPLICATE_PARAM",
|
|
109
|
+
`duplicate parameter "${name}" in "${pattern}"`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
paramNames.push(name)
|
|
113
|
+
segments.push(Object.freeze({ kind: "wildcard", name }))
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
segments.push(Object.freeze({ kind: "static", value }))
|
|
117
|
+
}
|
|
118
|
+
const compiled = Object.freeze({
|
|
119
|
+
[COMPILED_ROUTE_PATTERN]: true,
|
|
120
|
+
pattern,
|
|
121
|
+
segments: Object.freeze(segments),
|
|
122
|
+
paramNames: Object.freeze(paramNames),
|
|
123
|
+
}) as CompiledRoutePattern
|
|
124
|
+
return compiled
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Core precedence: static > param > wildcard at the first differing segment, independent of order. */
|
|
128
|
+
export function compareRoutePatternSpecificity(
|
|
129
|
+
left: CompiledRoutePattern,
|
|
130
|
+
right: CompiledRoutePattern,
|
|
131
|
+
): number {
|
|
132
|
+
const length = Math.max(left.segments.length, right.segments.length)
|
|
133
|
+
for (let i = 0; i < length; i++) {
|
|
134
|
+
const a = left.segments[i]
|
|
135
|
+
const b = right.segments[i]
|
|
136
|
+
if (a === undefined || b === undefined) return b === undefined ? -1 : 1
|
|
137
|
+
const aWeight = a.kind === "static" ? 3 : a.kind === "param" ? 2 : 1
|
|
138
|
+
const bWeight = b.kind === "static" ? 3 : b.kind === "param" ? 2 : 1
|
|
139
|
+
if (aWeight !== bWeight) return bWeight - aWeight
|
|
140
|
+
}
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Decode router captures under one rule. Plain values take the zero-allocation path; malformed
|
|
145
|
+
* escapes return `null`, allowing HTTP to emit 400 while client navigation declines the match. */
|
|
146
|
+
export function decodeRouteParams(raw: Record<string, string>): Record<string, string> | null {
|
|
147
|
+
let out: Record<string, string> | undefined
|
|
148
|
+
for (const key in raw) {
|
|
149
|
+
const value = raw[key]!
|
|
150
|
+
if (!value.includes("%")) continue
|
|
151
|
+
try {
|
|
152
|
+
out ??= { ...raw }
|
|
153
|
+
out[key] = decodeURIComponent(value)
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out ?? raw
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Match one compiled pattern and return decoded captures. The caller decides cross-pattern order. */
|
|
162
|
+
export function matchRoutePattern(
|
|
163
|
+
compiled: CompiledRoutePattern,
|
|
164
|
+
pathname: string,
|
|
165
|
+
): RoutePatternMatch {
|
|
166
|
+
const match = regexOf(compiled).exec(pathname)
|
|
167
|
+
if (match === null) return { matched: false, reason: "not-found" }
|
|
168
|
+
const params: Record<string, string> = {}
|
|
169
|
+
for (let i = 0; i < compiled.paramNames.length; i++) {
|
|
170
|
+
params[compiled.paramNames[i]!] = match[i + 1] ?? ""
|
|
171
|
+
}
|
|
172
|
+
const decoded = decodeRouteParams(params)
|
|
173
|
+
return decoded === null
|
|
174
|
+
? { matched: false, reason: "malformed" }
|
|
175
|
+
: { matched: true, params: decoded }
|
|
176
|
+
}
|
package/src/router/router.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RouteConfigError } from "../errors.ts"
|
|
2
|
+
import { type CompiledRoutePattern, compileRoutePattern } from "./pattern.ts"
|
|
2
3
|
|
|
3
4
|
/** HTTP methods the router accepts. */
|
|
4
5
|
export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const
|
|
@@ -11,24 +12,7 @@ function isMethod(value: string): value is Method {
|
|
|
11
12
|
return METHOD_SET.has(value)
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
/** Parameter names must be valid identifiers — mirrors the global identifier rule. */
|
|
15
|
-
const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
16
|
-
|
|
17
|
-
/** Object-prototype keys are rejected as param names: a route like `/:__proto__` could let a captured
|
|
18
|
-
* value reach a prototype-polluting key. Param *names* come from the route pattern (never from request
|
|
19
|
-
* input) and are validated here at registration, so a reserved key can never reach `buildParams` —
|
|
20
|
-
* this boot-time guard is the whole protection. The runtime params object stays a plain `{}` (only
|
|
21
|
-
* user-controlled *values* land in it, under these validated keys), keeping the param path
|
|
22
|
-
* allocation-cheap; a null-prototype object would add cost for no added safety. */
|
|
23
|
-
const RESERVED_PARAM_NAMES = new Set(["__proto__", "constructor", "prototype"])
|
|
24
|
-
|
|
25
|
-
/** A param name is valid when it's an identifier AND not a reserved object-prototype key. */
|
|
26
|
-
const validParamName = (name: string): boolean =>
|
|
27
|
-
PARAM_NAME.test(name) && !RESERVED_PARAM_NAMES.has(name)
|
|
28
|
-
|
|
29
15
|
const SLASH = 47
|
|
30
|
-
const COLON = 58
|
|
31
|
-
const STAR = 42
|
|
32
16
|
export const EMPTY_PARAMS: Record<string, string> = Object.freeze({})
|
|
33
17
|
const DYNAMIC_MATCH_CACHE_MAX = 2048
|
|
34
18
|
|
|
@@ -245,7 +229,7 @@ export class Router<T> {
|
|
|
245
229
|
* (boot-time, L2) on a duplicate route, a malformed pattern, or conflicting
|
|
246
230
|
* parameter names for the same path shape.
|
|
247
231
|
*/
|
|
248
|
-
add(method: Method,
|
|
232
|
+
add(method: Method, pattern: string | CompiledRoutePattern, payload: T): void {
|
|
249
233
|
DYNAMIC_MATCH_CACHES.delete(this)
|
|
250
234
|
const upper = method.toUpperCase()
|
|
251
235
|
// The `Method` parameter type is the compile-time guard; this re-checks at
|
|
@@ -253,66 +237,24 @@ export class Router<T> {
|
|
|
253
237
|
if (!isMethod(upper)) {
|
|
254
238
|
throw new RouteConfigError("INVALID_METHOD", `unsupported HTTP method "${method}"`)
|
|
255
239
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const segments = path === "/" ? [] : path.slice(1).split("/")
|
|
261
|
-
const paramNames: string[] = []
|
|
262
|
-
let isStatic = true
|
|
240
|
+
const compiled = typeof pattern === "string" ? compileRoutePattern(pattern) : pattern
|
|
241
|
+
const path = compiled.pattern
|
|
242
|
+
const paramNames = compiled.paramNames
|
|
243
|
+
const isStatic = paramNames.length === 0
|
|
263
244
|
let node = this.root
|
|
264
245
|
|
|
265
|
-
for (
|
|
266
|
-
|
|
267
|
-
const marker = seg.charCodeAt(0)
|
|
268
|
-
|
|
269
|
-
if (marker === COLON) {
|
|
270
|
-
isStatic = false
|
|
271
|
-
const name = seg.slice(1)
|
|
272
|
-
if (!validParamName(name)) {
|
|
273
|
-
throw new RouteConfigError(
|
|
274
|
-
"INVALID_PARAM_NAME",
|
|
275
|
-
`invalid parameter ":${name}" in "${path}"`,
|
|
276
|
-
)
|
|
277
|
-
}
|
|
278
|
-
if (paramNames.includes(name)) {
|
|
279
|
-
throw new RouteConfigError(
|
|
280
|
-
"DUPLICATE_PARAM",
|
|
281
|
-
`duplicate parameter ":${name}" in "${path}"`,
|
|
282
|
-
)
|
|
283
|
-
}
|
|
284
|
-
paramNames.push(name)
|
|
246
|
+
for (const segment of compiled.segments) {
|
|
247
|
+
if (segment.kind === "param") {
|
|
285
248
|
node.paramChild ??= createNode<T>()
|
|
286
249
|
node = node.paramChild
|
|
287
|
-
} else if (
|
|
288
|
-
isStatic = false
|
|
289
|
-
if (i !== segments.length - 1) {
|
|
290
|
-
throw new RouteConfigError(
|
|
291
|
-
"WILDCARD_NOT_LAST",
|
|
292
|
-
`wildcard must be the final segment in "${path}"`,
|
|
293
|
-
)
|
|
294
|
-
}
|
|
295
|
-
const name = seg.length === 1 ? "*" : seg.slice(1)
|
|
296
|
-
if (name !== "*" && !validParamName(name)) {
|
|
297
|
-
throw new RouteConfigError(
|
|
298
|
-
"INVALID_PARAM_NAME",
|
|
299
|
-
`invalid wildcard "*${name}" in "${path}"`,
|
|
300
|
-
)
|
|
301
|
-
}
|
|
302
|
-
if (paramNames.includes(name)) {
|
|
303
|
-
throw new RouteConfigError(
|
|
304
|
-
"DUPLICATE_PARAM",
|
|
305
|
-
`duplicate parameter "${name}" in "${path}"`,
|
|
306
|
-
)
|
|
307
|
-
}
|
|
308
|
-
paramNames.push(name)
|
|
250
|
+
} else if (segment.kind === "wildcard") {
|
|
309
251
|
node.wildcardChild ??= createNode<T>()
|
|
310
252
|
node = node.wildcardChild
|
|
311
253
|
} else {
|
|
312
|
-
let child = node.staticChildren.get(
|
|
254
|
+
let child = node.staticChildren.get(segment.value)
|
|
313
255
|
if (child === undefined) {
|
|
314
256
|
child = createNode<T>()
|
|
315
|
-
node.staticChildren.set(
|
|
257
|
+
node.staticChildren.set(segment.value, child)
|
|
316
258
|
}
|
|
317
259
|
node = child
|
|
318
260
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deriving the caller's IP (`c.clientIp`), correctly and vendor-neutrally.
|
|
3
|
+
*
|
|
4
|
+
* The default is the raw socket peer the serving adapter observed - the only value that cannot be
|
|
5
|
+
* forged by a client, since the client never controls who the TCP connection comes from. Behind a
|
|
6
|
+
* reverse proxy or CDN the socket peer is the proxy, not the caller, so a `Forwarded`/`X-Forwarded-For`
|
|
7
|
+
* value must be consulted - but ONLY as far as you actually trust the hops in front of you, because a
|
|
8
|
+
* directly-reachable app lets any client forge those headers. The trust is therefore an explicit,
|
|
9
|
+
* opt-in declaration; with none set, `c.clientIp` is the socket peer and no header is ever believed.
|
|
10
|
+
*
|
|
11
|
+
* No vendor names live here: a Cloudflare/Fastly/nginx deployment declares its own header or hop count.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How much of the forwarding chain to trust when deriving `c.clientIp`. Omit for the safe default
|
|
16
|
+
* (socket peer only, no header believed).
|
|
17
|
+
*
|
|
18
|
+
* - `{ trustedHops: n }` - trust `n` reverse-proxy hops in front of the app. The caller is taken from
|
|
19
|
+
* `X-Forwarded-For` counted from the right past the `n` trusted hops (the socket peer is hop 0). Use
|
|
20
|
+
* this when the app sits behind `n` proxies **you operate**. Fewer entries than trusted hops means
|
|
21
|
+
* the header is too short to trust, so `c.clientIp` is `undefined` (fail closed).
|
|
22
|
+
* - `{ header: name }` - trust a single named header's first value (e.g. a CDN that overwrites it at
|
|
23
|
+
* the edge). Only declare this when the app is reachable **exclusively** through that edge; a
|
|
24
|
+
* directly-reachable app lets any client forge the header.
|
|
25
|
+
*/
|
|
26
|
+
export type ClientIpTrust = { readonly trustedHops: number } | { readonly header: string }
|
|
27
|
+
|
|
28
|
+
function firstHeaderValue(req: Request, header: string): string | undefined {
|
|
29
|
+
const raw = req.headers.get(header)
|
|
30
|
+
if (raw === null) return undefined
|
|
31
|
+
const first = raw.split(",", 1)[0]?.trim()
|
|
32
|
+
return first === undefined || first === "" ? undefined : first
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the caller IP from the raw socket `peer` (adapter-observed), the request's forwarding
|
|
37
|
+
* headers, and the app's `trust` declaration. Pure and synchronous.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveClientIp(
|
|
40
|
+
peer: string | undefined,
|
|
41
|
+
req: Request,
|
|
42
|
+
trust: ClientIpTrust | undefined,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
if (trust === undefined) return peer
|
|
45
|
+
if ("header" in trust) return firstHeaderValue(req, trust.header)
|
|
46
|
+
|
|
47
|
+
// trustedHops: walk `X-Forwarded-For` (client-most first) plus the socket peer as the closest hop.
|
|
48
|
+
const xff = req.headers.get("x-forwarded-for")
|
|
49
|
+
const forwarded =
|
|
50
|
+
xff === null
|
|
51
|
+
? []
|
|
52
|
+
: xff
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((entry) => entry.trim())
|
|
55
|
+
.filter((entry) => entry !== "")
|
|
56
|
+
const chain = peer !== undefined ? [...forwarded, peer] : forwarded
|
|
57
|
+
// The caller sits just left of the `trustedHops` trusted proxies at the tail of the chain.
|
|
58
|
+
const index = chain.length - 1 - trust.trustedHops
|
|
59
|
+
return index >= 0 ? chain[index] : undefined
|
|
60
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -78,6 +78,34 @@ export interface RouteSchema {
|
|
|
78
78
|
* and read by downstream policy (e.g. a partner-API surface refusing to expose `pii`/`secret`).
|
|
79
79
|
*/
|
|
80
80
|
readonly classification?: DataClassification
|
|
81
|
+
/**
|
|
82
|
+
* Inline **route-assurance evidence**: the enforcement posture this route carries, declared adjacent to
|
|
83
|
+
* the handler (e.g. `[NIFRA_ASSURANCE.AUTHENTICATED]`). Each id becomes route-scoped `declared` evidence
|
|
84
|
+
* in reflection, so a `nifra.assurance.ts` policy `require:` clause is satisfiable WITHOUT rewriting an
|
|
85
|
+
* in-handler guard into a `withRouteAssurance`-marked middleware. Ids are lowercase dot/dash segments
|
|
86
|
+
* (invalid ids throw at registration). Reflected for the assurance gate; never read on the hot path.
|
|
87
|
+
*
|
|
88
|
+
* This is an author ASSERTION (recorded as `source: "declared"`), not runtime proof - like every
|
|
89
|
+
* assurance marker it is trusted by the static gate, which matches on the evidence id, not its source.
|
|
90
|
+
* Declare an id only on a route whose guard actually enforces it; a false declaration silently satisfies
|
|
91
|
+
* the policy. (`source` is retained in reflection, so a stricter policy or reviewer can still tell
|
|
92
|
+
* declared evidence from enforcement-module evidence.)
|
|
93
|
+
*/
|
|
94
|
+
readonly assurance?: readonly string[]
|
|
95
|
+
/**
|
|
96
|
+
* Mark this route a **dynamic route family**: a template (`/api/:slug/:resource`) whose concrete
|
|
97
|
+
* resources are resolved at runtime (auto-CRUD over tenant-defined tables, a catch-all dispatcher).
|
|
98
|
+
* Purely declarative - it does not change dispatch. It surfaces in reflection so the assurance gate and
|
|
99
|
+
* tooling treat the one templated route as a deliberate family whose evidence covers every runtime-
|
|
100
|
+
* resolved resource, instead of reading it as a single forgotten route.
|
|
101
|
+
*/
|
|
102
|
+
readonly family?: boolean
|
|
103
|
+
/** Optional **path-params schema**. Path params arrive as strings (`/users/:id` -> `c.params.id`);
|
|
104
|
+
* declaring a schema validates them at the boundary (a malformed `:id` is a `422` before the handler,
|
|
105
|
+
* like `body`/`query`) and can coerce them (use `t.query({ id: t.integer() })` for a numeric param).
|
|
106
|
+
* The schema must cover every path param; with the strict default an undeclared path param is rejected.
|
|
107
|
+
* When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
|
|
108
|
+
readonly params?: StandardSchemaV1
|
|
81
109
|
readonly body?: StandardSchemaV1
|
|
82
110
|
readonly query?: StandardSchemaV1
|
|
83
111
|
/** Optional **response contract**. When declared: the handler's return is type-checked against it
|
|
@@ -117,7 +145,7 @@ export interface RouteSchema {
|
|
|
117
145
|
readonly onValidationError?: (
|
|
118
146
|
issues: ReadonlyArray<StandardIssue>,
|
|
119
147
|
ctx: Context,
|
|
120
|
-
kind: "body" | "query",
|
|
148
|
+
kind: "body" | "query" | "params",
|
|
121
149
|
) => Response | unknown | Promise<Response | unknown>
|
|
122
150
|
}
|
|
123
151
|
|
|
@@ -131,6 +159,13 @@ type QueryOf<S extends RouteSchema> = S extends { query: infer Q extends Standar
|
|
|
131
159
|
? InferOutput<Q>
|
|
132
160
|
: URLSearchParams
|
|
133
161
|
|
|
162
|
+
/** The validated params type when a params schema is declared, else the path-inferred `Params<Path>`. */
|
|
163
|
+
type ParamsOf<S extends RouteSchema, Path extends string> = S extends {
|
|
164
|
+
params: infer P extends StandardSchemaV1
|
|
165
|
+
}
|
|
166
|
+
? InferOutput<P>
|
|
167
|
+
: Params<Path>
|
|
168
|
+
|
|
134
169
|
/** Mutable response controls a handler may write to before returning. */
|
|
135
170
|
export interface ResponseControls {
|
|
136
171
|
status?: number
|
|
@@ -159,6 +194,10 @@ export interface Platform<Env = unknown> {
|
|
|
159
194
|
readonly env?: Env
|
|
160
195
|
/** Extend the response's lifetime for background work (Workers `ctx.waitUntil`). */
|
|
161
196
|
readonly waitUntil?: (promise: Promise<unknown>) => void
|
|
197
|
+
/** The raw socket peer address the serving adapter observed, if any (`listen()`, `@nifrajs/node`,
|
|
198
|
+
* `@nifrajs/deno` set it). The server applies the app's `clientIp` trust declaration to this before
|
|
199
|
+
* exposing {@link Context.clientIp}; read `c.clientIp`, not this. */
|
|
200
|
+
readonly clientIp?: string | undefined
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
/**
|
|
@@ -174,7 +213,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
174
213
|
* everywhere (no more `c.req` vs `ctx.request` mismatch).
|
|
175
214
|
*/
|
|
176
215
|
readonly request: Request
|
|
177
|
-
readonly params:
|
|
216
|
+
readonly params: ParamsOf<S, Path>
|
|
178
217
|
readonly query: QueryOf<S>
|
|
179
218
|
readonly body: BodyOf<S>
|
|
180
219
|
/** The request's cookies, parsed from the `Cookie` header (values URL-decoded). Parsed lazily on
|
|
@@ -198,6 +237,15 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
198
237
|
* (`c.env: Env`); otherwise `unknown`. Validate at the trust boundary before use either way.
|
|
199
238
|
*/
|
|
200
239
|
readonly env: unknown
|
|
240
|
+
/**
|
|
241
|
+
* The caller's IP. By default the raw socket peer the serving adapter observed (`listen()`,
|
|
242
|
+
* `@nifrajs/node`, `@nifrajs/deno`) — the one address a client cannot forge — or `undefined` when the
|
|
243
|
+
* runtime exposes no socket (Workers) and no trust is declared. Behind a proxy/CDN, set the
|
|
244
|
+
* `clientIp` server option (`{ trustedHops }` or `{ header }`) to derive the real caller from the
|
|
245
|
+
* forwarding chain as far as you trust it; unset, no forwarded header is ever believed. Safe to key
|
|
246
|
+
* rate limits and audit logs on.
|
|
247
|
+
*/
|
|
248
|
+
readonly clientIp: string | undefined
|
|
201
249
|
/**
|
|
202
250
|
* Schedule background work. On edge runtimes it extends the response lifetime (Workers
|
|
203
251
|
* `ctx.waitUntil`); off-edge it runs the promise fire-and-forget (a rejection is swallowed,
|