@nifrajs/core 0.1.0-alpha.1

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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/errors.d.ts +21 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +26 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/index.d.ts +26 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +21 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/internal/brand.d.ts +11 -0
  12. package/dist/internal/brand.d.ts.map +1 -0
  13. package/dist/internal/brand.js +10 -0
  14. package/dist/internal/brand.js.map +1 -0
  15. package/dist/internal/secret.d.ts +9 -0
  16. package/dist/internal/secret.d.ts.map +1 -0
  17. package/dist/internal/secret.js +19 -0
  18. package/dist/internal/secret.js.map +1 -0
  19. package/dist/router/router.d.ts +61 -0
  20. package/dist/router/router.d.ts.map +1 -0
  21. package/dist/router/router.js +324 -0
  22. package/dist/router/router.js.map +1 -0
  23. package/dist/schema/standard.d.ts +46 -0
  24. package/dist/schema/standard.d.ts.map +1 -0
  25. package/dist/schema/standard.js +20 -0
  26. package/dist/schema/standard.js.map +1 -0
  27. package/dist/seo.d.ts +48 -0
  28. package/dist/seo.d.ts.map +1 -0
  29. package/dist/seo.js +109 -0
  30. package/dist/seo.js.map +1 -0
  31. package/dist/server/body.d.ts +39 -0
  32. package/dist/server/body.d.ts.map +1 -0
  33. package/dist/server/body.js +85 -0
  34. package/dist/server/body.js.map +1 -0
  35. package/dist/server/context.d.ts +111 -0
  36. package/dist/server/context.d.ts.map +1 -0
  37. package/dist/server/context.js +2 -0
  38. package/dist/server/context.js.map +1 -0
  39. package/dist/server/contract.d.ts +142 -0
  40. package/dist/server/contract.d.ts.map +1 -0
  41. package/dist/server/contract.js +59 -0
  42. package/dist/server/contract.js.map +1 -0
  43. package/dist/server/cookies.d.ts +41 -0
  44. package/dist/server/cookies.d.ts.map +1 -0
  45. package/dist/server/cookies.js +181 -0
  46. package/dist/server/cookies.js.map +1 -0
  47. package/dist/server/logger.d.ts +49 -0
  48. package/dist/server/logger.d.ts.map +1 -0
  49. package/dist/server/logger.js +125 -0
  50. package/dist/server/logger.js.map +1 -0
  51. package/dist/server/registry.d.ts +46 -0
  52. package/dist/server/registry.d.ts.map +1 -0
  53. package/dist/server/registry.js +2 -0
  54. package/dist/server/registry.js.map +1 -0
  55. package/dist/server/server.d.ts +528 -0
  56. package/dist/server/server.d.ts.map +1 -0
  57. package/dist/server/server.js +1660 -0
  58. package/dist/server/server.js.map +1 -0
  59. package/dist/server/sse.d.ts +54 -0
  60. package/dist/server/sse.d.ts.map +1 -0
  61. package/dist/server/sse.js +126 -0
  62. package/dist/server/sse.js.map +1 -0
  63. package/dist/server/webhook.d.ts +42 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +149 -0
  66. package/dist/server/webhook.js.map +1 -0
  67. package/dist/server/websocket.d.ts +152 -0
  68. package/dist/server/websocket.d.ts.map +1 -0
  69. package/dist/server/websocket.js +178 -0
  70. package/dist/server/websocket.js.map +1 -0
  71. package/dist/server/ws-bun.d.ts +27 -0
  72. package/dist/server/ws-bun.d.ts.map +1 -0
  73. package/dist/server/ws-bun.js +82 -0
  74. package/dist/server/ws-bun.js.map +1 -0
  75. package/dist/server/ws-hook.d.ts +36 -0
  76. package/dist/server/ws-hook.d.ts.map +1 -0
  77. package/dist/server/ws-hook.js +18 -0
  78. package/dist/server/ws-hook.js.map +1 -0
  79. package/dist/ws.d.ts +19 -0
  80. package/dist/ws.d.ts.map +1 -0
  81. package/dist/ws.js +26 -0
  82. package/dist/ws.js.map +1 -0
  83. package/package.json +63 -0
  84. package/src/errors.ts +41 -0
  85. package/src/index.ts +115 -0
  86. package/src/internal/brand.ts +11 -0
  87. package/src/internal/secret.ts +24 -0
  88. package/src/router/router.ts +429 -0
  89. package/src/schema/standard.ts +76 -0
  90. package/src/seo.ts +155 -0
  91. package/src/server/body.ts +89 -0
  92. package/src/server/context.ts +124 -0
  93. package/src/server/contract.ts +209 -0
  94. package/src/server/cookies.ts +198 -0
  95. package/src/server/logger.ts +167 -0
  96. package/src/server/registry.ts +63 -0
  97. package/src/server/server.ts +2479 -0
  98. package/src/server/sse.ts +161 -0
  99. package/src/server/webhook.ts +207 -0
  100. package/src/server/websocket.ts +308 -0
  101. package/src/server/ws-bun.ts +106 -0
  102. package/src/server/ws-hook.ts +53 -0
  103. package/src/ws.ts +40 -0
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@nifrajs/core",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Bun-native, contract-first HTTP framework — the router, server, and route descriptor.",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "edge",
12
+ "ssr",
13
+ "http",
14
+ "router",
15
+ "api",
16
+ "standard-schema",
17
+ "typesafe",
18
+ "backend"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "module": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "bun": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./ws": {
31
+ "bun": "./src/ws.ts",
32
+ "types": "./dist/ws.d.ts",
33
+ "default": "./dist/ws.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "sideEffects": [
41
+ "./src/ws.ts",
42
+ "./dist/ws.js"
43
+ ],
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
46
+ },
47
+ "license": "MIT",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "devDependencies": {
52
+ "@nifrajs/test-utils": "workspace:*"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/nifrajs/nifra.git",
57
+ "directory": "packages/core"
58
+ },
59
+ "bugs": {
60
+ "url": "https://github.com/nifrajs/nifra/issues"
61
+ },
62
+ "homepage": "https://github.com/nifrajs/nifra#readme"
63
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { FRAMEWORK_NAME } from "./internal/brand.ts"
2
+
3
+ /**
4
+ * Base class for every error the framework throws. Carries a stable, string
5
+ * `code` so callers can branch on the failure programmatically rather than
6
+ * matching on message text. Messages are prefixed with the brand name.
7
+ */
8
+ export class FrameworkError extends Error {
9
+ readonly code: string
10
+
11
+ constructor(code: string, message: string) {
12
+ super(`[${FRAMEWORK_NAME}] ${message}`)
13
+ this.name = "FrameworkError"
14
+ this.code = code
15
+ }
16
+ }
17
+
18
+ /** Stable codes for boot-time (L2) route configuration failures. */
19
+ export type RouteConfigErrorCode =
20
+ | "DUPLICATE_ROUTE"
21
+ | "DUPLICATE_PARAM"
22
+ | "PARAM_NAME_CONFLICT"
23
+ | "INVALID_PATH"
24
+ | "INVALID_PARAM_NAME"
25
+ | "WILDCARD_NOT_LAST"
26
+ | "INVALID_METHOD"
27
+
28
+ /**
29
+ * Thrown at route registration when a route is misconfigured. This is the
30
+ * boot-time rejection layer: loud and early,
31
+ * never deferred to the first request.
32
+ */
33
+ export class RouteConfigError extends FrameworkError {
34
+ // Narrows the inherited `code` to the closed union without re-initializing it.
35
+ declare readonly code: RouteConfigErrorCode
36
+
37
+ constructor(code: RouteConfigErrorCode, message: string) {
38
+ super(code, message)
39
+ this.name = "RouteConfigError"
40
+ }
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @nifrajs/core — Bun-native, contract-first HTTP framework.
3
+ *
4
+ * The router is the perf-critical heart; the server and inline API are built on
5
+ * the unified route descriptor.
6
+ */
7
+
8
+ /** Current package version. Kept as a literal so type-level tests can pin it. */
9
+ export const VERSION = "0.0.0" as const
10
+
11
+ export type Version = typeof VERSION
12
+
13
+ export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
14
+ export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
15
+ export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
16
+ export type {
17
+ InferInput,
18
+ InferOutput,
19
+ StandardIssue,
20
+ StandardResult,
21
+ StandardSchemaV1,
22
+ StandardTypes,
23
+ ValidationOutcome,
24
+ } from "./schema/standard.ts"
25
+ export { validateStandard } from "./schema/standard.ts"
26
+ export {
27
+ type RobotsOptions,
28
+ type RobotsRule,
29
+ robots,
30
+ type SitemapChangeFreq,
31
+ type SitemapEntry,
32
+ type SitemapOptions,
33
+ sitemap,
34
+ } from "./seo.ts"
35
+ export type {
36
+ Context,
37
+ Params,
38
+ Platform,
39
+ Prettify,
40
+ ResponseControls,
41
+ RouteSchema,
42
+ } from "./server/context.ts"
43
+ export {
44
+ type ContextForOp,
45
+ type ContractShape,
46
+ defineContract,
47
+ type HandlersFor,
48
+ implement,
49
+ type OperationDef,
50
+ type RegistryFor,
51
+ type RegistryFromImpl,
52
+ type ResponseDef,
53
+ } from "./server/contract.ts"
54
+ export {
55
+ type CookieOptions,
56
+ parseCookies,
57
+ serializeCookie,
58
+ signValue,
59
+ unsignValue,
60
+ } from "./server/cookies.ts"
61
+ export {
62
+ commonSecretPatterns,
63
+ jsonLogger,
64
+ type LogFields,
65
+ type Logger,
66
+ type RedactOptions,
67
+ redactLogFields,
68
+ silentLogger,
69
+ } from "./server/logger.ts"
70
+ export type { Registry, RouteInfo } from "./server/registry.ts"
71
+ export {
72
+ type AnyServer,
73
+ type DurableObjectNamespaceLike,
74
+ defineIdentityPlugin,
75
+ definePlugin,
76
+ type ExecutionContext,
77
+ type Handler,
78
+ type IdentityPlugin,
79
+ type Middleware,
80
+ type NifraPlugin,
81
+ type NodeServeOutcome,
82
+ type OnRequestResult,
83
+ type RouteDescriptor,
84
+ type RunningServer,
85
+ type ScheduledController,
86
+ type ScheduledHandler,
87
+ Server,
88
+ type ServerOptions,
89
+ server,
90
+ toFetchHandler,
91
+ } from "./server/server.ts"
92
+ export {
93
+ type SSEContext,
94
+ type SSEInit,
95
+ type SSEMessage,
96
+ type SSEStream,
97
+ sse,
98
+ } from "./server/sse.ts"
99
+ export {
100
+ type SignatureEncoding,
101
+ type VerifyWebhookOptions,
102
+ verifyWebhook,
103
+ type WebhookFailureReason,
104
+ type WebhookProvider,
105
+ type WebhookResult,
106
+ } from "./server/webhook.ts"
107
+ export type {
108
+ NifraWebSocket,
109
+ StandardWebSocket,
110
+ WebSocketContext,
111
+ WebSocketData,
112
+ WebSocketHandler,
113
+ WebSocketUpgradeOutcome,
114
+ } from "./server/websocket.ts"
115
+ export { attachWebSocket, TopicRegistry } from "./server/websocket.ts"
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Single source of truth for the framework's user-facing name.
3
+ *
4
+ * Every developer-facing string (error prefixes, the default OpenAPI title, the
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
7
+ * both at once.
8
+ */
9
+ export const FRAMEWORK_NAME = "Nifra" as const
10
+
11
+ export type FrameworkName = typeof FRAMEWORK_NAME
@@ -0,0 +1,24 @@
1
+ import { FRAMEWORK_NAME } from "./brand.ts"
2
+
3
+ /** Minimum HMAC secret strength: 256 bits, matching the SHA-256 block the framework signs with.
4
+ * A shorter key weakens every signature it backs (cookies, sessions, webhooks, signed URLs). */
5
+ const MIN_SECRET_BYTES = 32
6
+
7
+ const SECRET_ENCODER = new TextEncoder()
8
+
9
+ /**
10
+ * Boot-time guard: reject an HMAC secret under 256 bits, loudly, at construction — never at the
11
+ * first request. UTF-8 byte length (not char count), so a multibyte secret is measured honestly.
12
+ * The single source of truth for the framework's secret-strength floor; every HMAC entry point
13
+ * (signed cookies, `@nifrajs/auth` sessions, webhook verification, signed upload/image URLs) routes
14
+ * its secret through here so the bar can't drift between them.
15
+ */
16
+ export function requireSecretBytes(secret: string | Uint8Array, label: string): void {
17
+ const length =
18
+ typeof secret === "string" ? SECRET_ENCODER.encode(secret).length : secret.byteLength
19
+ if (length < MIN_SECRET_BYTES) {
20
+ throw new Error(
21
+ `[${FRAMEWORK_NAME}] ${label} secret must be at least ${MIN_SECRET_BYTES} bytes (256-bit); got ${length}. Generate one with: openssl rand -base64 32`,
22
+ )
23
+ }
24
+ }
@@ -0,0 +1,429 @@
1
+ import { RouteConfigError } from "../errors.ts"
2
+
3
+ /** HTTP methods the router accepts. */
4
+ export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const
5
+
6
+ export type Method = (typeof METHODS)[number]
7
+
8
+ const METHOD_SET: ReadonlySet<string> = new Set(METHODS)
9
+
10
+ function isMethod(value: string): value is Method {
11
+ return METHOD_SET.has(value)
12
+ }
13
+
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
+ const SLASH = 47
30
+ const COLON = 58
31
+ const STAR = 42
32
+ export const EMPTY_PARAMS: Record<string, string> = Object.freeze({})
33
+ const DYNAMIC_MATCH_CACHE_MAX = 2048
34
+
35
+ /**
36
+ * Result of {@link Router.find}. The `found: false` cases deliberately separate
37
+ * a missing path (404) from a path that exists for other methods (405), so the
38
+ * server layer can answer correctly and populate an `Allow` header.
39
+ */
40
+ export type RouterMatch<T> =
41
+ | { readonly found: true; readonly payload: T; readonly params: Record<string, string> }
42
+ | { readonly found: false; readonly reason: "not-found" }
43
+ | { readonly found: false; readonly reason: "method-not-allowed"; readonly allowed: string[] }
44
+
45
+ /**
46
+ * Payload at a terminal node: per-method handlers plus the ordered param names
47
+ * for this path shape. Bundling them means "a node that has handlers always has
48
+ * param names" is enforced by the type, not by a runtime invariant — which
49
+ * removes the otherwise-dead defensive branches in `find`.
50
+ */
51
+ interface Terminal<T> {
52
+ readonly handlers: Map<string, T>
53
+ readonly paramNames: readonly string[]
54
+ readonly staticMatches?: Map<string, RouterMatch<T>>
55
+ }
56
+
57
+ interface DynamicMatchCacheEntry<T> {
58
+ readonly terminal: Terminal<T>
59
+ readonly values: readonly string[]
60
+ }
61
+
62
+ interface DynamicMatchCacheState<T> {
63
+ cache: Map<string, DynamicMatchCacheEntry<T>> | undefined
64
+ pendingPath: string | undefined
65
+ }
66
+
67
+ const DYNAMIC_MATCH_CACHES = new WeakMap<object, DynamicMatchCacheState<unknown>>()
68
+
69
+ function dynamicMatchCacheStateFor<T>(owner: object): DynamicMatchCacheState<T> {
70
+ const existing = DYNAMIC_MATCH_CACHES.get(owner)
71
+ if (existing !== undefined) {
72
+ return existing as DynamicMatchCacheState<T>
73
+ }
74
+ const state: DynamicMatchCacheState<T> = {
75
+ cache: undefined,
76
+ pendingPath: undefined,
77
+ }
78
+ DYNAMIC_MATCH_CACHES.set(owner, state as DynamicMatchCacheState<unknown>)
79
+ return state
80
+ }
81
+
82
+ interface RouteNode<T> {
83
+ /** Exact-segment children keyed by literal segment. */
84
+ readonly staticChildren: Map<string, RouteNode<T>>
85
+ /** Single `:param` child, if any. */
86
+ paramChild: RouteNode<T> | undefined
87
+ /** Catch-all `*` child, if any — always terminal. */
88
+ wildcardChild: RouteNode<T> | undefined
89
+ /** Set once this node terminates one or more routes. */
90
+ terminal: Terminal<T> | undefined
91
+ }
92
+
93
+ function createNode<T>(): RouteNode<T> {
94
+ return {
95
+ staticChildren: new Map(),
96
+ paramChild: undefined,
97
+ wildcardChild: undefined,
98
+ terminal: undefined,
99
+ }
100
+ }
101
+
102
+ function sameNames(a: readonly string[], b: readonly string[]): boolean {
103
+ if (a.length !== b.length) return false
104
+ for (let i = 0; i < a.length; i++) {
105
+ if (a[i] !== b[i]) return false
106
+ }
107
+ return true
108
+ }
109
+
110
+ /**
111
+ * Depth-first match with backtracking, walking the path by character offsets
112
+ * instead of pre-splitting it into an array. `start` is the index of the first
113
+ * char of the current segment; the segment runs to the next `/` (or end). Tries
114
+ * static, then param, then wildcard; if a static branch dead-ends, param/wildcard
115
+ * are still attempted — so `/a/b/c` and `/a/:x/d` coexist and `/a/b/d` resolves
116
+ * to the param route. Fills `paramValues` along the successful path; abandoned
117
+ * branches are popped. Avoiding the split keeps `find` allocation-light: only the
118
+ * segment substrings needed for map lookups (and matched param values) are cut.
119
+ */
120
+ function matchNode<T>(
121
+ node: RouteNode<T>,
122
+ path: string,
123
+ start: number,
124
+ len: number,
125
+ paramValues: string[],
126
+ ): Terminal<T> | undefined {
127
+ const slash = path.indexOf("/", start)
128
+ const isLast = slash === -1
129
+ const end = isLast ? len : slash
130
+ const seg = path.slice(start, end)
131
+
132
+ const staticChild = node.staticChildren.get(seg)
133
+ if (staticChild !== undefined) {
134
+ if (isLast) {
135
+ if (staticChild.terminal !== undefined) return staticChild.terminal
136
+ } else {
137
+ const found = matchNode(staticChild, path, end + 1, len, paramValues)
138
+ if (found !== undefined) return found
139
+ }
140
+ }
141
+
142
+ const paramChild = node.paramChild
143
+ // An empty segment (`/users//posts`) never matches a `:param` — matching would hand the handler
144
+ // `id: ""` and downstream code a `WHERE id = ''` class of bug. Peers (Hono, Elysia, Next) 404
145
+ // here too; a static "" child can still match (none is ever registered — INVALID_PATH guards).
146
+ if (paramChild !== undefined && seg.length > 0) {
147
+ paramValues.push(seg)
148
+ if (isLast) {
149
+ if (paramChild.terminal !== undefined) return paramChild.terminal
150
+ } else {
151
+ const found = matchNode(paramChild, path, end + 1, len, paramValues)
152
+ if (found !== undefined) return found
153
+ }
154
+ paramValues.pop() // backtrack: this branch failed deeper down
155
+ }
156
+
157
+ const wildcard = node.wildcardChild
158
+ if (wildcard !== undefined && wildcard.terminal !== undefined) {
159
+ // Capture the rest of the path verbatim from this segment's start.
160
+ paramValues.push(path.slice(start))
161
+ return wildcard.terminal
162
+ }
163
+
164
+ return undefined
165
+ }
166
+
167
+ function buildParams(names: readonly string[], values: readonly string[]): Record<string, string> {
168
+ if (names.length === 0) return EMPTY_PARAMS
169
+ const params: Record<string, string> = {}
170
+ for (let i = 0; i < names.length; i++) {
171
+ // names and values align: one value was pushed per param/wildcard node along
172
+ // the matched path, in declaration order.
173
+ params[names[i]!] = values[i]!
174
+ }
175
+ return params
176
+ }
177
+
178
+ function resolve<T>(
179
+ terminal: Terminal<T>,
180
+ method: string,
181
+ params: Record<string, string>,
182
+ ): RouterMatch<T> {
183
+ if (terminal.staticMatches !== undefined && params === EMPTY_PARAMS) {
184
+ const upper = method.toUpperCase()
185
+ const cached = terminal.staticMatches.get(upper)
186
+ if (cached !== undefined) return cached
187
+ if (!terminal.handlers.has(upper)) return resolveDirect(terminal, method, params)
188
+
189
+ const res = resolveDirect(terminal, method, params)
190
+ terminal.staticMatches.set(upper, res)
191
+ return res
192
+ }
193
+ return resolveDirect(terminal, method, params)
194
+ }
195
+
196
+ function resolveDirect<T>(
197
+ terminal: Terminal<T>,
198
+ method: string,
199
+ params: Record<string, string>,
200
+ ): RouterMatch<T> {
201
+ if (terminal.handlers.has(method)) {
202
+ return { found: true, payload: terminal.handlers.get(method)!, params }
203
+ }
204
+ const upper = method.toUpperCase()
205
+ if (upper === method || !terminal.handlers.has(upper)) {
206
+ return { found: false, reason: "method-not-allowed", allowed: [...terminal.handlers.keys()] }
207
+ }
208
+ const payload = terminal.handlers.get(upper)! // present by the has() check above
209
+ return { found: true, payload, params }
210
+ }
211
+
212
+ /**
213
+ * Radix-style segment trie router. Matching precedence is static > param >
214
+ * wildcard. Parameter/wildcard values are returned RAW (not percent-decoded);
215
+ * the server boundary decodes and rejects malformed encodings with a 400,
216
+ * keeping this layer pure and allocation-light.
217
+ *
218
+ * @typeParam T - the payload stored per (method, path); a route descriptor in
219
+ * practice, but the router is agnostic to it.
220
+ */
221
+ export class Router<T> {
222
+ private readonly root: RouteNode<T>
223
+ /**
224
+ * Fast path: fully-static paths (no `:param`/`*`) resolve in a single map
225
+ * lookup keyed by the full path, skipping the trie walk entirely. Values are
226
+ * the same `Terminal` objects held in the trie, so adding a method later
227
+ * updates both views at once.
228
+ */
229
+ private readonly staticRoutes: Map<string, Terminal<T>>
230
+ /**
231
+ * Scratch storage for param/wildcard captures during a synchronous trie walk. `find()` copies the
232
+ * values into the returned params object before clearing this array, so returned matches never share
233
+ * mutable state. Kept per Router instance, not module-global, to avoid cross-app coupling.
234
+ */
235
+ private readonly paramValuesScratch: string[]
236
+
237
+ constructor() {
238
+ this.root = createNode<T>()
239
+ this.staticRoutes = new Map()
240
+ this.paramValuesScratch = []
241
+ }
242
+
243
+ /**
244
+ * Register a payload for `method` + `path`. Throws {@link RouteConfigError}
245
+ * (boot-time, L2) on a duplicate route, a malformed pattern, or conflicting
246
+ * parameter names for the same path shape.
247
+ */
248
+ add(method: Method, path: string, payload: T): void {
249
+ DYNAMIC_MATCH_CACHES.delete(this)
250
+ const upper = method.toUpperCase()
251
+ // The `Method` parameter type is the compile-time guard; this re-checks at
252
+ // runtime for JS callers and dynamically-computed methods.
253
+ if (!isMethod(upper)) {
254
+ throw new RouteConfigError("INVALID_METHOD", `unsupported HTTP method "${method}"`)
255
+ }
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
263
+ let node = this.root
264
+
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)
285
+ node.paramChild ??= createNode<T>()
286
+ 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)
309
+ node.wildcardChild ??= createNode<T>()
310
+ node = node.wildcardChild
311
+ } else {
312
+ let child = node.staticChildren.get(seg)
313
+ if (child === undefined) {
314
+ child = createNode<T>()
315
+ node.staticChildren.set(seg, child)
316
+ }
317
+ node = child
318
+ }
319
+ }
320
+
321
+ const existing = node.terminal
322
+ let terminal: Terminal<T>
323
+ if (existing !== undefined) {
324
+ if (!sameNames(existing.paramNames, paramNames)) {
325
+ throw new RouteConfigError(
326
+ "PARAM_NAME_CONFLICT",
327
+ `conflicting parameter names for "${path}": [${existing.paramNames.join(", ")}] vs [${paramNames.join(", ")}]`,
328
+ )
329
+ }
330
+ if (existing.handlers.has(upper)) {
331
+ throw new RouteConfigError("DUPLICATE_ROUTE", `duplicate route: ${upper} ${path}`)
332
+ }
333
+ existing.handlers.set(upper, payload)
334
+ existing.staticMatches?.clear()
335
+ terminal = existing
336
+ } else {
337
+ terminal = isStatic
338
+ ? {
339
+ handlers: new Map([[upper, payload]]),
340
+ paramNames,
341
+ staticMatches: new Map(),
342
+ }
343
+ : {
344
+ handlers: new Map([[upper, payload]]),
345
+ paramNames,
346
+ }
347
+ node.terminal = terminal
348
+ }
349
+
350
+ // Mirror fully-static routes into the fast-path map (same Terminal object).
351
+ if (isStatic) {
352
+ this.staticRoutes.set(path, terminal)
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Resolve `method` + `path`. Tolerant of a missing leading slash and of
358
+ * method casing. Never throws.
359
+ */
360
+ find(method: string, path: string): RouterMatch<T> {
361
+ const len = path.length
362
+ const leading = len > 0 && path.charCodeAt(0) === SLASH
363
+
364
+ // Fast path: a fully-static path resolves in one map lookup, no trie walk.
365
+ if (leading) {
366
+ const staticTerminal = this.staticRoutes.get(path)
367
+ if (staticTerminal !== undefined) {
368
+ return resolve(staticTerminal, method, EMPTY_PARAMS)
369
+ }
370
+ }
371
+
372
+ return this.findDynamic(method, path, leading, len)
373
+ }
374
+
375
+ private findDynamic(method: string, path: string, leading: boolean, len: number): RouterMatch<T> {
376
+ if (leading) {
377
+ const cached = (
378
+ DYNAMIC_MATCH_CACHES.get(this) as DynamicMatchCacheState<T> | undefined
379
+ )?.cache?.get(path)
380
+ if (cached !== undefined) {
381
+ return resolve(
382
+ cached.terminal,
383
+ method,
384
+ buildParams(cached.terminal.paramNames, cached.values),
385
+ )
386
+ }
387
+ }
388
+
389
+ const paramValues = this.paramValuesScratch
390
+ paramValues.length = 0
391
+ try {
392
+ const terminal = matchNode(this.root, path, leading ? 1 : 0, len, paramValues)
393
+ if (terminal === undefined) {
394
+ return { found: false, reason: "not-found" }
395
+ }
396
+ if (leading && terminal.paramNames.length > 0) {
397
+ this.maybeCacheDynamicMatch(path, terminal, paramValues)
398
+ }
399
+ return resolve(terminal, method, buildParams(terminal.paramNames, paramValues))
400
+ } finally {
401
+ paramValues.length = 0
402
+ }
403
+ }
404
+
405
+ private maybeCacheDynamicMatch(
406
+ path: string,
407
+ terminal: Terminal<T>,
408
+ values: readonly string[],
409
+ ): void {
410
+ const state = dynamicMatchCacheStateFor<T>(this)
411
+ if (state.pendingPath !== path) {
412
+ state.pendingPath = path
413
+ return
414
+ }
415
+ let cache = state.cache
416
+ if (cache === undefined) {
417
+ cache = new Map<string, DynamicMatchCacheEntry<T>>()
418
+ state.cache = cache
419
+ }
420
+ if (cache.size >= DYNAMIC_MATCH_CACHE_MAX) {
421
+ const oldest = cache.keys().next().value
422
+ if (oldest !== undefined) cache.delete(oldest)
423
+ }
424
+ cache.set(path, {
425
+ terminal,
426
+ values: values.slice(0, terminal.paramNames.length),
427
+ })
428
+ }
429
+ }