@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
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Cookie primitives — parse a request `Cookie` header, serialize a `Set-Cookie`, and sign/verify a
3
+ * value with HMAC-SHA256 via **WebCrypto** (`crypto.subtle`), so they're portable across Bun, Node,
4
+ * Deno, and workerd with no `node:crypto` dependency. Pure + runtime-agnostic: `c.cookies` (read) and
5
+ * `c.set.cookie` (write) build on these, and `@nifrajs/auth` builds sessions on top.
6
+ */
7
+
8
+ import { requireSecretBytes } from "../internal/secret.ts"
9
+
10
+ /** Attributes for a `Set-Cookie`. `expires` is a `Date`; `maxAge` is in **seconds**. */
11
+ export interface CookieOptions {
12
+ /** Lifetime in seconds. `0` (with a past `expires`) deletes the cookie. */
13
+ readonly maxAge?: number
14
+ readonly expires?: Date
15
+ readonly path?: string
16
+ readonly domain?: string
17
+ readonly secure?: boolean
18
+ readonly httpOnly?: boolean
19
+ readonly sameSite?: "strict" | "lax" | "none"
20
+ readonly partitioned?: boolean
21
+ }
22
+
23
+ // RFC 6265 cookie-name token: visible ASCII minus separators/whitespace/controls.
24
+ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
25
+ const MAX_COOKIE_BYTES = 4096 // browsers cap a cookie near 4 KB — reject oversized before the wire
26
+
27
+ /** True if `v` contains a character illegal in a cookie attribute (control char or `;` separator) —
28
+ * the header-injection guard for dev-supplied `Path`/`Domain`. */
29
+ const hasIllegalChar = (v: string): boolean => {
30
+ for (let i = 0; i < v.length; i++) {
31
+ const code = v.charCodeAt(i)
32
+ if (code < 0x20 || code === 0x7f || code === 0x3b /* ; */) return true
33
+ }
34
+ return false
35
+ }
36
+
37
+ const safeDecode = (v: string): string => {
38
+ // No `%` ⇒ nothing to decode — skip the call AND its try frame (profiled: cookie values are
39
+ // overwhelmingly plain; this was ~3% of a realistic request).
40
+ if (!v.includes("%")) return v
41
+ try {
42
+ return decodeURIComponent(v)
43
+ } catch {
44
+ return v // a malformed %-escape from a hostile/garbage cookie must not throw (it'd 500 the request)
45
+ }
46
+ }
47
+
48
+ /** Parse a request `Cookie` header into a name→value map (values URL-decoded). Unparseable pairs are
49
+ * skipped rather than throwing — a junk `Cookie` header shouldn't fail the request.
50
+ *
51
+ * Hand-rolled index walk instead of `split(";")` + `trim()` chains: those allocated an array + up
52
+ * to three substrings per pair and showed up at ~6% of a realistic (auth + cookie) request. Same
53
+ * semantics, two slices per pair, no intermediate array. */
54
+ export function parseCookies(header: string | null | undefined): Record<string, string> {
55
+ // Null-prototype: cookie NAMES come straight from the untrusted header, so a cookie named
56
+ // `constructor`/`__proto__`/`toString` must be an inert own key, not a shadow of a prototype
57
+ // member. Matches the query/form/params parsers (all `Object.create(null)`); values are always
58
+ // strings so global pollution was never possible, but this keeps the whole request-parse layer
59
+ // consistent and safe to treat as a plain dictionary.
60
+ const out: Record<string, string> = Object.create(null)
61
+ if (!header) return out
62
+ const len = header.length
63
+ let pos = 0
64
+ while (pos < len) {
65
+ let end = header.indexOf(";", pos)
66
+ if (end === -1) end = len
67
+ // trim() equivalent on the segment, without allocating the untrimmed slice
68
+ let start = pos
69
+ while (start < end && header.charCodeAt(start) === 32) start++
70
+ let stop = end
71
+ while (stop > start && header.charCodeAt(stop - 1) === 32) stop--
72
+ const eq = header.indexOf("=", start)
73
+ if (eq > start && eq < stop) {
74
+ // name: trim trailing spaces before '='; value: trim leading spaces after it
75
+ let nameEnd = eq
76
+ while (nameEnd > start && header.charCodeAt(nameEnd - 1) === 32) nameEnd--
77
+ let valueStart = eq + 1
78
+ while (valueStart < stop && header.charCodeAt(valueStart) === 32) valueStart++
79
+ if (nameEnd > start) {
80
+ const name = header.slice(start, nameEnd)
81
+ let value: string
82
+ if (
83
+ stop - valueStart >= 2 &&
84
+ header.charCodeAt(valueStart) === 34 /* " */ &&
85
+ header.charCodeAt(stop - 1) === 34
86
+ ) {
87
+ value = header.slice(valueStart + 1, stop - 1)
88
+ } else {
89
+ value = header.slice(valueStart, stop)
90
+ }
91
+ out[name] = safeDecode(value)
92
+ }
93
+ }
94
+ pos = end + 1
95
+ }
96
+ return out
97
+ }
98
+
99
+ /**
100
+ * Serialize a `Set-Cookie` header value. Pure — applies **no** security defaults (the caller, e.g.
101
+ * `c.set.cookie`, layers `HttpOnly`/`Secure`/`SameSite` on). Throws on an invalid cookie name, a
102
+ * header-injecting `Path`/`Domain`, a non-integer `maxAge`, or an oversized result — a serialization
103
+ * bug should fail loudly, not silently emit a cookie the browser drops.
104
+ */
105
+ export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
106
+ if (!COOKIE_NAME.test(name)) {
107
+ throw new Error(
108
+ `[nifra] invalid cookie name ${JSON.stringify(name)}: must be an RFC 6265 token`,
109
+ )
110
+ }
111
+ let str = `${name}=${encodeURIComponent(value)}`
112
+ if (options.maxAge !== undefined) {
113
+ if (!Number.isInteger(options.maxAge)) {
114
+ throw new Error("[nifra] cookie maxAge must be an integer number of seconds")
115
+ }
116
+ str += `; Max-Age=${options.maxAge}`
117
+ }
118
+ if (options.domain !== undefined) {
119
+ if (hasIllegalChar(options.domain))
120
+ throw new Error("[nifra] cookie Domain contains an illegal character")
121
+ str += `; Domain=${options.domain}`
122
+ }
123
+ if (options.path !== undefined) {
124
+ if (hasIllegalChar(options.path))
125
+ throw new Error("[nifra] cookie Path contains an illegal character")
126
+ str += `; Path=${options.path}`
127
+ }
128
+ if (options.expires !== undefined) str += `; Expires=${options.expires.toUTCString()}`
129
+ if (options.httpOnly === true) str += "; HttpOnly"
130
+ if (options.secure === true) str += "; Secure"
131
+ if (options.partitioned === true) str += "; Partitioned"
132
+ if (options.sameSite !== undefined) {
133
+ str += `; SameSite=${options.sameSite === "strict" ? "Strict" : options.sameSite === "none" ? "None" : "Lax"}`
134
+ }
135
+ if (str.length > MAX_COOKIE_BYTES) {
136
+ throw new Error(
137
+ `[nifra] cookie ${JSON.stringify(name)} is ${str.length}B, over the ${MAX_COOKIE_BYTES}B limit`,
138
+ )
139
+ }
140
+ return str
141
+ }
142
+
143
+ const TEXT = new TextEncoder()
144
+
145
+ const importHmacKey = (secret: string): Promise<CryptoKey> => {
146
+ // Reject a weak secret at the choke point both signing and verification flow through (so signed
147
+ // cookies AND @nifrajs/auth sessions, which delegate here via signValue/unsignValue, are covered).
148
+ requireSecretBytes(secret, "signed-cookie")
149
+ return crypto.subtle.importKey(
150
+ "raw",
151
+ TEXT.encode(secret),
152
+ { name: "HMAC", hash: "SHA-256" },
153
+ false,
154
+ ["sign", "verify"],
155
+ )
156
+ }
157
+
158
+ const toBase64Url = (buf: ArrayBuffer): string => {
159
+ let bin = ""
160
+ for (const b of new Uint8Array(buf)) bin += String.fromCharCode(b)
161
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
162
+ }
163
+
164
+ // Returns `Uint8Array<ArrayBuffer>` (not the generic `Uint8Array<ArrayBufferLike>`) so it satisfies
165
+ // WebCrypto's `BufferSource` parameter under TS 5.7+'s typed-array generics.
166
+ const fromBase64Url = (s: string): Uint8Array<ArrayBuffer> | null => {
167
+ try {
168
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"))
169
+ const bytes = new Uint8Array(bin.length)
170
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
171
+ return bytes
172
+ } catch {
173
+ return null // not valid base64 — treat as a bad signature
174
+ }
175
+ }
176
+
177
+ /** Append an HMAC-SHA256 signature to a value → `value.signature` (base64url). For signed cookies. */
178
+ export async function signValue(value: string, secret: string): Promise<string> {
179
+ const key = await importHmacKey(secret)
180
+ const sig = await crypto.subtle.sign("HMAC", key, TEXT.encode(value))
181
+ return `${value}.${toBase64Url(sig)}`
182
+ }
183
+
184
+ /**
185
+ * Verify a `value.signature` produced by {@link signValue} and return the value, or `null` if the
186
+ * signature is missing, malformed, or doesn't match. Verification is **constant-time**
187
+ * (`crypto.subtle.verify`), so a wrong signature can't be discovered byte-by-byte via timing.
188
+ */
189
+ export async function unsignValue(signed: string, secret: string): Promise<string | null> {
190
+ const dot = signed.lastIndexOf(".")
191
+ if (dot < 1) return null // no signature segment (or empty value)
192
+ const value = signed.slice(0, dot)
193
+ const sig = fromBase64Url(signed.slice(dot + 1))
194
+ if (sig === null) return null
195
+ const key = await importHmacKey(secret)
196
+ const ok = await crypto.subtle.verify("HMAC", key, sig, TEXT.encode(value))
197
+ return ok ? value : null
198
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Structured, redacting logger. The framework logs through this interface so
3
+ * secrets/PII are scrubbed once, centrally (per the project's logging rule), not at
4
+ * each call site. Bring your own by passing `logger` to `server()`.
5
+ */
6
+
7
+ export type LogFields = Record<string, unknown>
8
+
9
+ export interface Logger {
10
+ debug(message: string, fields?: LogFields): void
11
+ info(message: string, fields?: LogFields): void
12
+ warn(message: string, fields?: LogFields): void
13
+ error(message: string, fields?: LogFields): void
14
+ }
15
+
16
+ const REDACTED = "[REDACTED]"
17
+
18
+ /**
19
+ * Field-name fragments whose values are replaced with `[REDACTED]` (case-insensitive
20
+ * substring match). Key-name redaction is the default + always on.
21
+ */
22
+ const SENSITIVE_KEY_PARTS: ReadonlyArray<string> = [
23
+ "authorization",
24
+ "cookie",
25
+ "password",
26
+ "passwd",
27
+ "secret",
28
+ "token",
29
+ "apikey",
30
+ "api_key",
31
+ "api-key",
32
+ "credential",
33
+ "ssn",
34
+ ]
35
+
36
+ /**
37
+ * Tunes redaction. Key-name redaction always runs; the rest is **opt-in**:
38
+ * - `keyParts` — extra case-insensitive key fragments, added to the built-in denylist.
39
+ * - `valuePatterns` — regexes matched against string **values** *and* the log message; each match is
40
+ * replaced with the placeholder. This is the value-scanning hook for secrets that land in a value or
41
+ * message (e.g. `err.message`), which key-name redaction can't catch. Off unless provided — the
42
+ * default path does no value scanning, so it stays allocation-light. See {@link commonSecretPatterns}.
43
+ * - `placeholder` — the replacement string (default `[REDACTED]`).
44
+ */
45
+ export interface RedactOptions {
46
+ readonly keyParts?: readonly string[]
47
+ readonly valuePatterns?: readonly RegExp[]
48
+ readonly placeholder?: string
49
+ }
50
+
51
+ /**
52
+ * A conservative, high-signal set of patterns for {@link RedactOptions.valuePatterns} — opt in by
53
+ * passing it (or a subset) to `jsonLogger`/`redactLogFields`. Covers bearer tokens, JWTs, emails, and a
54
+ * few well-known key formats (Stripe, GitHub, AWS access-key ids). Chosen to minimize false positives;
55
+ * add your own (e.g. internal id formats) as needed. Every pattern is global so all matches are scrubbed.
56
+ */
57
+ export const commonSecretPatterns: ReadonlyArray<RegExp> = [
58
+ /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, // Authorization: Bearer <token>
59
+ /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, // JWT (header.payload.signature)
60
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // email
61
+ /\b[sprk]k_(?:live|test)_[A-Za-z0-9]{8,}/g, // Stripe-style secret/restricted keys
62
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub personal/OAuth/refresh tokens
63
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
64
+ ]
65
+
66
+ interface RedactConfig {
67
+ readonly keyParts: readonly string[]
68
+ readonly valuePatterns: readonly RegExp[]
69
+ readonly placeholder: string
70
+ }
71
+
72
+ // `String.replace` with a global regex replaces every match and is stateless across calls; a
73
+ // non-global pattern would only scrub the first occurrence, so normalize to global once up front.
74
+ const ensureGlobal = (re: RegExp): RegExp =>
75
+ re.flags.includes("g") ? re : new RegExp(re.source, `${re.flags}g`)
76
+
77
+ function buildConfig(options?: RedactOptions): RedactConfig {
78
+ const extra = options?.keyParts
79
+ return {
80
+ keyParts:
81
+ extra !== undefined && extra.length > 0
82
+ ? [...SENSITIVE_KEY_PARTS, ...extra.map((k) => k.toLowerCase())]
83
+ : SENSITIVE_KEY_PARTS,
84
+ valuePatterns: (options?.valuePatterns ?? []).map(ensureGlobal),
85
+ placeholder: options?.placeholder ?? REDACTED,
86
+ }
87
+ }
88
+
89
+ function isSensitiveKey(key: string, parts: readonly string[]): boolean {
90
+ const lower = key.toLowerCase()
91
+ return parts.some((part) => lower.includes(part))
92
+ }
93
+
94
+ function redactString(value: string, config: RedactConfig): string {
95
+ if (config.valuePatterns.length === 0) return value // default fast path: no value scanning
96
+ let out = value
97
+ for (const pattern of config.valuePatterns) out = out.replace(pattern, config.placeholder)
98
+ return out
99
+ }
100
+
101
+ function redactValue(value: unknown, seen: WeakSet<object>, config: RedactConfig): unknown {
102
+ if (typeof value === "string") return redactString(value, config)
103
+ if (value === null || typeof value !== "object") return value
104
+ if (seen.has(value)) return "[Circular]"
105
+ seen.add(value)
106
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, seen, config))
107
+ const out: Record<string, unknown> = {}
108
+ for (const [key, val] of Object.entries(value)) {
109
+ // A sensitive key wins outright (don't scan it) — the whole value is replaced.
110
+ out[key] = isSensitiveKey(key, config.keyParts)
111
+ ? config.placeholder
112
+ : redactValue(val, seen, config)
113
+ }
114
+ return out
115
+ }
116
+
117
+ /**
118
+ * Deep-copy `fields`, replacing values under sensitive keys with the placeholder; cycle-safe. With
119
+ * `options.valuePatterns`, also scans string values for those patterns (opt-in). Without options, this
120
+ * is pure key-name redaction (the long-standing default).
121
+ */
122
+ export function redactLogFields(fields: LogFields, options?: RedactOptions): LogFields {
123
+ return redactValue(fields, new WeakSet(), buildConfig(options)) as LogFields
124
+ }
125
+
126
+ function writeToStderr(line: string): void {
127
+ process.stderr.write(`${line}\n`)
128
+ }
129
+
130
+ /**
131
+ * The default logger: one redacted JSON object per line. `write` is injectable for tests or
132
+ * alternative sinks (defaults to stderr). `options` tunes redaction — pass `valuePatterns` (e.g.
133
+ * {@link commonSecretPatterns}) to also scrub secrets embedded in values + the message. Framework keys
134
+ * (`level`, `message`, `time`) always win over user fields of the same name.
135
+ */
136
+ export function jsonLogger(
137
+ write: (line: string) => void = writeToStderr,
138
+ options?: RedactOptions,
139
+ ): Logger {
140
+ const config = buildConfig(options) // normalize patterns once, reuse per entry
141
+ const emit = (level: string, message: string, fields?: LogFields): void => {
142
+ const entry: Record<string, unknown> =
143
+ fields === undefined
144
+ ? {}
145
+ : (redactValue(fields, new WeakSet(), config) as Record<string, unknown>)
146
+ entry.level = level
147
+ entry.message = redactString(message, config) // scan the message too (no-op without valuePatterns)
148
+ entry.time = new Date().toISOString()
149
+ write(JSON.stringify(entry))
150
+ }
151
+ return {
152
+ debug: (message, fields) => emit("debug", message, fields),
153
+ info: (message, fields) => emit("info", message, fields),
154
+ warn: (message, fields) => emit("warn", message, fields),
155
+ error: (message, fields) => emit("error", message, fields),
156
+ }
157
+ }
158
+
159
+ const noop = (): void => undefined
160
+
161
+ /** Discards everything — for tests, or when log output is handled elsewhere. */
162
+ export const silentLogger: Logger = {
163
+ debug: noop,
164
+ info: noop,
165
+ warn: noop,
166
+ error: noop,
167
+ }
@@ -0,0 +1,63 @@
1
+ import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
2
+ import type { Params, RouteSchema } from "./context.ts"
3
+
4
+ /**
5
+ * One route's input/output shape as the **client** will consume it. `query`/`body`
6
+ * are `never` when the route declares no schema for them, so the client can detect
7
+ * "this route takes no body" via `[body] extends [never]`. `output` is the
8
+ * handler's raw return type (the client applies `Jsonify` when reading it).
9
+ */
10
+ export interface RouteInfo {
11
+ // `object` is the *bound*; each route stores its precise `Params<Path>` (e.g.
12
+ // `{ id: string }`), which a generic `Path` can't be proven to fit into
13
+ // `Record<string, string>`. The precise type survives in the accumulated
14
+ // registry, so exact param-key checking is preserved downstream.
15
+ readonly params: object
16
+ readonly query: unknown
17
+ readonly body: unknown
18
+ readonly output: unknown
19
+ }
20
+
21
+ /** The accumulated, type-level map of every route on a Server: path → method → RouteInfo. */
22
+ export type Registry = Record<string, Record<string, RouteInfo>>
23
+
24
+ /** The empty registry (no routes). `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
25
+ export type EmptyRegistry = NonNullable<unknown>
26
+
27
+ type RegistryBody<S extends RouteSchema> = S extends { body: infer B extends StandardSchemaV1 }
28
+ ? InferOutput<B>
29
+ : never
30
+
31
+ type RegistryQuery<S extends RouteSchema> = S extends { query: infer Q extends StandardSchemaV1 }
32
+ ? InferOutput<Q>
33
+ : never
34
+
35
+ /** The client-visible output: the declared `response` contract when present (so the client sees the
36
+ * contract, not the handler's incidental return), otherwise the inferred handler output. */
37
+ type RegistryOutput<S extends RouteSchema, Output> = S extends {
38
+ response: infer R extends StandardSchemaV1
39
+ }
40
+ ? InferOutput<R>
41
+ : Output
42
+
43
+ /** Build a {@link RouteInfo} from a route's path, schema, and handler output type. */
44
+ export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
45
+ readonly params: Params<Path>
46
+ readonly query: RegistryQuery<S>
47
+ readonly body: RegistryBody<S>
48
+ readonly output: RegistryOutput<S, Output>
49
+ }
50
+
51
+ /** The client-visible output of a handler: its awaited return, minus raw `Response`. */
52
+ export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
53
+ Awaited<ReturnType<H>>,
54
+ Response
55
+ >
56
+
57
+ /** Merge a new route into the registry, combining methods that share a path. */
58
+ export type AddRoute<
59
+ R extends Registry,
60
+ Method extends string,
61
+ Path extends string,
62
+ Info extends RouteInfo,
63
+ > = R & { [P in Path]: { [M in Method]: Info } }