@nifrajs/core 2.2.0 → 2.4.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 (136) 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 +9 -0
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +13 -4
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.js +3 -3
  53. package/dist/schema/standard.d.ts +2 -2
  54. package/dist/schema/standard.js +2 -2
  55. package/dist/seo.d.ts +3 -3
  56. package/dist/seo.js +4 -4
  57. package/dist/server/body.d.ts +2 -2
  58. package/dist/server/body.js +2 -2
  59. package/dist/server/context.d.ts +18 -18
  60. package/dist/server/contract.d.ts +7 -7
  61. package/dist/server/contract.js +3 -3
  62. package/dist/server/cookies.d.ts +4 -4
  63. package/dist/server/cookies.js +8 -8
  64. package/dist/server/edge.d.ts +83 -0
  65. package/dist/server/edge.d.ts.map +1 -0
  66. package/dist/server/edge.js +71 -0
  67. package/dist/server/edge.js.map +1 -0
  68. package/dist/server/install.d.ts +2 -0
  69. package/dist/server/install.d.ts.map +1 -1
  70. package/dist/server/install.js +2 -0
  71. package/dist/server/install.js.map +1 -1
  72. package/dist/server/logger.d.ts +7 -7
  73. package/dist/server/logger.js +4 -4
  74. package/dist/server/registry.d.ts +5 -5
  75. package/dist/server/response-contract-lane.d.ts +91 -0
  76. package/dist/server/response-contract-lane.d.ts.map +1 -0
  77. package/dist/server/response-contract-lane.js +81 -0
  78. package/dist/server/response-contract-lane.js.map +1 -0
  79. package/dist/server/server.d.ts +52 -91
  80. package/dist/server/server.d.ts.map +1 -1
  81. package/dist/server/server.js +130 -186
  82. package/dist/server/server.js.map +1 -1
  83. package/dist/server/sse.d.ts +4 -4
  84. package/dist/server/sse.js +7 -7
  85. package/dist/server/webhook.d.ts +1 -1
  86. package/dist/server/webhook.js +5 -5
  87. package/dist/server/websocket.d.ts +19 -19
  88. package/dist/server/websocket.js +9 -9
  89. package/dist/server/ws-bun.d.ts +4 -4
  90. package/dist/server/ws-bun.js +3 -3
  91. package/dist/server.d.ts +3 -1
  92. package/dist/server.d.ts.map +1 -1
  93. package/dist/server.js +3 -1
  94. package/dist/server.js.map +1 -1
  95. package/dist/transport-codec.d.ts +12 -0
  96. package/dist/transport-codec.d.ts.map +1 -1
  97. package/dist/transport-codec.js +19 -4
  98. package/dist/transport-codec.js.map +1 -1
  99. package/package.json +12 -2
  100. package/src/assurance.ts +154 -8
  101. package/src/binary.ts +128 -0
  102. package/src/budget.ts +1 -1
  103. package/src/capabilities.ts +71 -9
  104. package/src/classification.ts +1 -1
  105. package/src/diff.ts +8 -8
  106. package/src/durable-adapters.ts +40 -70
  107. package/src/durable-execution.ts +2 -2
  108. package/src/idempotency.ts +4 -4
  109. package/src/index.ts +3 -3
  110. package/src/internal/brand.ts +1 -1
  111. package/src/internal/route-catalog.ts +94 -0
  112. package/src/internal/route-execution.ts +106 -0
  113. package/src/internal/same-origin.ts +43 -0
  114. package/src/internal/secret.ts +1 -1
  115. package/src/ledger.ts +11 -11
  116. package/src/reflection.ts +1 -1
  117. package/src/router/pattern.ts +16 -4
  118. package/src/router/router.ts +5 -5
  119. package/src/schema/standard.ts +2 -2
  120. package/src/seo.ts +6 -6
  121. package/src/server/body.ts +2 -2
  122. package/src/server/context.ts +18 -18
  123. package/src/server/contract.ts +13 -13
  124. package/src/server/cookies.ts +8 -8
  125. package/src/server/edge.ts +150 -0
  126. package/src/server/install.ts +5 -0
  127. package/src/server/logger.ts +8 -8
  128. package/src/server/registry.ts +5 -5
  129. package/src/server/response-contract-lane.ts +164 -0
  130. package/src/server/server.ts +159 -366
  131. package/src/server/sse.ts +11 -11
  132. package/src/server/webhook.ts +5 -5
  133. package/src/server/websocket.ts +22 -22
  134. package/src/server/ws-bun.ts +4 -4
  135. package/src/server.ts +8 -5
  136. package/src/transport-codec.ts +24 -5
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.2.0" as const
13
+ export const VERSION = "2.4.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>>
@@ -71,17 +71,17 @@ function validParamName(name: string): boolean {
71
71
  *
72
72
  * The grammar is per-segment: a segment is wholly static or wholly a parameter, so everything after
73
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
74
+ * which is not what anyone means by it - but the bare "invalid parameter" that produces reads as a
75
75
  * typo rather than as a rule, leaving the author to guess whether the dot, the length, or the casing
76
76
  * was the problem. Naming the actual limitation and showing the two ways out is the difference
77
77
  * between a five-second fix and a trip to the source.
78
78
  */
79
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`
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
82
  // No "a segment is wholly static or wholly a parameter" case here any more: mixed segments made
83
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}`
84
+ return ` - a name must match ${PARAM_NAME.source}`
85
85
  }
86
86
 
87
87
  function escapeRegex(value: string): string {
@@ -312,6 +312,18 @@ export function compareRoutePatternSpecificity(
312
312
  return 0
313
313
  }
314
314
 
315
+ /**
316
+ * Sort compiled routes most-specific-first - a static segment beats a dynamic one, the order the router
317
+ * resolves a path in. The single home for that precedence: the web router, the mock server, and the
318
+ * editor plugin all order routes through this one comparator, so which file a path resolves to can never
319
+ * diverge between runtime, client, and editor. Sorts in place and returns the same array.
320
+ */
321
+ export function sortRoutesBySpecificity<T extends { readonly pattern: CompiledRoutePattern }>(
322
+ routes: T[],
323
+ ): T[] {
324
+ return routes.sort((left, right) => compareRoutePatternSpecificity(left.pattern, right.pattern))
325
+ }
326
+
315
327
  /** Decode router captures under one rule. Plain values take the zero-allocation path; malformed
316
328
  * escapes return `null`, allowing HTTP to emit 400 while client navigation declines the match. */
317
329
  export function decodeRouteParams(raw: Record<string, string>): Record<string, string> | null {
@@ -35,7 +35,7 @@ export type RouterMatch<T> =
35
35
  /**
36
36
  * Payload at a terminal node: per-method handlers plus the ordered param names
37
37
  * for this path shape. Bundling them means "a node that has handlers always has
38
- * param names" is enforced by the type, not by a runtime invariant — which
38
+ * param names" is enforced by the type, not by a runtime invariant - which
39
39
  * removes the otherwise-dead defensive branches in `find`.
40
40
  */
41
41
  interface Terminal<T> {
@@ -85,7 +85,7 @@ interface RouteNode<T> {
85
85
  * no allocation, and one `undefined` check on the match path.
86
86
  */
87
87
  mixedChildren: MixedChild<T>[] | undefined
88
- /** Catch-all `*` child, if any — always terminal. */
88
+ /** Catch-all `*` child, if any - always terminal. */
89
89
  wildcardChild: RouteNode<T> | undefined
90
90
  /** Set once this node terminates one or more routes. */
91
91
  terminal: Terminal<T> | undefined
@@ -129,7 +129,7 @@ function sameNames(a: readonly string[], b: readonly string[]): boolean {
129
129
  * instead of pre-splitting it into an array. `start` is the index of the first
130
130
  * char of the current segment; the segment runs to the next `/` (or end). Tries
131
131
  * static, then param, then wildcard; if a static branch dead-ends, param/wildcard
132
- * are still attempted — so `/a/b/c` and `/a/:x/d` coexist and `/a/b/d` resolves
132
+ * are still attempted - so `/a/b/c` and `/a/:x/d` coexist and `/a/b/d` resolves
133
133
  * to the param route. Fills `paramValues` along the successful path; abandoned
134
134
  * branches are popped. Avoiding the split keeps `find` allocation-light: only the
135
135
  * segment substrings needed for map lookups (and matched param values) are cut.
@@ -179,9 +179,9 @@ function matchNode<T>(
179
179
  }
180
180
 
181
181
  const paramChild = node.paramChild
182
- // An empty segment (`/users//posts`) never matches a `:param` — matching would hand the handler
182
+ // An empty segment (`/users//posts`) never matches a `:param` - matching would hand the handler
183
183
  // `id: ""` and downstream code a `WHERE id = ''` class of bug. Peers (Hono, Elysia, Next) 404
184
- // here too; a static "" child can still match (none is ever registered — INVALID_PATH guards).
184
+ // here too; a static "" child can still match (none is ever registered - INVALID_PATH guards).
185
185
  if (paramChild !== undefined && seg.length > 0) {
186
186
  paramValues.push(seg)
187
187
  if (isLast) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The Standard Schema v1 interface (https://standardschema.dev), vendored as
3
- * types + a tiny runtime helper so any compliant validator — zod, valibot,
4
- * arktype, … — validates requests without coupling the framework to one lib.
3
+ * types + a tiny runtime helper so any compliant validator - zod, valibot,
4
+ * arktype, … - validates requests without coupling the framework to one lib.
5
5
  * The spec is MIT-licensed and explicitly designed to be copied.
6
6
  */
7
7
 
package/src/seo.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `sitemap.xml` + `robots.txt` builders — pure, edge-safe string generators (no I/O, no runtime deps).
2
+ * `sitemap.xml` + `robots.txt` builders - pure, edge-safe string generators (no I/O, no runtime deps).
3
3
  * Wire them to a route on any runtime:
4
4
  *
5
5
  * app.get("/sitemap.xml", () =>
@@ -21,7 +21,7 @@ const XML_ESCAPES: Readonly<Record<string, string>> = {
21
21
  "'": "&apos;",
22
22
  }
23
23
 
24
- /** Escape the five XML metacharacters — `<loc>` and `<lastmod>` carry app- or DB-derived strings. */
24
+ /** Escape the five XML metacharacters - `<loc>` and `<lastmod>` carry app- or DB-derived strings. */
25
25
  function escapeXml(value: string): string {
26
26
  return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] as string)
27
27
  }
@@ -48,10 +48,10 @@ const CHANGE_FREQS: ReadonlySet<string> = new Set([
48
48
  export interface SitemapEntry {
49
49
  /** An absolute URL, or a path that `hostname` will make absolute. */
50
50
  readonly url: string
51
- /** Last-modified — a `Date` (serialized as ISO 8601) or a pre-formatted W3C-datetime string. */
51
+ /** Last-modified - a `Date` (serialized as ISO 8601) or a pre-formatted W3C-datetime string. */
52
52
  readonly lastmod?: string | Date
53
53
  readonly changefreq?: SitemapChangeFreq
54
- /** Crawl priority, `0.0`–`1.0`. */
54
+ /** Crawl priority, `0.0`-`1.0`. */
55
55
  readonly priority?: number
56
56
  }
57
57
 
@@ -67,7 +67,7 @@ const SITEMAP_MAX_URLS = 50_000
67
67
  export function sitemap(entries: readonly SitemapEntry[], options: SitemapOptions = {}): string {
68
68
  if (entries.length > SITEMAP_MAX_URLS) {
69
69
  throw new RangeError(
70
- `sitemap: ${entries.length} entries exceeds the ${SITEMAP_MAX_URLS}-URL per-file limit — split into a sitemap index`,
70
+ `sitemap: ${entries.length} entries exceeds the ${SITEMAP_MAX_URLS}-URL per-file limit - split into a sitemap index`,
71
71
  )
72
72
  }
73
73
  const base = options.hostname?.replace(/\/+$/, "")
@@ -96,7 +96,7 @@ export function sitemap(entries: readonly SitemapEntry[], options: SitemapOption
96
96
 
97
97
  function resolveLoc(url: string, base: string | undefined): string {
98
98
  if (/^https?:\/\//i.test(url)) return url // already absolute
99
- if (base === undefined) return url // relative — caller's choice (sitemaps prefer absolute)
99
+ if (base === undefined) return url // relative - caller's choice (sitemaps prefer absolute)
100
100
  return `${base}${url.startsWith("/") ? "" : "/"}${url}`
101
101
  }
102
102
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Bounded request-body reading — the single source of truth for nifra's body-size cap. A lying or
2
+ * Bounded request-body reading - the single source of truth for nifra's body-size cap. A lying or
3
3
  * absent `Content-Length` can't force us to buffer an oversized payload: a declared length over the
4
4
  * cap is rejected *before* buffering, and a chunked / length-less body is aborted mid-stream once the
5
5
  * running byte count exceeds the cap. Shared by the server's schema path, `c.boundedBody`, and
@@ -26,7 +26,7 @@ export function parseContentLength(value: string): number | undefined {
26
26
 
27
27
  /** The shared streaming byte-cap loop: read until done, or cancel + 413 once over `maxBytes`.
28
28
  * A single-chunk body (the common case for small chunked payloads) returns the runtime's own
29
- * chunk directly — no chunk array, no copy-merge. */
29
+ * chunk directly - no chunk array, no copy-merge. */
30
30
  export async function drainCapped(
31
31
  body: ReadableStream<Uint8Array>,
32
32
  maxBytes: number,