@nifrajs/core 2.1.0 → 2.3.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 (138) hide show
  1. package/README.md +2 -2
  2. package/dist/assurance.d.ts +48 -4
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +87 -9
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/binary.d.ts +54 -0
  7. package/dist/binary.d.ts.map +1 -0
  8. package/dist/binary.js +90 -0
  9. package/dist/binary.js.map +1 -0
  10. package/dist/budget.d.ts +1 -1
  11. package/dist/budget.js +1 -1
  12. package/dist/capabilities.d.ts +19 -2
  13. package/dist/capabilities.d.ts.map +1 -1
  14. package/dist/capabilities.js +60 -8
  15. package/dist/capabilities.js.map +1 -1
  16. package/dist/classification.d.ts +1 -1
  17. package/dist/classification.js +1 -1
  18. package/dist/diff.d.ts +6 -6
  19. package/dist/diff.js +6 -6
  20. package/dist/durable-adapters.d.ts.map +1 -1
  21. package/dist/durable-adapters.js +36 -70
  22. package/dist/durable-adapters.js.map +1 -1
  23. package/dist/durable-execution.d.ts +2 -2
  24. package/dist/durable-execution.js +2 -2
  25. package/dist/idempotency.d.ts +3 -3
  26. package/dist/idempotency.js +4 -4
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +3 -3
  29. package/dist/internal/brand.d.ts +1 -1
  30. package/dist/internal/brand.js +1 -1
  31. package/dist/internal/route-catalog.d.ts +58 -0
  32. package/dist/internal/route-catalog.d.ts.map +1 -0
  33. package/dist/internal/route-catalog.js +58 -0
  34. package/dist/internal/route-catalog.js.map +1 -0
  35. package/dist/internal/route-execution.d.ts +75 -0
  36. package/dist/internal/route-execution.d.ts.map +1 -0
  37. package/dist/internal/route-execution.js +2 -0
  38. package/dist/internal/route-execution.js.map +1 -0
  39. package/dist/internal/same-origin.d.ts +29 -0
  40. package/dist/internal/same-origin.d.ts.map +1 -0
  41. package/dist/internal/same-origin.js +47 -0
  42. package/dist/internal/same-origin.js.map +1 -0
  43. package/dist/internal/secret.d.ts +1 -1
  44. package/dist/internal/secret.js +1 -1
  45. package/dist/ledger.d.ts +9 -9
  46. package/dist/ledger.js +5 -5
  47. package/dist/reflection.d.ts +1 -1
  48. package/dist/router/pattern.d.ts +26 -1
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +164 -8
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.d.ts.map +1 -1
  53. package/dist/router/router.js +52 -4
  54. package/dist/router/router.js.map +1 -1
  55. package/dist/schema/standard.d.ts +2 -2
  56. package/dist/schema/standard.js +2 -2
  57. package/dist/seo.d.ts +3 -3
  58. package/dist/seo.js +4 -4
  59. package/dist/server/body.d.ts +2 -2
  60. package/dist/server/body.js +2 -2
  61. package/dist/server/context.d.ts +18 -18
  62. package/dist/server/contract.d.ts +7 -7
  63. package/dist/server/contract.js +3 -3
  64. package/dist/server/cookies.d.ts +4 -4
  65. package/dist/server/cookies.js +8 -8
  66. package/dist/server/edge.d.ts +83 -0
  67. package/dist/server/edge.d.ts.map +1 -0
  68. package/dist/server/edge.js +71 -0
  69. package/dist/server/edge.js.map +1 -0
  70. package/dist/server/install.d.ts +2 -0
  71. package/dist/server/install.d.ts.map +1 -1
  72. package/dist/server/install.js +2 -0
  73. package/dist/server/install.js.map +1 -1
  74. package/dist/server/logger.d.ts +7 -7
  75. package/dist/server/logger.js +4 -4
  76. package/dist/server/registry.d.ts +5 -5
  77. package/dist/server/response-contract-lane.d.ts +91 -0
  78. package/dist/server/response-contract-lane.d.ts.map +1 -0
  79. package/dist/server/response-contract-lane.js +81 -0
  80. package/dist/server/response-contract-lane.js.map +1 -0
  81. package/dist/server/server.d.ts +52 -91
  82. package/dist/server/server.d.ts.map +1 -1
  83. package/dist/server/server.js +130 -186
  84. package/dist/server/server.js.map +1 -1
  85. package/dist/server/sse.d.ts +4 -4
  86. package/dist/server/sse.js +7 -7
  87. package/dist/server/webhook.d.ts +1 -1
  88. package/dist/server/webhook.js +5 -5
  89. package/dist/server/websocket.d.ts +19 -19
  90. package/dist/server/websocket.js +9 -9
  91. package/dist/server/ws-bun.d.ts +4 -4
  92. package/dist/server/ws-bun.js +3 -3
  93. package/dist/server.d.ts +3 -1
  94. package/dist/server.d.ts.map +1 -1
  95. package/dist/server.js +3 -1
  96. package/dist/server.js.map +1 -1
  97. package/dist/transport-codec.d.ts +13 -1
  98. package/dist/transport-codec.d.ts.map +1 -1
  99. package/dist/transport-codec.js +48 -9
  100. package/dist/transport-codec.js.map +1 -1
  101. package/package.json +12 -2
  102. package/src/assurance.ts +154 -8
  103. package/src/binary.ts +128 -0
  104. package/src/budget.ts +1 -1
  105. package/src/capabilities.ts +71 -9
  106. package/src/classification.ts +1 -1
  107. package/src/diff.ts +8 -8
  108. package/src/durable-adapters.ts +40 -70
  109. package/src/durable-execution.ts +2 -2
  110. package/src/idempotency.ts +4 -4
  111. package/src/index.ts +3 -3
  112. package/src/internal/brand.ts +1 -1
  113. package/src/internal/route-catalog.ts +94 -0
  114. package/src/internal/route-execution.ts +106 -0
  115. package/src/internal/same-origin.ts +43 -0
  116. package/src/internal/secret.ts +1 -1
  117. package/src/ledger.ts +11 -11
  118. package/src/reflection.ts +1 -1
  119. package/src/router/pattern.ts +179 -8
  120. package/src/router/router.ts +80 -6
  121. package/src/schema/standard.ts +2 -2
  122. package/src/seo.ts +6 -6
  123. package/src/server/body.ts +2 -2
  124. package/src/server/context.ts +18 -18
  125. package/src/server/contract.ts +13 -13
  126. package/src/server/cookies.ts +8 -8
  127. package/src/server/edge.ts +150 -0
  128. package/src/server/install.ts +5 -0
  129. package/src/server/logger.ts +8 -8
  130. package/src/server/registry.ts +5 -5
  131. package/src/server/response-contract-lane.ts +164 -0
  132. package/src/server/server.ts +159 -366
  133. package/src/server/sse.ts +11 -11
  134. package/src/server/webhook.ts +5 -5
  135. package/src/server/websocket.ts +22 -22
  136. package/src/server/ws-bun.ts +4 -4
  137. package/src/server.ts +8 -5
  138. package/src/transport-codec.ts +63 -10
package/src/index.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
- * `@nifrajs/core` — Nifra's lean HTTP server API.
2
+ * `@nifrajs/core` - Nifra's lean HTTP server API.
3
3
  *
4
4
  * Nifra 2.0 keeps the package root intentionally lean. Optional systems live at documented
5
5
  * subpaths so importing the root cannot activate unrelated runtimes.
6
6
  */
7
7
 
8
8
  /**
9
- * Current package version. A hardcoded literal on purpose — core runs on the edge (no fs), so it can't
9
+ * Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't
10
10
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
11
11
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
12
12
  */
13
- export const VERSION = "2.1.0" as const
13
+ export const VERSION = "2.3.0" as const
14
14
 
15
15
  export type Version = typeof VERSION
16
16
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every developer-facing string (error prefixes, the default OpenAPI title, the
5
5
  * logger tag) pulls from here, so renaming the brand is a one-line change. The
6
- * npm scope (`@nifrajs/*`) is the other naming axis — `scripts/rename.ts` flips
6
+ * npm scope (`@nifrajs/*`) is the other naming axis - `scripts/rename.ts` flips
7
7
  * both at once.
8
8
  */
9
9
  export const FRAMEWORK_NAME = "Nifra" as const
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The app's route table: one canonical fact per registered route, and the router built over them.
3
+ *
4
+ * ## Why this is not in the server kernel
5
+ *
6
+ * Nothing here knows what a `Server` is. It owns matching, reflection, assurance and native-compilation
7
+ * input, and the kernel holds one instance - which is the whole relationship. The reason it lived in
8
+ * `server.ts` is that `RouteEntry` used to be declared there, and until that vocabulary moved to
9
+ * `internal/` this could not be split out without exporting the engine's internals as public types.
10
+ *
11
+ * ## Why this costs the request path nothing
12
+ *
13
+ * Per-request code calls `this.catalog.find(...)`: a property load and a method call on an instance.
14
+ * Which module DECLARED the class does not enter into that. The only cross-module operation is the
15
+ * `new RouteCatalog()` in the server's constructor, once per server; every other method here runs at
16
+ * registration.
17
+ */
18
+ import type { CompiledRoutePattern } from "../router/pattern.ts"
19
+ import { type Method, Router, type RouterMatch } from "../router/router.ts"
20
+ import type { RouteDescriptor } from "../server/server-types.ts"
21
+ import type { AssuranceDeclaration } from "./route-assurance.ts"
22
+ import type { RouteEntry } from "./route-execution.ts"
23
+
24
+ /** One canonical runtime route fact. The catalog owns matching, reflection, assurance, tool metadata,
25
+ * replay, and native compilation input so batch registration has one commit point. */
26
+ export interface CatalogRoute {
27
+ readonly method: Method
28
+ readonly path: string
29
+ readonly pattern: CompiledRoutePattern
30
+ readonly entry: RouteEntry
31
+ readonly descriptor: RouteDescriptor
32
+ readonly assurance: readonly AssuranceDeclaration[]
33
+ }
34
+
35
+ /**
36
+ * Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
37
+ * the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
38
+ * after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
39
+ * reflection unchanged.
40
+ *
41
+ * Coverage note: this file reports 90% functions with every method exercised. `bun test --coverage`
42
+ * (1.3.14) counts one synthetic function per class that it never marks hit - a one-method class with a
43
+ * passing test for that method reports 50%. So 90 is this file's ceiling, not a missing test; do not go
44
+ * looking for the tenth function.
45
+ */
46
+ export class RouteCatalog {
47
+ private matcher = new Router<RouteEntry>()
48
+ private records: CatalogRoute[] = []
49
+ /** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
50
+ private descriptors: RouteDescriptor[] = []
51
+ private assurancePresent = false
52
+
53
+ add(route: CatalogRoute): void {
54
+ this.matcher.add(route.method, route.pattern, route.entry)
55
+ this.records.push(route)
56
+ this.descriptors.push(route.descriptor)
57
+ if (route.assurance.length > 0) this.assurancePresent = true
58
+ }
59
+
60
+ addBatch(routes: readonly CatalogRoute[]): void {
61
+ if (routes.length === 0) return
62
+ const nextRecords = this.records.concat(routes)
63
+ const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor))
64
+ const nextAssurancePresent =
65
+ this.assurancePresent || routes.some((route) => route.assurance.length > 0)
66
+ const staged = new Router<RouteEntry>()
67
+ for (const route of this.records) staged.add(route.method, route.pattern, route.entry)
68
+ for (const route of routes) staged.add(route.method, route.pattern, route.entry)
69
+ this.matcher = staged
70
+ this.records = nextRecords
71
+ this.descriptors = nextDescriptors
72
+ this.assurancePresent = nextAssurancePresent
73
+ }
74
+
75
+ find(method: string, path: string): RouterMatch<RouteEntry> {
76
+ return this.matcher.find(method, path)
77
+ }
78
+
79
+ entries(): readonly CatalogRoute[] {
80
+ return this.records
81
+ }
82
+
83
+ routeDescriptors(): ReadonlyArray<RouteDescriptor> {
84
+ return this.descriptors
85
+ }
86
+
87
+ lastDescriptor(): RouteDescriptor | undefined {
88
+ return this.records[this.records.length - 1]?.descriptor
89
+ }
90
+
91
+ hasAssurance(): boolean {
92
+ return this.assurancePresent
93
+ }
94
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What a compiled route IS, and the registration-time decision that produces it.
3
+ *
4
+ * ## Why this is not in the server kernel
5
+ *
6
+ * `compileExecutionPlan` is BOOT work: it runs from `register`/`registerBatch`, both gated by
7
+ * `assertConfigurable`, so nothing here is reachable once the server is serving. Moving it costs the
8
+ * request path nothing - per-request code still calls the same frozen `run` closure it always did.
9
+ *
10
+ * ## Why `internal/`
11
+ *
12
+ * These types are the engine's vocabulary, not the package's API. They lived in `server.ts`, which IS
13
+ * published as `./server`, so every attempt to split the kernel had the same choice: export the engine's
14
+ * internals as public types, or not split. `internal/` is not a published subpath, so this moves the
15
+ * vocabulary somewhere it can be shared without becoming API. That is the step that unblocks the rest.
16
+ *
17
+ * The `Server` import is type-only and erased, so there is no runtime cycle back into the kernel.
18
+ */
19
+ import type { RequestBudget } from "../budget.ts"
20
+ import type { StandardSchemaV1 } from "../schema/standard.ts"
21
+ import type { Platform, RouteSchema } from "../server/context.ts"
22
+ import type { ResolvedIdempotency } from "../server/idempotency-lane.ts"
23
+ import type { ResolvedEffectLedger } from "../server/ledger-lane.ts"
24
+ import type { Registry } from "../server/registry.ts"
25
+ import type { ResponseContractRuntime } from "../server/response-contract-lane.ts"
26
+ import type { HandlerResult } from "../server/runtime-core.ts"
27
+ import type { CtxSet, MaybePromise, RawContext, RequestSource, Server } from "../server/server.ts"
28
+
29
+ export type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
30
+
31
+ /** A `derive` computes per-request context extensions; stored path-erased. */
32
+ export type RawDerive = (ctx: RawContext) => MaybePromise<object>
33
+ export type RawBeforeHandle = (ctx: RawContext) => MaybePromise<unknown>
34
+ export type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown>
35
+ export type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
36
+ export type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
37
+
38
+ export type RouteExecutionRunner = <T, R extends Registry, Ctx>(
39
+ runtime: Server<R, Ctx>,
40
+ entry: RouteEntry,
41
+ source: RequestSource,
42
+ params: Record<string, string>,
43
+ search: string | undefined,
44
+ signal: AbortSignal,
45
+ budget: RequestBudget,
46
+ platform: Platform | undefined,
47
+ finalize: (result: unknown, set: CtxSet) => T,
48
+ wrapResponse: (response: Response) => T,
49
+ ) => MaybePromise<T>
50
+
51
+ export type ContextRouteRunner = <T, R extends Registry, Ctx>(
52
+ runtime: Server<R, Ctx>,
53
+ entry: RouteEntry,
54
+ source: RequestSource,
55
+ ctx: RawContext,
56
+ finalize: (result: unknown, set: CtxSet) => T,
57
+ wrapResponse: (response: Response) => T,
58
+ ) => MaybePromise<T>
59
+
60
+ /** Registration-compiled route behavior. Every adapter invokes the same runner; the optional fused
61
+ * renderer is only a response-format specialization of that same selected route semantics. */
62
+ export interface RouteExecutionPlan {
63
+ readonly run: RouteExecutionRunner
64
+ readonly fusedWeb: FusedWebRunner | undefined
65
+ }
66
+
67
+ export interface RouteEntry {
68
+ readonly handler: InternalHandler
69
+ readonly schema: RouteSchema | undefined
70
+ /** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
71
+ readonly idempotent: ResolvedIdempotency | undefined
72
+ /** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
73
+ readonly ledgered: ResolvedEffectLedger | undefined
74
+ /** The installed response-contract runtime paired with this route's declared schema, resolved once
75
+ * at registration; `undefined` = not checked (the default, and the only state in which the route can
76
+ * still take the fused/native lanes). */
77
+ readonly responseContract:
78
+ | { readonly runtime: ResponseContractRuntime; readonly schema: StandardSchemaV1 }
79
+ | undefined
80
+ /** Per-request context extensions captured at registration (order-scoped). */
81
+ readonly derives: ReadonlyArray<RawDerive>
82
+ /** Static context extensions captured at registration. */
83
+ readonly decorations: Record<PropertyKey, unknown>
84
+ /** Whether {@link decorations} has any keys - precomputed so the hot path skips a no-op
85
+ * `Object.assign` on the (common) no-decoration route. */
86
+ readonly hasDecorations: boolean
87
+ /** Lifecycle hooks captured at registration (order-scoped). */
88
+ readonly beforeHandle: ReadonlyArray<RawBeforeHandle>
89
+ readonly afterHandle: ReadonlyArray<RawAfterHandle>
90
+ readonly onError: ReadonlyArray<RawErrorHandler>
91
+ /** Wraps the matched route lifecycle. Empty for the common no-around path. */
92
+ readonly around: ReadonlyArray<RawAround>
93
+ /** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
94
+ readonly execution: RouteExecutionPlan
95
+ }
96
+
97
+ /** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
98
+ export type FusedWebRunner = (
99
+ source: RequestSource,
100
+ params: Record<string, string>,
101
+ search: string | undefined,
102
+ signal: AbortSignal,
103
+ budget: RequestBudget,
104
+ platform: Platform | undefined,
105
+ nativeContext: boolean,
106
+ ) => MaybePromise<Response>
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The same-origin test every browser-facing endpoint in Nifra uses.
3
+ *
4
+ * One owner, because there are two of these seams - the WebSocket handshake's CSWSH default and the
5
+ * server-function mount - and they answered differently for the same request. A browser that could
6
+ * open a socket was told its POST was cross-origin.
7
+ */
8
+
9
+ /**
10
+ * True when `origin` is the request's own origin, as observed from inside a possibly-proxied server.
11
+ *
12
+ * The host must match exactly. The scheme must be equal or STRONGER on the Origin side, never weaker:
13
+ *
14
+ * - `https://app` page → `http://app` request URL: accepted. This is what every TLS-terminating
15
+ * proxy looks like from the origin server - Cloudflare, a tunnel, an ingress. The socket is plain
16
+ * HTTP, so `request.url` says `http:` while the browser correctly reports an `https:` page.
17
+ * - `http://app` page → `https://app` request URL: rejected. Nifra terminated TLS itself here, so a
18
+ * plaintext page on the same host is the downgrade an attacker would need and buys nothing real.
19
+ *
20
+ * Comparing full origins instead is not the stricter option, it is an outage: measured, it rejects
21
+ * every server-function POST behind a terminating proxy, while the cross-origin attacker it aims at
22
+ * was already rejected by the host comparison. Nifra deliberately does not read `X-Forwarded-Proto` -
23
+ * a forwarded header is attacker-controlled unless something upstream is proven to overwrite it - so
24
+ * the scheme in `request.url` is the socket's, not the browser's, and this asymmetry is how the two
25
+ * are reconciled without trusting a header.
26
+ *
27
+ * An unparseable Origin is not same-origin.
28
+ */
29
+ export function isSameOriginRequest(origin: string, request: Request): boolean {
30
+ try {
31
+ const from = new URL(origin)
32
+ const own = new URL(request.url)
33
+ if (from.host !== own.host) return false
34
+ // Written out rather than ranked through a lookup table: a table is a shipped object, and there
35
+ // are exactly two schemes to order. Any other scheme on either side falls through to `false`,
36
+ // which is what refuses `Origin: null`, `file:` and webview schemes.
37
+ if (from.protocol === "https:") return own.protocol === "https:" || own.protocol === "http:"
38
+ if (from.protocol === "http:") return own.protocol === "http:"
39
+ return false
40
+ } catch {
41
+ return false
42
+ }
43
+ }
@@ -7,7 +7,7 @@ const MIN_SECRET_BYTES = 32
7
7
  const SECRET_ENCODER = new TextEncoder()
8
8
 
9
9
  /**
10
- * Boot-time guard: reject an HMAC secret under 256 bits, loudly, at construction — never at the
10
+ * Boot-time guard: reject an HMAC secret under 256 bits, loudly, at construction - never at the
11
11
  * first request. UTF-8 byte length (not char count), so a multibyte secret is measured honestly.
12
12
  * The single source of truth for the framework's secret-strength floor; every HMAC entry point
13
13
  * (signed cookies, `@nifrajs/auth` sessions, webhook verification, signed upload/image URLs) routes
package/src/ledger.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Every owned effect seam (a repository write, an external call, an email send) records one entry via
5
5
  * the `useCapability` beacon. The entry type is deliberately **token-only**: capability ids, phase,
6
6
  * an adapter/resource token, dimensionless cost counters, an optional keyed digest, and an error code.
7
- * There is no free-form payload field — request bodies, rows, and values cannot enter the ledger, so
7
+ * There is no free-form payload field - request bodies, rows, and values cannot enter the ledger, so
8
8
  * redaction holds by construction rather than by filtering. The sealed ledger carries the **route
9
9
  * pattern** (`/users/:id`), never the concrete URL, so path parameters cannot leak either.
10
10
  *
@@ -29,7 +29,7 @@ export type EffectCost = Readonly<Record<string, number>>
29
29
 
30
30
  /** Token-only caller metadata shared by an effect intent and outcome. */
31
31
  export interface EffectMetadata {
32
- /** Adapter/resource token (`repo:orders`, `provider:payments`). A token — never a value or a row. */
32
+ /** Adapter/resource token (`repo:orders`, `provider:payments`). A token - never a value or a row. */
33
33
  readonly target?: string
34
34
  /** Dimensionless counters; see {@link EffectCost}. At most {@link MAX_COST_AXES} axes. */
35
35
  readonly cost?: EffectCost
@@ -45,7 +45,7 @@ export interface EffectEntryInput extends EffectMetadata {
45
45
  readonly capability: string
46
46
  /** Default `"intent"`. Record `committed`/`failed`/`compensated` only after the outcome is known. */
47
47
  readonly phase?: EffectPhase
48
- /** Outcome error as a bounded token code — never a message, never a stack. */
48
+ /** Outcome error as a bounded token code - never a message, never a stack. */
49
49
  readonly error?: { readonly code: string }
50
50
  }
51
51
 
@@ -75,10 +75,10 @@ export interface EffectChain {
75
75
  /** The immutable result of sealing a request's ledger. Token-only; safe to hand to any sink. */
76
76
  export interface SealedEffectLedger {
77
77
  readonly method: string
78
- /** The registered route pattern (`/users/:id`) — never the concrete request URL. */
78
+ /** The registered route pattern (`/users/:id`) - never the concrete request URL. */
79
79
  readonly path: string
80
80
  readonly entries: readonly EffectEntry[]
81
- /** The route's declared capability tokens — the runtime-enforcement view: recorded ⊆ declared is
81
+ /** The route's declared capability tokens - the runtime-enforcement view: recorded ⊆ declared is
82
82
  * guaranteed by the beacon, and `declared` minus the recorded ids is the unused declaration set. */
83
83
  readonly declared: readonly string[]
84
84
  /** Present when the ledger was created with `chain: true`. */
@@ -99,7 +99,7 @@ export class EffectLedgerOverflowError extends Error {
99
99
  }
100
100
  }
101
101
 
102
- /** Thrown by `append` after `seal()` — e.g. an effect attempted while streaming a response body. */
102
+ /** Thrown by `append` after `seal()` - e.g. an effect attempted while streaming a response body. */
103
103
  export class EffectLedgerSealedError extends Error {
104
104
  constructor() {
105
105
  super("effect ledger is sealed: effects cannot be recorded after the response settles")
@@ -115,14 +115,14 @@ export interface RequestLedger {
115
115
  entries(): readonly EffectEntry[]
116
116
  readonly size: number
117
117
  readonly sealed: boolean
118
- /** Finalize the ledger (computing the chain when enabled). Idempotent — always the same result. */
118
+ /** Finalize the ledger (computing the chain when enabled). Idempotent - always the same result. */
119
119
  seal(): Promise<SealedEffectLedger>
120
120
  }
121
121
 
122
122
  export interface CreateRequestLedgerOptions {
123
123
  /** HTTP method of the matched route. */
124
124
  readonly method: string
125
- /** The registered route pattern — callers must never pass the concrete request URL. */
125
+ /** The registered route pattern - callers must never pass the concrete request URL. */
126
126
  readonly path: string
127
127
  /** The route's declared capability tokens, surfaced verbatim on the sealed ledger. Default `[]`. */
128
128
  readonly declared?: readonly string[]
@@ -255,7 +255,7 @@ class BoundedRequestLedger implements RequestLedger {
255
255
  if (input.error !== undefined && !ERROR_CODE.test(input.error.code)) {
256
256
  throw new TypeError("effect ledger: error code must be a bounded lowercase token")
257
257
  }
258
- // Only the known token fields are copied — a stray `payload`-like property never survives append.
258
+ // Only the known token fields are copied - a stray `payload`-like property never survives append.
259
259
  const at = this.clock()
260
260
  if (!Number.isFinite(at) || at < 0) {
261
261
  throw new TypeError("effect ledger: clock must return a finite non-negative number")
@@ -315,7 +315,7 @@ export function createRequestLedger(options: CreateRequestLedgerOptions): Reques
315
315
  return new BoundedRequestLedger(options)
316
316
  }
317
317
 
318
- /** Canonical, key-ordered serialization of one entry — the hash-chain input. */
318
+ /** Canonical, key-ordered serialization of one entry - the hash-chain input. */
319
319
  function canonicalEntry(entry: EffectEntry): string {
320
320
  const cost =
321
321
  entry.cost === undefined
@@ -382,7 +382,7 @@ export function createMemoryLedgerSink(options: MemoryLedgerSinkOptions = {}): M
382
382
  /** Minimum digest key material. A short key would make the keyed digest brute-forceable. */
383
383
  export const MIN_DIGEST_KEY_BYTES = 16
384
384
 
385
- /** Fresh random digest key (32 bytes). Per-process by default — persist one externally to correlate across restarts. */
385
+ /** Fresh random digest key (32 bytes). Per-process by default - persist one externally to correlate across restarts. */
386
386
  export function randomEffectDigestKey(): Uint8Array {
387
387
  const bytes = new Uint8Array(32)
388
388
  crypto.getRandomValues(bytes)
package/src/reflection.ts CHANGED
@@ -40,7 +40,7 @@ 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(…)`. */
43
+ /** Path-params schema - constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
44
44
  readonly params?: SchemaReflection
45
45
  readonly response?: SchemaReflection
46
46
  readonly errors?: Readonly<Record<string, SchemaReflection>>
@@ -8,6 +8,13 @@ export type RoutePatternSegment =
8
8
  | { readonly kind: "static"; readonly value: string }
9
9
  | { readonly kind: "param"; readonly name: string }
10
10
  | { readonly kind: "wildcard"; readonly name: string }
11
+ /** A segment that is part literal, part parameter: `:key.txt`, `feed.:format`, `v:major.:minor`. */
12
+ | { readonly kind: "mixed"; readonly parts: readonly MixedPart[] }
13
+
14
+ /** One piece of a {@link RoutePatternSegment} of kind `mixed`, in left-to-right order. */
15
+ export type MixedPart =
16
+ | { readonly t: "lit"; readonly v: string }
17
+ | { readonly t: "param"; readonly name: string }
11
18
 
12
19
  /**
13
20
  * Compiled route grammar shared by runtime routers, browser navigation, mocks, and adapters.
@@ -45,7 +52,9 @@ function regexOf(compiled: CompiledRoutePattern): RegExp {
45
52
  ? escapeRegex(segment.value)
46
53
  : segment.kind === "param"
47
54
  ? "([^/]+)"
48
- : "(.+)",
55
+ : segment.kind === "mixed"
56
+ ? mixedSegmentSource(segment.parts)
57
+ : "(.+)",
49
58
  )
50
59
  regex = new RegExp(parts.length === 0 ? "^/$" : `^/${parts.join("/")}$`)
51
60
  REGEX_CACHE.set(compiled, regex)
@@ -57,10 +66,93 @@ function validParamName(name: string): boolean {
57
66
  return PARAM_NAME.test(name) && !RESERVED_PARAM_NAMES.has(name)
58
67
  }
59
68
 
69
+ /**
70
+ * Explain WHY a parameter name was rejected, in the terms the author was thinking in.
71
+ *
72
+ * The grammar is per-segment: a segment is wholly static or wholly a parameter, so everything after
73
+ * the colon is the name. `/v/:id.json` therefore asks for a parameter literally named `id.json`,
74
+ * which is not what anyone means by it - but the bare "invalid parameter" that produces reads as a
75
+ * typo rather than as a rule, leaving the author to guess whether the dot, the length, or the casing
76
+ * was the problem. Naming the actual limitation and showing the two ways out is the difference
77
+ * between a five-second fix and a trip to the source.
78
+ */
79
+ function paramNameHint(name: string): string {
80
+ if (RESERVED_PARAM_NAMES.has(name)) return ` - "${name}" is reserved (prototype key)`
81
+ if (name.length === 0) return ` - ":" needs a name after it`
82
+ // No "a segment is wholly static or wholly a parameter" case here any more: mixed segments made
83
+ // that message obsolete. `:id.json` was the shape it explained, and `:id.json` now compiles.
84
+ return ` - a name must match ${PARAM_NAME.source}`
85
+ }
86
+
60
87
  function escapeRegex(value: string): string {
61
88
  return value.replace(/[.*+?^()|[\]\\{}$]/g, "\\$&")
62
89
  }
63
90
 
91
+ /**
92
+ * Split a segment containing `:` into literal and parameter parts.
93
+ *
94
+ * The param name is the LONGEST run matching {@link PARAM_NAME}'s body after each `:`; everything
95
+ * else in the segment is literal. So `:key.txt` is `[param key][lit ".txt"]`. Choosing a greedy scan
96
+ * on the existing sigil rather than a new `{key}.txt` syntax is a safety argument, not a taste one:
97
+ * every segment this newly accepts currently THROWS `INVALID_PARAM_NAME`, so no pattern that compiles
98
+ * today can change meaning.
99
+ *
100
+ * Returns `undefined` when the segment holds no parameter at all, so a literal colon
101
+ * (`/v1/things:batchGet` - legal in a URL path) stays a plain static segment.
102
+ */
103
+ function splitMixed(value: string): MixedPart[] | undefined {
104
+ const parts: MixedPart[] = []
105
+ let literal = ""
106
+ let sawParam = false
107
+ for (let i = 0; i < value.length; i++) {
108
+ if (value[i] !== ":") {
109
+ literal += value[i]
110
+ continue
111
+ }
112
+ const rest = value.slice(i + 1)
113
+ const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest)?.[0]
114
+ // A `:` not followed by a valid name start is literal text, not a malformed parameter.
115
+ if (name === undefined) {
116
+ literal += ":"
117
+ continue
118
+ }
119
+ const paramEnd = i + 1 + name.length
120
+ const previous = i > 0 ? value[i - 1] : undefined
121
+ const precededByBoundary = previous === undefined || !/[A-Za-z0-9_]/.test(previous)
122
+ // Preserve established RPC-style literals such as `things:batchGet`. A colon embedded after an
123
+ // identifier and running to the end is literal; mixed params remain unambiguous at segment start,
124
+ // after punctuation (`post-:id`), or when followed by a literal suffix (`v:major.json`).
125
+ if (!precededByBoundary && paramEnd === value.length) {
126
+ literal += `:${name}`
127
+ i += name.length
128
+ continue
129
+ }
130
+ if (literal !== "") {
131
+ parts.push({ t: "lit", v: literal })
132
+ literal = ""
133
+ }
134
+ parts.push({ t: "param", name })
135
+ sawParam = true
136
+ i += name.length
137
+ }
138
+ if (!sawParam) return undefined
139
+ if (literal !== "") parts.push({ t: "lit", v: literal })
140
+ return parts
141
+ }
142
+
143
+ /** The regex source matching one segment's worth of a mixed pattern, with a capture per parameter. */
144
+ export function mixedSegmentSource(parts: readonly MixedPart[]): string {
145
+ let source = ""
146
+ for (const part of parts) {
147
+ // LAZY (`+?`), not greedy. A greedy capture swallows the trailing literal, so `/:key.txt` against
148
+ // `/abc.txt` would capture `abc.txt` and then fail to match `\.txt`. With `^…$` anchoring, the
149
+ // lazy form still yields `abc.txt` for `/abc.txt.txt` - the anchor forces the LAST `.txt` to be
150
+ // the literal. `+?` and not `*?`: an empty capture must not match (see the empty-segment rule).
151
+ source += part.t === "lit" ? escapeRegex(part.v) : "([^/]+?)"
152
+ }
153
+ return source
154
+ }
155
+
64
156
  /** Parse and validate Nifra's strict route grammar once. Trailing slashes remain significant. */
65
157
  export function compileRoutePattern(pattern: string): CompiledRoutePattern {
66
158
  if (pattern.length === 0 || pattern.charCodeAt(0) !== 47 /* / */) {
@@ -71,14 +163,20 @@ export function compileRoutePattern(pattern: string): CompiledRoutePattern {
71
163
  const paramNames: string[] = []
72
164
  for (let i = 0; i < raw.length; i++) {
73
165
  const value = raw[i]!
74
- if (value.charCodeAt(0) === 58 /* : */) {
75
- const name = value.slice(1)
76
- if (!validParamName(name)) {
166
+ // A leading `:` whose name run spans the WHOLE segment is a plain parameter - the untouched fast
167
+ // path. When it does not (`:key.txt`), the segment is mixed and is handled below; but a leading
168
+ // `:` that yields no usable name at all (`:9lives`, `:`) was clearly meant as a parameter, so it
169
+ // still fails here rather than being silently reinterpreted as literal text.
170
+ if (value.charCodeAt(0) === 58 /* : */ && !validParamName(value.slice(1))) {
171
+ if (splitMixed(value) === undefined) {
172
+ const name = value.slice(1)
77
173
  throw new RouteConfigError(
78
174
  "INVALID_PARAM_NAME",
79
- `invalid parameter ":${name}" in "${pattern}"`,
175
+ `invalid parameter ":${name}" in "${pattern}"${paramNameHint(name)}`,
80
176
  )
81
177
  }
178
+ } else if (value.charCodeAt(0) === 58 /* : */) {
179
+ const name = value.slice(1)
82
180
  if (paramNames.includes(name)) {
83
181
  throw new RouteConfigError(
84
182
  "DUPLICATE_PARAM",
@@ -113,6 +211,32 @@ export function compileRoutePattern(pattern: string): CompiledRoutePattern {
113
211
  segments.push(Object.freeze({ kind: "wildcard", name }))
114
212
  continue
115
213
  }
214
+ // A segment holding a `:` anywhere other than the front is part literal, part parameter. Checked
215
+ // last so the wholly-static and wholly-param fast paths above are untouched.
216
+ const mixed = value.includes(":") ? splitMixed(value) : undefined
217
+ if (mixed !== undefined) {
218
+ for (const part of mixed) {
219
+ if (part.t !== "param") continue
220
+ if (!validParamName(part.name)) {
221
+ throw new RouteConfigError(
222
+ "INVALID_PARAM_NAME",
223
+ `invalid parameter ":${part.name}" in "${pattern}"${paramNameHint(part.name)}`,
224
+ )
225
+ }
226
+ // Duplicates must be rejected WITHIN a segment (`/:a.:a`) as well as across segments,
227
+ // or two captures would race for one params key.
228
+ if (paramNames.includes(part.name)) {
229
+ throw new RouteConfigError(
230
+ "DUPLICATE_PARAM",
231
+ `duplicate parameter ":${part.name}" in "${pattern}"`,
232
+ )
233
+ }
234
+ // Pushed left-to-right so `paramNames` stays aligned with regex capture order.
235
+ paramNames.push(part.name)
236
+ }
237
+ segments.push(Object.freeze({ kind: "mixed", parts: Object.freeze(mixed) }))
238
+ continue
239
+ }
116
240
  segments.push(Object.freeze({ kind: "static", value }))
117
241
  }
118
242
  const compiled = Object.freeze({
@@ -124,7 +248,50 @@ export function compileRoutePattern(pattern: string): CompiledRoutePattern {
124
248
  return compiled
125
249
  }
126
250
 
127
- /** Core precedence: static > param > wildcard at the first differing segment, independent of order. */
251
+ /** Weight for {@link compareRoutePatternSpecificity}. A mixed segment constrains more than a bare
252
+ * param (it pins literal text) and less than a fully static one, so it sits between them. */
253
+ const SPECIFICITY: Readonly<Record<RoutePatternSegment["kind"], number>> = {
254
+ static: 4,
255
+ mixed: 3,
256
+ param: 2,
257
+ wildcard: 1,
258
+ }
259
+
260
+ /**
261
+ * Total ordering for mixed segment shapes, shared by the trie and regex-based routers.
262
+ *
263
+ * Code-unit comparison, never `localeCompare`: this exists so the server and the browser agree on which
264
+ * of two equally-weighted patterns wins, and `localeCompare` answers by the RUNTIME's locale - a server
265
+ * and a visitor in different locales would order the same pair differently, which is the divergence
266
+ * being prevented.
267
+ */
268
+ export function compareMixedPartsSpecificity(
269
+ left: readonly MixedPart[],
270
+ right: readonly MixedPart[],
271
+ ): number {
272
+ const literalWeight = (parts: readonly MixedPart[]): number =>
273
+ parts.reduce((sum, part) => (part.t === "lit" ? sum + part.v.length : sum), 0)
274
+ const order = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
275
+
276
+ const weightDifference = literalWeight(right) - literalWeight(left)
277
+ if (weightDifference !== 0) return weightDifference
278
+
279
+ const length = Math.max(left.length, right.length)
280
+ for (let i = 0; i < length; i++) {
281
+ const a = left[i]
282
+ const b = right[i]
283
+ if (a === undefined || b === undefined) return b === undefined ? -1 : 1
284
+ if (a.t !== b.t) return a.t === "lit" ? -1 : 1
285
+ if (a.t === "lit" && b.t === "lit" && a.v !== b.v) {
286
+ return a.v.length === b.v.length ? order(a.v, b.v) : b.v.length - a.v.length
287
+ }
288
+ }
289
+
290
+ return order(mixedSegmentSource(left), mixedSegmentSource(right))
291
+ }
292
+
293
+ /** Core precedence: static > mixed > param > wildcard at the first differing segment, independent of
294
+ * registration order. */
128
295
  export function compareRoutePatternSpecificity(
129
296
  left: CompiledRoutePattern,
130
297
  right: CompiledRoutePattern,
@@ -134,9 +301,13 @@ export function compareRoutePatternSpecificity(
134
301
  const a = left.segments[i]
135
302
  const b = right.segments[i]
136
303
  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
304
+ const aWeight = SPECIFICITY[a.kind]
305
+ const bWeight = SPECIFICITY[b.kind]
139
306
  if (aWeight !== bWeight) return bWeight - aWeight
307
+ if (a.kind === "mixed" && b.kind === "mixed") {
308
+ const mixed = compareMixedPartsSpecificity(a.parts, b.parts)
309
+ if (mixed !== 0) return mixed
310
+ }
140
311
  }
141
312
  return 0
142
313
  }