@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.
Files changed (167) hide show
  1. package/README.md +10 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/effect-ledger.d.ts +9 -0
  7. package/dist/effect-ledger.d.ts.map +1 -0
  8. package/dist/effect-ledger.js +8 -0
  9. package/dist/effect-ledger.js.map +1 -0
  10. package/dist/idempotency-plugin.d.ts +8 -0
  11. package/dist/idempotency-plugin.d.ts.map +1 -0
  12. package/dist/idempotency-plugin.js +8 -0
  13. package/dist/idempotency-plugin.js.map +1 -0
  14. package/dist/index.d.ts +6 -34
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -30
  17. package/dist/index.js.map +1 -1
  18. package/dist/mcp.d.ts +7 -0
  19. package/dist/mcp.d.ts.map +1 -0
  20. package/dist/mcp.js +56 -0
  21. package/dist/mcp.js.map +1 -0
  22. package/dist/node-direct.d.ts +5 -0
  23. package/dist/node-direct.d.ts.map +1 -0
  24. package/dist/node-direct.js +27 -0
  25. package/dist/node-direct.js.map +1 -0
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +2 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/router/pattern.d.ts +42 -0
  31. package/dist/router/pattern.d.ts.map +1 -0
  32. package/dist/router/pattern.js +127 -0
  33. package/dist/router/pattern.js.map +1 -0
  34. package/dist/router/router.d.ts +2 -1
  35. package/dist/router/router.d.ts.map +1 -1
  36. package/dist/router/router.js +11 -48
  37. package/dist/router/router.js.map +1 -1
  38. package/dist/server/client-ip.d.ts +35 -0
  39. package/dist/server/client-ip.d.ts.map +1 -0
  40. package/dist/server/client-ip.js +42 -0
  41. package/dist/server/client-ip.js.map +1 -0
  42. package/dist/server/context.d.ts +47 -2
  43. package/dist/server/context.d.ts.map +1 -1
  44. package/dist/server/contract.d.ts +37 -10
  45. package/dist/server/contract.d.ts.map +1 -1
  46. package/dist/server/contract.js +33 -8
  47. package/dist/server/contract.js.map +1 -1
  48. package/dist/server/http.d.ts +14 -0
  49. package/dist/server/http.d.ts.map +1 -0
  50. package/dist/server/http.js +45 -0
  51. package/dist/server/http.js.map +1 -0
  52. package/dist/server/idempotency-lane.d.ts +44 -0
  53. package/dist/server/idempotency-lane.d.ts.map +1 -0
  54. package/dist/server/idempotency-lane.js +181 -0
  55. package/dist/server/idempotency-lane.js.map +1 -0
  56. package/dist/server/install.d.ts +21 -0
  57. package/dist/server/install.d.ts.map +1 -0
  58. package/dist/server/install.js +21 -0
  59. package/dist/server/install.js.map +1 -0
  60. package/dist/server/ledger-lane.d.ts +53 -0
  61. package/dist/server/ledger-lane.d.ts.map +1 -0
  62. package/dist/server/ledger-lane.js +82 -0
  63. package/dist/server/ledger-lane.js.map +1 -0
  64. package/dist/server/mcp-hook.d.ts +28 -0
  65. package/dist/server/mcp-hook.d.ts.map +1 -0
  66. package/dist/server/mcp-hook.js +2 -0
  67. package/dist/server/mcp-hook.js.map +1 -0
  68. package/dist/server/node-outcome-hook.d.ts +9 -0
  69. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  70. package/dist/server/node-outcome-hook.js +2 -0
  71. package/dist/server/node-outcome-hook.js.map +1 -0
  72. package/dist/server/node-outcome.d.ts +37 -0
  73. package/dist/server/node-outcome.d.ts.map +1 -0
  74. package/dist/server/node-outcome.js +75 -0
  75. package/dist/server/node-outcome.js.map +1 -0
  76. package/dist/server/plugin.d.ts +78 -0
  77. package/dist/server/plugin.d.ts.map +1 -0
  78. package/dist/server/plugin.js +57 -0
  79. package/dist/server/plugin.js.map +1 -0
  80. package/dist/server/query.d.ts +18 -0
  81. package/dist/server/query.d.ts.map +1 -0
  82. package/dist/server/query.js +102 -0
  83. package/dist/server/query.js.map +1 -0
  84. package/dist/server/registry.d.ts +31 -6
  85. package/dist/server/registry.d.ts.map +1 -1
  86. package/dist/server/request-context.d.ts +51 -0
  87. package/dist/server/request-context.d.ts.map +1 -0
  88. package/dist/server/request-context.js +184 -0
  89. package/dist/server/request-context.js.map +1 -0
  90. package/dist/server/respond.d.ts +17 -0
  91. package/dist/server/respond.d.ts.map +1 -0
  92. package/dist/server/respond.js +81 -0
  93. package/dist/server/respond.js.map +1 -0
  94. package/dist/server/runtime-core.d.ts +36 -0
  95. package/dist/server/runtime-core.d.ts.map +1 -0
  96. package/dist/server/runtime-core.js +51 -0
  97. package/dist/server/runtime-core.js.map +1 -0
  98. package/dist/server/server-types.d.ts +203 -0
  99. package/dist/server/server-types.d.ts.map +1 -0
  100. package/dist/server/server-types.js +2 -0
  101. package/dist/server/server-types.js.map +1 -0
  102. package/dist/server/server.d.ts +135 -358
  103. package/dist/server/server.d.ts.map +1 -1
  104. package/dist/server/server.js +476 -1065
  105. package/dist/server/server.js.map +1 -1
  106. package/dist/server/sse-hook.d.ts +10 -0
  107. package/dist/server/sse-hook.d.ts.map +1 -0
  108. package/dist/server/sse-hook.js +2 -0
  109. package/dist/server/sse-hook.js.map +1 -0
  110. package/dist/server/sse.d.ts +7 -23
  111. package/dist/server/sse.d.ts.map +1 -1
  112. package/dist/server/sse.js +18 -0
  113. package/dist/server/sse.js.map +1 -1
  114. package/dist/server/websocket.d.ts +17 -1
  115. package/dist/server/websocket.d.ts.map +1 -1
  116. package/dist/server/websocket.js.map +1 -1
  117. package/dist/server/ws-hook.d.ts +8 -11
  118. package/dist/server/ws-hook.d.ts.map +1 -1
  119. package/dist/server/ws-hook.js +7 -14
  120. package/dist/server/ws-hook.js.map +1 -1
  121. package/dist/server.d.ts +21 -0
  122. package/dist/server.d.ts.map +1 -0
  123. package/dist/server.js +16 -0
  124. package/dist/server.js.map +1 -0
  125. package/dist/ws.d.ts +16 -7
  126. package/dist/ws.d.ts.map +1 -1
  127. package/dist/ws.js +25 -10
  128. package/dist/ws.js.map +1 -1
  129. package/package.json +82 -6
  130. package/src/assurance.ts +36 -1
  131. package/src/effect-ledger.ts +9 -0
  132. package/src/idempotency-plugin.ts +7 -0
  133. package/src/index.ts +6 -132
  134. package/src/mcp.ts +65 -0
  135. package/src/node-direct.ts +37 -0
  136. package/src/reflection.ts +7 -0
  137. package/src/router/pattern.ts +176 -0
  138. package/src/router/router.ts +11 -69
  139. package/src/server/client-ip.ts +60 -0
  140. package/src/server/context.ts +50 -2
  141. package/src/server/contract.ts +68 -25
  142. package/src/server/http.ts +58 -0
  143. package/src/server/idempotency-lane.ts +287 -0
  144. package/src/server/install.ts +28 -0
  145. package/src/server/ledger-lane.ts +125 -0
  146. package/src/server/mcp-hook.ts +41 -0
  147. package/src/server/node-outcome-hook.ts +9 -0
  148. package/src/server/node-outcome.ts +112 -0
  149. package/src/server/plugin.ts +92 -0
  150. package/src/server/query.ts +119 -0
  151. package/src/server/registry.ts +36 -6
  152. package/src/server/request-context.ts +271 -0
  153. package/src/server/respond.ts +93 -0
  154. package/src/server/runtime-core.ts +74 -0
  155. package/src/server/server-types.ts +204 -0
  156. package/src/server/server.ts +863 -1657
  157. package/src/server/sse-hook.ts +14 -0
  158. package/src/server/sse.ts +26 -0
  159. package/src/server/websocket.ts +20 -0
  160. package/src/server/ws-hook.ts +8 -19
  161. package/src/server.ts +92 -0
  162. package/src/ws.ts +33 -10
  163. package/dist/invariants.d.ts +0 -70
  164. package/dist/invariants.d.ts.map +0 -1
  165. package/dist/invariants.js +0 -487
  166. package/dist/invariants.js.map +0 -1
  167. 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
+ }
@@ -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, path: string, payload: T): void {
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
- if (path.length === 0 || path.charCodeAt(0) !== SLASH) {
257
- throw new RouteConfigError("INVALID_PATH", `path must start with "/": "${path}"`)
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 (let i = 0; i < segments.length; i++) {
266
- const seg = segments[i]!
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 (marker === STAR) {
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(seg)
254
+ let child = node.staticChildren.get(segment.value)
313
255
  if (child === undefined) {
314
256
  child = createNode<T>()
315
- node.staticChildren.set(seg, child)
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
+ }
@@ -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: Params<Path>
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,