@nifrajs/core 1.12.0 → 2.0.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 (167) hide show
  1. package/README.md +10 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/effect-ledger.d.ts +9 -0
  7. package/dist/effect-ledger.d.ts.map +1 -0
  8. package/dist/effect-ledger.js +8 -0
  9. package/dist/effect-ledger.js.map +1 -0
  10. package/dist/idempotency-plugin.d.ts +8 -0
  11. package/dist/idempotency-plugin.d.ts.map +1 -0
  12. package/dist/idempotency-plugin.js +8 -0
  13. package/dist/idempotency-plugin.js.map +1 -0
  14. package/dist/index.d.ts +6 -34
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -30
  17. package/dist/index.js.map +1 -1
  18. package/dist/mcp.d.ts +7 -0
  19. package/dist/mcp.d.ts.map +1 -0
  20. package/dist/mcp.js +56 -0
  21. package/dist/mcp.js.map +1 -0
  22. package/dist/node-direct.d.ts +5 -0
  23. package/dist/node-direct.d.ts.map +1 -0
  24. package/dist/node-direct.js +27 -0
  25. package/dist/node-direct.js.map +1 -0
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +2 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/router/pattern.d.ts +42 -0
  31. package/dist/router/pattern.d.ts.map +1 -0
  32. package/dist/router/pattern.js +127 -0
  33. package/dist/router/pattern.js.map +1 -0
  34. package/dist/router/router.d.ts +2 -1
  35. package/dist/router/router.d.ts.map +1 -1
  36. package/dist/router/router.js +11 -48
  37. package/dist/router/router.js.map +1 -1
  38. package/dist/server/client-ip.d.ts +35 -0
  39. package/dist/server/client-ip.d.ts.map +1 -0
  40. package/dist/server/client-ip.js +42 -0
  41. package/dist/server/client-ip.js.map +1 -0
  42. package/dist/server/context.d.ts +47 -2
  43. package/dist/server/context.d.ts.map +1 -1
  44. package/dist/server/contract.d.ts +37 -10
  45. package/dist/server/contract.d.ts.map +1 -1
  46. package/dist/server/contract.js +33 -8
  47. package/dist/server/contract.js.map +1 -1
  48. package/dist/server/http.d.ts +14 -0
  49. package/dist/server/http.d.ts.map +1 -0
  50. package/dist/server/http.js +45 -0
  51. package/dist/server/http.js.map +1 -0
  52. package/dist/server/idempotency-lane.d.ts +44 -0
  53. package/dist/server/idempotency-lane.d.ts.map +1 -0
  54. package/dist/server/idempotency-lane.js +181 -0
  55. package/dist/server/idempotency-lane.js.map +1 -0
  56. package/dist/server/install.d.ts +21 -0
  57. package/dist/server/install.d.ts.map +1 -0
  58. package/dist/server/install.js +21 -0
  59. package/dist/server/install.js.map +1 -0
  60. package/dist/server/ledger-lane.d.ts +53 -0
  61. package/dist/server/ledger-lane.d.ts.map +1 -0
  62. package/dist/server/ledger-lane.js +82 -0
  63. package/dist/server/ledger-lane.js.map +1 -0
  64. package/dist/server/mcp-hook.d.ts +28 -0
  65. package/dist/server/mcp-hook.d.ts.map +1 -0
  66. package/dist/server/mcp-hook.js +2 -0
  67. package/dist/server/mcp-hook.js.map +1 -0
  68. package/dist/server/node-outcome-hook.d.ts +9 -0
  69. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  70. package/dist/server/node-outcome-hook.js +2 -0
  71. package/dist/server/node-outcome-hook.js.map +1 -0
  72. package/dist/server/node-outcome.d.ts +37 -0
  73. package/dist/server/node-outcome.d.ts.map +1 -0
  74. package/dist/server/node-outcome.js +75 -0
  75. package/dist/server/node-outcome.js.map +1 -0
  76. package/dist/server/plugin.d.ts +78 -0
  77. package/dist/server/plugin.d.ts.map +1 -0
  78. package/dist/server/plugin.js +57 -0
  79. package/dist/server/plugin.js.map +1 -0
  80. package/dist/server/query.d.ts +18 -0
  81. package/dist/server/query.d.ts.map +1 -0
  82. package/dist/server/query.js +102 -0
  83. package/dist/server/query.js.map +1 -0
  84. package/dist/server/registry.d.ts +31 -6
  85. package/dist/server/registry.d.ts.map +1 -1
  86. package/dist/server/request-context.d.ts +51 -0
  87. package/dist/server/request-context.d.ts.map +1 -0
  88. package/dist/server/request-context.js +184 -0
  89. package/dist/server/request-context.js.map +1 -0
  90. package/dist/server/respond.d.ts +17 -0
  91. package/dist/server/respond.d.ts.map +1 -0
  92. package/dist/server/respond.js +81 -0
  93. package/dist/server/respond.js.map +1 -0
  94. package/dist/server/runtime-core.d.ts +36 -0
  95. package/dist/server/runtime-core.d.ts.map +1 -0
  96. package/dist/server/runtime-core.js +51 -0
  97. package/dist/server/runtime-core.js.map +1 -0
  98. package/dist/server/server-types.d.ts +203 -0
  99. package/dist/server/server-types.d.ts.map +1 -0
  100. package/dist/server/server-types.js +2 -0
  101. package/dist/server/server-types.js.map +1 -0
  102. package/dist/server/server.d.ts +135 -358
  103. package/dist/server/server.d.ts.map +1 -1
  104. package/dist/server/server.js +476 -1065
  105. package/dist/server/server.js.map +1 -1
  106. package/dist/server/sse-hook.d.ts +10 -0
  107. package/dist/server/sse-hook.d.ts.map +1 -0
  108. package/dist/server/sse-hook.js +2 -0
  109. package/dist/server/sse-hook.js.map +1 -0
  110. package/dist/server/sse.d.ts +7 -23
  111. package/dist/server/sse.d.ts.map +1 -1
  112. package/dist/server/sse.js +18 -0
  113. package/dist/server/sse.js.map +1 -1
  114. package/dist/server/websocket.d.ts +17 -1
  115. package/dist/server/websocket.d.ts.map +1 -1
  116. package/dist/server/websocket.js.map +1 -1
  117. package/dist/server/ws-hook.d.ts +8 -11
  118. package/dist/server/ws-hook.d.ts.map +1 -1
  119. package/dist/server/ws-hook.js +7 -14
  120. package/dist/server/ws-hook.js.map +1 -1
  121. package/dist/server.d.ts +21 -0
  122. package/dist/server.d.ts.map +1 -0
  123. package/dist/server.js +16 -0
  124. package/dist/server.js.map +1 -0
  125. package/dist/ws.d.ts +16 -7
  126. package/dist/ws.d.ts.map +1 -1
  127. package/dist/ws.js +25 -10
  128. package/dist/ws.js.map +1 -1
  129. package/package.json +82 -6
  130. package/src/assurance.ts +36 -1
  131. package/src/effect-ledger.ts +9 -0
  132. package/src/idempotency-plugin.ts +7 -0
  133. package/src/index.ts +6 -132
  134. package/src/mcp.ts +65 -0
  135. package/src/node-direct.ts +37 -0
  136. package/src/reflection.ts +7 -0
  137. package/src/router/pattern.ts +176 -0
  138. package/src/router/router.ts +11 -69
  139. package/src/server/client-ip.ts +60 -0
  140. package/src/server/context.ts +50 -2
  141. package/src/server/contract.ts +68 -25
  142. package/src/server/http.ts +58 -0
  143. package/src/server/idempotency-lane.ts +287 -0
  144. package/src/server/install.ts +28 -0
  145. package/src/server/ledger-lane.ts +125 -0
  146. package/src/server/mcp-hook.ts +41 -0
  147. package/src/server/node-outcome-hook.ts +9 -0
  148. package/src/server/node-outcome.ts +112 -0
  149. package/src/server/plugin.ts +92 -0
  150. package/src/server/query.ts +119 -0
  151. package/src/server/registry.ts +36 -6
  152. package/src/server/request-context.ts +271 -0
  153. package/src/server/respond.ts +93 -0
  154. package/src/server/runtime-core.ts +74 -0
  155. package/src/server/server-types.ts +204 -0
  156. package/src/server/server.ts +863 -1657
  157. package/src/server/sse-hook.ts +14 -0
  158. package/src/server/sse.ts +26 -0
  159. package/src/server/websocket.ts +20 -0
  160. package/src/server/ws-hook.ts +8 -19
  161. package/src/server.ts +92 -0
  162. package/src/ws.ts +33 -10
  163. package/dist/invariants.d.ts +0 -70
  164. package/dist/invariants.d.ts.map +0 -1
  165. package/dist/invariants.js +0 -487
  166. package/dist/invariants.js.map +0 -1
  167. package/src/invariants.ts +0 -604
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The runtime request context (`c`): the `RawContext` implementation the handler receives, its lazy
3
+ * `c.set` response-controls backing, and the bounded body readers behind `c.boundedBody`/`c.boundedJson`.
4
+ * Type-imports the kernel's spine (never its values) and pulls its primitives from `runtime-core`, so it
5
+ * sits below the server module in the graph.
6
+ */
7
+ import type { RequestBudget } from "../budget.ts"
8
+ import { drainCapped, parseContentLength, readBoundedBytes } from "./body.ts"
9
+ import type { Platform } from "./context.ts"
10
+ import { type CookieOptions, parseCookies, serializeCookie } from "./cookies.ts"
11
+ import { jsonError } from "./http.ts"
12
+ import { searchOf } from "./query.ts"
13
+ import {
14
+ CONTEXT_SEARCH,
15
+ CONTEXT_SET,
16
+ fallbackWaitUntil,
17
+ getNeverAbortSignal,
18
+ getUnboundedRequestBudget,
19
+ headerOf,
20
+ requestOf,
21
+ TEXT_DECODER,
22
+ } from "./runtime-core.ts"
23
+ import type { CtxSet, RawContext, RequestSource } from "./server.ts"
24
+
25
+ /** A fixed past instant for cookie deletion (`Expires`). A literal epoch - deterministic, unlike an
26
+ * argless `new Date()`. */
27
+ const EPOCH = new Date(0)
28
+
29
+ class LazyResponseControls implements CtxSet {
30
+ status?: number
31
+ _headers?: Record<string, string>
32
+ _cookies?: string[]
33
+
34
+ get headers(): Record<string, string> {
35
+ this._headers ??= {}
36
+ return this._headers
37
+ }
38
+
39
+ cookie(name: string, value: string, options?: CookieOptions): void {
40
+ // Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
41
+ const merged: CookieOptions = {
42
+ httpOnly: true,
43
+ secure: true,
44
+ sameSite: "lax",
45
+ path: "/",
46
+ ...options,
47
+ }
48
+ this._cookies ??= []
49
+ this._cookies.push(serializeCookie(name, value, merged))
50
+ }
51
+
52
+ deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
53
+ // Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
54
+ this._cookies ??= []
55
+ this._cookies.push(
56
+ serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
57
+ )
58
+ }
59
+ }
60
+
61
+ /** Coerce `c.json`/`c.text`'s second arg - a status number (the common case) or a full `ResponseInit`. */
62
+ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
63
+ return typeof init === "number" ? { status: init } : init
64
+ }
65
+
66
+ export class RequestContext implements RawContext {
67
+ // `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
68
+ // constructor initializes only the eager request state, while lazy fields remain absent until used.
69
+ // Not `readonly`: the lifecycle replaces it with the validated/coerced value when a `params` schema is
70
+ // declared (`c.params` stays `readonly` to handlers via the `Context` interface).
71
+ declare params: Record<string, string>
72
+ declare body: unknown
73
+ private declare searchValue: string | undefined
74
+ private declare signalValue: AbortSignal | undefined
75
+ private declare budgetValue: RequestBudget | undefined
76
+ private declare platformValue: Platform | undefined
77
+
78
+ private declare setValue: CtxSet | undefined
79
+ private declare queryValue: unknown
80
+ private declare queryReady: boolean
81
+ private declare cookiesValue: Readonly<Record<string, string>> | undefined
82
+ private declare readonly source: RequestSource
83
+ private declare readonly maxBodyBytes: number
84
+
85
+ constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
86
+ constructor(
87
+ source: RequestSource,
88
+ params: Record<string, string>,
89
+ search: string | undefined,
90
+ signal: AbortSignal,
91
+ budget: RequestBudget,
92
+ platform: Platform | undefined,
93
+ maxBodyBytes: number,
94
+ )
95
+ constructor(
96
+ source: RequestSource,
97
+ params: Record<string, string>,
98
+ searchOrMaxBodyBytes: string | number | undefined,
99
+ signal?: AbortSignal,
100
+ budget?: RequestBudget,
101
+ platform?: Platform,
102
+ maxBodyBytes?: number,
103
+ ) {
104
+ this.source = source
105
+ this.params = params
106
+ if (typeof searchOrMaxBodyBytes === "number") {
107
+ this.maxBodyBytes = searchOrMaxBodyBytes
108
+ return
109
+ }
110
+ if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
111
+ this.signalValue = signal
112
+ this.budgetValue = budget
113
+ if (platform !== undefined) this.platformValue = platform
114
+ this.maxBodyBytes = maxBodyBytes as number
115
+ }
116
+
117
+ static native(
118
+ source: RequestSource,
119
+ params: Record<string, string>,
120
+ maxBodyBytes: number,
121
+ ): RequestContext {
122
+ return new RequestContext(source, params, maxBodyBytes)
123
+ }
124
+
125
+ [CONTEXT_SET](): CtxSet | undefined {
126
+ return this.setValue
127
+ }
128
+
129
+ get [CONTEXT_SEARCH](): string {
130
+ this.searchValue ??= searchOf(this.source.url)
131
+ return this.searchValue
132
+ }
133
+
134
+ get set(): CtxSet {
135
+ this.setValue ??= new LazyResponseControls()
136
+ return this.setValue
137
+ }
138
+
139
+ get signal(): AbortSignal {
140
+ return this.signalValue ?? getNeverAbortSignal()
141
+ }
142
+
143
+ get budget(): RequestBudget {
144
+ return this.budgetValue ?? getUnboundedRequestBudget()
145
+ }
146
+
147
+ get env(): unknown {
148
+ return this.platformValue?.env
149
+ }
150
+
151
+ get clientIp(): string | undefined {
152
+ // The server resolves the trust declaration into `platform.clientIp` before the context is built,
153
+ // so this getter just surfaces the already-derived value (raw socket peer by default).
154
+ return this.platformValue?.clientIp
155
+ }
156
+
157
+ get waitUntil(): (promise: Promise<unknown>) => void {
158
+ return this.platformValue?.waitUntil ?? fallbackWaitUntil
159
+ }
160
+
161
+ get req(): Request {
162
+ return requestOf(this.source)
163
+ }
164
+
165
+ get request(): Request {
166
+ return requestOf(this.source)
167
+ }
168
+
169
+ json(body: unknown, init?: ResponseInit | number): Response {
170
+ return Response.json(body, statusInit(init))
171
+ }
172
+
173
+ text(body: string, init?: ResponseInit | number): Response {
174
+ const i = statusInit(init)
175
+ // Default to text/plain; if the caller passes their own headers, they own the content-type.
176
+ if (i?.headers !== undefined) return new Response(body, i)
177
+ return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } })
178
+ }
179
+
180
+ get query(): unknown {
181
+ if (!this.queryReady) {
182
+ this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH])
183
+ this.queryReady = true
184
+ }
185
+ return this.queryValue
186
+ }
187
+
188
+ set query(v: unknown) {
189
+ this.queryValue = v
190
+ this.queryReady = true
191
+ }
192
+
193
+ get cookies(): Readonly<Record<string, string>> {
194
+ this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"))
195
+ return this.cookiesValue
196
+ }
197
+
198
+ boundedBody(maxBytes?: number): Promise<Uint8Array> {
199
+ return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
200
+ }
201
+
202
+ boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
203
+ return readBoundedJsonBodyOrThrow<T>(this.source, this.maxBodyBytes, maxBytes)
204
+ }
205
+ }
206
+
207
+ /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
208
+ * `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
209
+ * The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
210
+ async function readBoundedBodyOrThrow(
211
+ req: RequestSource,
212
+ maxBodyBytes: number,
213
+ maxBytes?: number,
214
+ ): Promise<Uint8Array> {
215
+ const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
216
+ if (r.ok) return r.bytes
217
+ throw r.status === 413
218
+ ? jsonError(413, "payload_too_large")
219
+ : jsonError(400, "invalid_content_length")
220
+ }
221
+
222
+ /** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
223
+ async function readBoundedJsonBodyOrThrow<T>(
224
+ req: RequestSource,
225
+ maxBodyBytes: number,
226
+ maxBytes?: number,
227
+ ): Promise<T> {
228
+ const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
229
+ if (parsed instanceof Response) throw parsed
230
+ return parsed as T
231
+ }
232
+
233
+ /**
234
+ * Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
235
+ * Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
236
+ * chunked or length-less requests fall back to the streaming byte-cap guard.
237
+ */
238
+ export async function readBoundedJsonSource(
239
+ req: RequestSource,
240
+ maxBytes: number,
241
+ ): Promise<unknown | Response> {
242
+ const declared = headerOf(req, "content-length")
243
+ if (declared !== null) {
244
+ // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
245
+ // non-numeric / negative / fractional / exponential value (`Number()` would happily accept
246
+ // "abc"->NaN, "-5", "1.5", "1e3", "0x10") is malformed -> 400, rather than silently falling
247
+ // through to the streaming guard - which is an UPPER-bound cap only, so a lying SMALLER length
248
+ // would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
249
+ // hardens hand-built Requests (tests, the in-process client) and crafted input.
250
+ const length = parseContentLength(declared)
251
+ if (length === undefined) return jsonError(400, "invalid_content_length")
252
+ if (length > maxBytes) return jsonError(413, "payload_too_large")
253
+ const chunked = headerOf(req, "transfer-encoding") !== null
254
+ if (!chunked) {
255
+ try {
256
+ return await req.json()
257
+ } catch {
258
+ return jsonError(400, "invalid_json")
259
+ }
260
+ }
261
+ }
262
+ const body = req.body
263
+ if (body === null) return jsonError(400, "invalid_json")
264
+ const drained = await drainCapped(body, maxBytes)
265
+ if (!drained.ok) return jsonError(413, "payload_too_large")
266
+ try {
267
+ return JSON.parse(TEXT_DECODER.decode(drained.bytes))
268
+ } catch {
269
+ return jsonError(400, "invalid_json")
270
+ }
271
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Response construction from a handler's return value: the header/cookie init, the fused-lane
3
+ * responders, the fast JSON path, and cookie append. Imports only the runtime-core primitives + the
4
+ * spine types, so the wire-response format lives in one module distinct from the request engine.
5
+ */
6
+ import {
7
+ CONTEXT_SET,
8
+ EMPTY_RESPONSE_CONTROLS,
9
+ type HandlerResult,
10
+ isResponseResult,
11
+ } from "./runtime-core.ts"
12
+ import type { CtxSet, RawContext } from "./server.ts"
13
+
14
+ /** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
15
+ * gets no `headers` at all. Cookies force a `Headers` object - multiple `Set-Cookie`s can't live in a
16
+ * `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
17
+ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined {
18
+ const cookies = set._cookies
19
+ if (cookies === undefined || cookies.length === 0) return set._headers
20
+ const headers = new Headers(set._headers)
21
+ for (const cookie of cookies) headers.append("set-cookie", cookie)
22
+ return headers
23
+ }
24
+
25
+ // Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
26
+ // scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
27
+ // across responses: the Response constructor copies `init.headers` into its own list.
28
+ const JSON_CT_HEADERS = new Headers({
29
+ "content-type": "application/json;charset=utf-8",
30
+ })
31
+ const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
32
+
33
+ /** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
34
+ * older hand-inlined stringify + Response construction on this lane while preserving the exact
35
+ * body/content-type contract; keep the generic fallback for non-JSON values. */
36
+ export function fusedRespondNoSet(result: unknown): Response {
37
+ if (
38
+ result !== undefined &&
39
+ !(result instanceof Response) &&
40
+ typeof result === "object" &&
41
+ result !== null &&
42
+ !isResponseResult(result)
43
+ ) {
44
+ return Response.json(result)
45
+ }
46
+ return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
47
+ }
48
+
49
+ /** Fused-lane respond with a context: read `c.set` once; untouched (the common case) -> the fast
50
+ * JSON respond; touched -> the generic `toResponse` with those controls (statuses, headers, cookies). */
51
+ export function fusedRespond(result: unknown, ctx: RawContext): Response {
52
+ const set = ctx[CONTEXT_SET]()
53
+ if (set === undefined) return fusedRespondNoSet(result)
54
+ return toResponse(result as HandlerResult, set)
55
+ }
56
+
57
+ export function toResponse(result: HandlerResult, set: CtxSet): Response {
58
+ if (isResponseResult(result)) {
59
+ return appendCookiesToResponse(result.toResponse(), set)
60
+ }
61
+ if (result instanceof Response) {
62
+ return appendCookiesToResponse(result, set)
63
+ }
64
+ const headers = headersInit(set)
65
+ const status = set.status ?? (result === undefined ? 204 : 200)
66
+ if (headers === undefined && result !== undefined) {
67
+ // Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
68
+ // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
69
+ // same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
70
+ // result) delegates to Response.json so its TypeError contract stays the single source.
71
+ const body = JSON.stringify(result) as string | undefined
72
+ if (body !== undefined) {
73
+ return new Response(
74
+ body,
75
+ status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
76
+ )
77
+ }
78
+ }
79
+ const init: ResponseInit = headers === undefined ? { status } : { status, headers }
80
+ return result === undefined ? new Response(null, init) : Response.json(result, init)
81
+ }
82
+
83
+ export function appendCookiesToResponse(response: Response, set: CtxSet): Response {
84
+ // A handler may queue cookies (`c.set.cookie` - e.g. a session cookie) AND return its own Response
85
+ // (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
86
+ // returned Response - otherwise the canonical set-session-then-redirect pattern would silently drop
87
+ // the cookie. (Other `c.set` fields stay the returned Response's own concern.)
88
+ const cookies = set._cookies
89
+ if (cookies !== undefined && cookies.length > 0) {
90
+ for (const cookie of cookies) response.headers.append("set-cookie", cookie)
91
+ }
92
+ return response
93
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Runtime-core: the small primitives shared across the server's request/response modules - the context
3
+ * symbols, the `ResponseResult` marker, the lazy never-abort signal / unbounded budget singletons, and
4
+ * the request-source accessors. Kept in one leaf (it only type-imports the server's spine types, never
5
+ * its values) so `request-context`, `respond`, `node-outcome`, and the kernel form a cycle-free graph.
6
+ */
7
+ import { createUnboundedRequestBudget, type RequestBudget } from "../budget.ts"
8
+ import type { CtxSet, RequestSource } from "./server.ts"
9
+
10
+ /** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
11
+ export type HandlerResult = Response | unknown
12
+
13
+ export const RESPONSE_RESULT = Symbol.for("nifra.response.result")
14
+ export const CONTEXT_SET = Symbol("nifra.context.set")
15
+ export const CONTEXT_SEARCH = Symbol("nifra.context.search")
16
+
17
+ export interface ResponseResult {
18
+ readonly [RESPONSE_RESULT]: true
19
+ toResponse(): Response
20
+ toNodeBody?(): {
21
+ readonly status: number
22
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
23
+ readonly body: string | Uint8Array
24
+ }
25
+ }
26
+
27
+ export function isResponseResult(value: unknown): value is ResponseResult {
28
+ return (
29
+ typeof value === "object" &&
30
+ value !== null &&
31
+ (value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
32
+ typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
33
+ )
34
+ }
35
+
36
+ /** The concrete `Request` for a source - itself when a real `Request` was passed (the Web path), or the
37
+ * lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
38
+ * on the Web hot path. */
39
+ export function requestOf(source: RequestSource): Request {
40
+ return source.request ?? (source as unknown as Request)
41
+ }
42
+
43
+ export function headerOf(source: RequestSource, name: string): string | null {
44
+ return source.header?.(name) ?? source.headers.get(name)
45
+ }
46
+
47
+ /** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
48
+ * rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
49
+ export const fallbackWaitUntil = (promise: Promise<unknown>): void => {
50
+ void promise.catch(() => {})
51
+ }
52
+
53
+ // Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
54
+ // live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
55
+ export const EMPTY_RESPONSE_CONTROLS = Object.freeze({}) as CtxSet
56
+
57
+ export const TEXT_DECODER = new TextDecoder()
58
+
59
+ /**
60
+ * Shared never-aborting signal for `ctx.signal` when no timeout is armed - created lazily and
61
+ * cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
62
+ * constructing one in global scope; the first request builds it inside the handler, then it's
63
+ * reused at zero per-request cost.
64
+ */
65
+ let neverAbortSignal: AbortSignal | undefined
66
+ let unboundedRequestBudget: RequestBudget | undefined
67
+ export const getNeverAbortSignal = (): AbortSignal => {
68
+ neverAbortSignal ??= new AbortController().signal
69
+ return neverAbortSignal
70
+ }
71
+ export const getUnboundedRequestBudget = (): RequestBudget => {
72
+ unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
73
+ return unboundedRequestBudget
74
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Public API-surface types for the server: construction options, the capacity-admission contract,
3
+ * route/tool/MCP descriptors, and the middleware bundle shape. Pure types (no runtime), split out of
4
+ * the server kernel so the descriptor/option vocabulary is one file, distinct from the engine.
5
+ */
6
+ import type { CapabilityUseEvent } from "../internal/capability-runtime.ts"
7
+ import type { AssuranceEvidence } from "../internal/route-assurance.ts"
8
+ import type { Method } from "../router/router.ts"
9
+ import type { ClientIpTrust } from "./client-ip.ts"
10
+ import type { Context, Platform, RouteSchema } from "./context.ts"
11
+ import type { Logger } from "./logger.ts"
12
+ import type { MaybePromise, OnRequestResult } from "./server.ts"
13
+
14
+ /**
15
+ * The outcome of a capacity-admission decision. `admitted` requests carry a `release` the server calls
16
+ * exactly once when the response is finalized; a shed request carries a ready `429` Response.
17
+ */
18
+ export type AdmissionDecision =
19
+ | { readonly admitted: true; release(): void }
20
+ | { readonly admitted: false; readonly response: Response }
21
+
22
+ /**
23
+ * A capacity-admission gate. Decides, per request, whether the instance has capacity to run it now -
24
+ * bounding *concurrency*, which rate limits (frequency) and deadlines (duration) do not. Provide an
25
+ * implementation (see `@nifrajs/middleware`'s `createAdmissionController`) as {@link ServerOptions.admission}.
26
+ *
27
+ * Enabling it trades the fused native-route lane for native matching + this gate (the gate cannot be a
28
+ * per-request `onRequest` hook without disabling native routes entirely); when unset, the request path
29
+ * is untouched.
30
+ */
31
+ export interface AdmissionController {
32
+ admit(req: Request): AdmissionDecision | Promise<AdmissionDecision>
33
+ }
34
+
35
+ export interface ServerOptions {
36
+ /**
37
+ * Max request body size (bytes), enforced **only when a route declares a body schema** - the cap
38
+ * lives in the schema-validated read path. Default 1_000_000.
39
+ *
40
+ * A route WITHOUT a body schema (raw body, file upload, BYO-validation) that reads `c.req` directly
41
+ * is not auto-bounded - use **`c.boundedBody(maxBytes?)`** / **`c.boundedJson(maxBytes?)`**, which
42
+ * apply this same cap (override per route by passing `maxBytes` - larger for an upload endpoint,
43
+ * smaller to tighten one).
44
+ */
45
+ readonly maxBodyBytes?: number
46
+ /** Max inbound WebSocket message size (bytes) when `listen()`ing on Bun - frames over this are rejected
47
+ * by the runtime before reaching your handler (so a huge frame can't be JSON-parsed into memory).
48
+ * Default: `maxBodyBytes` (1 MB). */
49
+ readonly wsMaxPayloadBytes?: number
50
+ /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
51
+ readonly requestTimeoutMs?: number
52
+ /**
53
+ * How to derive `c.clientIp` behind a reverse proxy or CDN. Omit (default) to trust only the raw
54
+ * socket peer and never believe a forwarded header. Set `{ trustedHops: n }` when the app sits
55
+ * behind `n` proxies you operate (the caller is read from `X-Forwarded-For` past those hops), or
56
+ * `{ header: name }` to trust one edge-set header's first value. Declaring trust the app can't
57
+ * actually enforce lets clients forge their IP, so leave it unset unless a proxy really fronts you.
58
+ */
59
+ readonly clientIp?: ClientIpTrust
60
+ /**
61
+ * Admit the public `x-nifra-deadline` header and let it shorten local work. Disabled by default:
62
+ * services that participate in trusted cross-service deadline propagation opt in explicitly,
63
+ * while ordinary/public HTTP routes pay no header-admission tax and ignore hostile client values.
64
+ */
65
+ readonly acceptInboundDeadlines?: boolean
66
+ /**
67
+ * Maximum time accepted from an inbound `x-nifra-deadline`, in milliseconds. An incoming absolute
68
+ * deadline may shorten this cap (and `requestTimeoutMs`, when configured), never extend it.
69
+ * Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
70
+ */
71
+ readonly maxInboundDeadlineMs?: number
72
+ /** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
73
+ readonly gracefulSignals?: boolean
74
+ /**
75
+ * Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
76
+ * healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
77
+ * Off by default. Enabling it disables the fused native-route fast path (requests route through the
78
+ * shared matched lane where the gate runs); when unset, the request path pays nothing.
79
+ */
80
+ readonly admission?: AdmissionController
81
+ /**
82
+ * Optional token-only observation hook called by `useCapability(c, id)`. It receives no request,
83
+ * parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
84
+ */
85
+ readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
86
+ /** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
87
+ readonly logger?: Logger
88
+ /**
89
+ * App-wide fallback fired when a route **without its own `onValidationError`** fails body/query
90
+ * validation. Same contract as the per-route hook (`(issues, ctx, kind) => Response | repaired-value |
91
+ * undefined`): a route's own hook takes precedence, and a route can fall through to the plain `422` by
92
+ * returning `undefined`. Use it for one app-wide error envelope (like tRPC's `errorFormatter` /
93
+ * Fastify's `setErrorHandler`) instead of repeating a formatter per route.
94
+ */
95
+ readonly onValidationError?: RouteSchema["onValidationError"]
96
+ }
97
+
98
+ /**
99
+ * MCP tool safety hints, surfaced in `tools/list`, that tell an agent how risky a `.tool()` call is - so it
100
+ * can decide whether to auto-invoke or confirm first. All optional; an omitted hint means "unknown". Mirrors
101
+ * the MCP spec's tool `annotations`.
102
+ */
103
+ export interface ToolAnnotations {
104
+ /** Human-readable display title for the tool, distinct from the machine `name`. */
105
+ readonly title?: string
106
+ /** The tool does not modify its environment (a pure read). */
107
+ readonly readOnlyHint?: boolean
108
+ /** The tool may perform destructive updates - only meaningful when `readOnlyHint` is not `true`. */
109
+ readonly destructiveHint?: boolean
110
+ /** Repeated calls with the same arguments have no additional effect beyond the first. */
111
+ readonly idempotentHint?: boolean
112
+ /** The tool interacts with external entities (an "open world" beyond this server). */
113
+ readonly openWorldHint?: boolean
114
+ }
115
+
116
+ /**
117
+ * A registered route's public descriptor - method, path, and input schemas. The
118
+ * router trie discards the original patterns, so this flat list is what lets tools
119
+ * (e.g. `toOpenAPI`) enumerate routes after registration.
120
+ */
121
+ export interface RouteDescriptor {
122
+ readonly method: Method
123
+ readonly path: string
124
+ readonly schema: RouteSchema | undefined
125
+ /** Effective enforcement evidence, populated only during reflection/introspection. */
126
+ readonly assurance?: readonly AssuranceEvidence[]
127
+ /** Normalized declared effect tokens, populated only when the route declares any. */
128
+ readonly capabilities?: readonly string[]
129
+ /** Set when the route is a declared dynamic route family (`schema.family`). */
130
+ readonly family?: boolean
131
+ readonly tool?: {
132
+ readonly name: string
133
+ readonly description: string
134
+ readonly annotations?: ToolAnnotations
135
+ }
136
+ }
137
+
138
+ /** A message in an MCP prompt's rendered output (see {@link Server.prompt}). */
139
+ export interface PromptMessage {
140
+ readonly role: "user" | "assistant"
141
+ readonly content: { readonly type: "text"; readonly text: string }
142
+ }
143
+
144
+ /** One declared argument of an MCP prompt, surfaced in `prompts/list`. */
145
+ export interface PromptArgument {
146
+ readonly name: string
147
+ readonly description?: string
148
+ readonly required?: boolean
149
+ }
150
+
151
+ /** An app-declared MCP resource - read-only data an agent can fetch through `nifra mcp`. */
152
+ export interface McpResourceDescriptor {
153
+ readonly uri: string
154
+ readonly name: string
155
+ readonly description?: string
156
+ readonly mimeType?: string
157
+ readonly read: () => MaybePromise<string | { readonly text: string; readonly mimeType?: string }>
158
+ }
159
+
160
+ /** An app-declared MCP prompt - a reusable prompt template an agent can fetch through `nifra mcp`. */
161
+ export interface McpPromptDescriptor {
162
+ readonly name: string
163
+ readonly description: string
164
+ readonly arguments?: readonly PromptArgument[]
165
+ readonly handler: (args: Record<string, string>) => MaybePromise<readonly PromptMessage[]>
166
+ }
167
+
168
+ /**
169
+ * The handle `listen()` returns - the slice of Bun's server nifra holds and exposes.
170
+ * Declared explicitly (rather than `ReturnType<typeof Bun.serve>`) so the public type
171
+ * surface doesn't leak the ambient `Bun` global into consumers' `.d.ts` resolution.
172
+ */
173
+ export interface RunningServer {
174
+ readonly port: number
175
+ readonly hostname: string
176
+ readonly pendingRequests: number
177
+ stop(closeActiveConnections?: boolean): void
178
+ }
179
+
180
+ /**
181
+ * A bundle of lifecycle hooks applied together via {@link Server.use} - the unit
182
+ * `@nifrajs/middleware` ships (cors, security headers, rate-limit). Every hook is
183
+ * optional and wired to its lifecycle point. Middleware is context-agnostic (sees
184
+ * the base `Context`); `use` does no context-type merging - the full type-merging
185
+ * plugin system is deferred, and `.use` is reserved as its future entry point.
186
+ */
187
+ export interface Middleware {
188
+ readonly name?: string
189
+ readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
190
+ readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
191
+ readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
192
+ readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
193
+ readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
194
+ readonly onResponseFinalized?: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
195
+ readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
196
+ }
197
+
198
+ /** The terminal response-pipeline outcome observed after every transforming `onResponse` hook. */
199
+ export interface ResponseFinalization {
200
+ /** The final response, or the last response available when a transformation failed. */
201
+ readonly response: Response
202
+ /** A response-hook failure. Terminal observers run fail-open before the error is rethrown. */
203
+ readonly error?: unknown
204
+ }