@nifrajs/core 1.12.0 → 1.13.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.
@@ -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
  }
@@ -4,7 +4,7 @@ import { normalizeRouteCapabilities } from "../internal/capability-runtime.ts"
4
4
  import { METHODS, type Method } from "../router/router.ts"
5
5
  import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
6
6
  import type { Context, IdempotencyConfig, Params, RouteSchema } from "./context.ts"
7
- import type { OutputOf } from "./registry.ts"
7
+ import type { EmptyRegistry, OutputOf, Registry } from "./registry.ts"
8
8
  import { Server } from "./server.ts"
9
9
 
10
10
  /** An additional (non-success) response a contract operation can document, e.g. a `404`. */
@@ -140,9 +140,13 @@ type HandlerReturnForOp<O extends OperationDef> = O extends {
140
140
  ? Response | InferOutput<R>
141
141
  : unknown
142
142
 
143
- /** The handlers `implement` requires: one per operation, typed from the op's input + response contract. */
144
- export type HandlersFor<C extends ContractShape> = {
145
- [K in keyof C]: (context: ContextForOp<C[K]>) => MaybePromise<HandlerReturnForOp<C[K]>>
143
+ /**
144
+ * The handlers `implement` requires: one per operation, typed from the op's input + response contract,
145
+ * intersected with the host app's accumulated `derive`/`decorate` context - the same
146
+ * `Context & Ctx` an inline {@link Handler} receives, so a handler graduates either way unchanged.
147
+ */
148
+ export type HandlersFor<C extends ContractShape, Ctx = NonNullable<unknown>> = {
149
+ [K in keyof C]: (context: ContextForOp<C[K]> & Ctx) => MaybePromise<HandlerReturnForOp<C[K]>>
146
150
  }
147
151
 
148
152
  const METHOD_SET: ReadonlySet<string> = new Set(METHODS)
@@ -189,7 +193,11 @@ type AnyFn = (...args: never[]) => unknown
189
193
  * the implemented server stays route-for-route identical to the equivalent inline server (the
190
194
  * mode-conformance guarantee), and a contract-typed client and a `typeof app`-typed client agree.
191
195
  */
192
- export type RegistryFromImpl<C extends ContractShape, H extends HandlersFor<C>> = {
196
+ export type RegistryFromImpl<
197
+ C extends ContractShape,
198
+ H extends HandlersFor<C, Ctx>,
199
+ Ctx = NonNullable<unknown>,
200
+ > = {
193
201
  [P in C[keyof C]["path"]]: {
194
202
  [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: {
195
203
  readonly params: Params<C[K]["path"]>
@@ -215,13 +223,28 @@ export type RegistryFromImpl<C extends ContractShape, H extends HandlersFor<C>>
215
223
  * builder, so the result is identical to writing the routes inline — handlers
216
224
  * lift over **unchanged** ("graduation"), and body/query schemas validate at the
217
225
  * request boundary exactly as in inline mode.
226
+ *
227
+ * Pass a pre-configured `app` to give the contract's routes a middleware chain. A route captures the
228
+ * server's `derive`/`decorate`/assurance chain **at registration**, so anything applied to the
229
+ * returned server afterwards reaches the contract's routes not at all - the app must already carry it:
230
+ *
231
+ * ```ts
232
+ * const app = implement(contract, handlers, server().use(auth).derive(sessionOf))
233
+ * ```
234
+ *
235
+ * That is also the seam that lets `nifra assure` prove rather than merely classify a contract-first
236
+ * app: the plugin that installs the enforcement is what declares the evidence
237
+ * ({@link withRouteAssurance}), and only a plugin installed *before* registration is captured. Handlers
238
+ * then see the app's `Ctx`, and the returned server keeps any routes the app already had.
218
239
  */
219
- export function implement<const C extends ContractShape, H extends HandlersFor<C>>(
220
- contract: C,
221
- handlers: H,
222
- ): Server<RegistryFromImpl<C, H>> {
223
- const app = new Server()
224
- for (const [name, op] of Object.entries(contract)) {
240
+ export function implement<
241
+ const C extends ContractShape,
242
+ H extends HandlersFor<C, Ctx>,
243
+ R extends Registry = EmptyRegistry,
244
+ Ctx = NonNullable<unknown>,
245
+ >(contract: C, handlers: H, app?: Server<R, Ctx>): Server<R & RegistryFromImpl<C, H, Ctx>, Ctx> {
246
+ const target = (app ?? new Server()) as Server<R, Ctx>
247
+ const routes = Object.entries(contract).map(([name, op]) => {
225
248
  // body/query are validated at the request boundary; `response` is a type + introspection contract
226
249
  // ONLY — never validated at runtime and never read on the request hot path (the lifecycle reads
227
250
  // schema.body/query by name; the `bare`/bodyOnly/queryOnly fast-path gates ignore `response`). Built
@@ -243,14 +266,15 @@ export function implement<const C extends ContractShape, H extends HandlersFor<C
243
266
  ...(op.classification !== undefined ? { classification: op.classification } : {}),
244
267
  }
245
268
  : undefined
246
- app.register(
247
- op.method,
248
- op.path,
269
+ return {
270
+ method: op.method,
271
+ path: op.path,
249
272
  schema,
250
- handlers[name as keyof H] as (context: never) => unknown,
251
- )
252
- }
273
+ handler: handlers[name as keyof H] as (context: never) => unknown,
274
+ }
275
+ })
276
+ target.registerBatch(routes)
253
277
  // Runtime registered exactly the contract's routes through the inline path; the
254
278
  // registry type is computed from the contract inputs + handler return types.
255
- return app as unknown as Server<RegistryFromImpl<C, H>>
279
+ return target as unknown as Server<R & RegistryFromImpl<C, H, Ctx>, Ctx>
256
280
  }