@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,2479 @@
1
+ import { FrameworkError } from "../errors.ts"
2
+ import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
3
+ import type {
4
+ InferOutput,
5
+ StandardIssue,
6
+ StandardResult,
7
+ StandardSchemaV1,
8
+ } from "../schema/standard.ts"
9
+ import { drainCapped, parseContentLength, readBoundedBytes } from "./body.ts"
10
+ import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
11
+ import { type CookieOptions, parseCookies, serializeCookie } from "./cookies.ts"
12
+ import { jsonLogger, type Logger } from "./logger.ts"
13
+ import type { AddRoute, EmptyRegistry, OutputOf, Registry, RouteInfoFor } from "./registry.ts"
14
+ import type {
15
+ StandardWebSocket,
16
+ TopicRegistry,
17
+ WebSocketContext,
18
+ WebSocketHandler,
19
+ WebSocketUpgradeOutcome,
20
+ } from "./websocket.ts"
21
+ import type { BunWsData } from "./ws-bun.ts"
22
+ import { getWsRuntime, type WsRuntime } from "./ws-hook.ts"
23
+
24
+ type MaybePromise<T> = T | Promise<T>
25
+
26
+ /**
27
+ * Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
28
+ * their `Request` **directly** (zero wrapper allocation on the hot path — `request` is simply absent and
29
+ * {@link requestOf} returns the source itself). Node's adapter passes a *lazy* source whose `request`
30
+ * getter builds an undici `Request` only when user code reads `c.req`, an onRequest/onResponse hook
31
+ * needs it, or a body helper consumes it — so the common Node request never pays for a `Request` build.
32
+ */
33
+ export interface RequestSource {
34
+ readonly method: string
35
+ readonly url: string
36
+ readonly headers: Headers
37
+ header?(name: string): string | null
38
+ readonly body: ReadableStream<Uint8Array> | null
39
+ arrayBuffer(): Promise<ArrayBuffer>
40
+ json(): Promise<unknown>
41
+ /** Present only when materializing a `Request` is non-trivial (the Node lazy source); for a real
42
+ * `Request` passed as the source it's absent and {@link requestOf} returns the source itself. */
43
+ readonly request?: Request
44
+ }
45
+
46
+ /** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
47
+ * lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
48
+ * on the Web hot path. */
49
+ function requestOf(source: RequestSource): Request {
50
+ return source.request ?? (source as unknown as Request)
51
+ }
52
+
53
+ function headerOf(source: RequestSource, name: string): string | null {
54
+ return source.header?.(name) ?? source.headers.get(name)
55
+ }
56
+
57
+ /** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
58
+ type EmptyContext = NonNullable<unknown>
59
+
60
+ /**
61
+ * Extracts the app's platform `Env` from its context `Ctx`. `server<Env>()` seeds `Ctx` with
62
+ * `{ env: Env }`, so this pulls that back out to type `fetch`/`toFetchHandler`'s `env` argument
63
+ * against the app's declared bindings. Defaults to `unknown` when no env was declared.
64
+ */
65
+ type EnvOf<Ctx> = Ctx extends { readonly env: infer E } ? E : unknown
66
+
67
+ /** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
68
+ type HandlerResult = Response | unknown
69
+ type ContextlessHandler = () => MaybePromise<HandlerResult>
70
+
71
+ const RESPONSE_RESULT = Symbol.for("nifra.response.result")
72
+ const CONTEXT_SET = Symbol("nifra.context.set")
73
+ const CONTEXT_SEARCH = Symbol("nifra.context.search")
74
+ const functionToString = Function.prototype.toString
75
+ const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/
76
+
77
+ interface ResponseResult {
78
+ readonly [RESPONSE_RESULT]: true
79
+ toResponse(): Response
80
+ toNodeBody?(): {
81
+ readonly status: number
82
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
83
+ readonly body: string | Uint8Array
84
+ }
85
+ }
86
+
87
+ function isResponseResult(value: unknown): value is ResponseResult {
88
+ return (
89
+ typeof value === "object" &&
90
+ value !== null &&
91
+ (value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
92
+ typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
93
+ )
94
+ }
95
+
96
+ /** Internal, path-erased runtime context. The typed `Context<Path, S>` is a structural view of this. */
97
+ interface RawContext {
98
+ readonly req: Request
99
+ readonly params: Record<string, string>
100
+ query: unknown
101
+ readonly cookies: Readonly<Record<string, string>>
102
+ body: unknown
103
+ readonly set: ResponseControls
104
+ readonly [CONTEXT_SET]: () => CtxSet | undefined
105
+ readonly [CONTEXT_SEARCH]: string
106
+ readonly signal: AbortSignal
107
+ readonly env: unknown
108
+ readonly waitUntil: (promise: Promise<unknown>) => void
109
+ readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
110
+ readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
111
+ }
112
+
113
+ /** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
114
+ * rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
115
+ const fallbackWaitUntil = (promise: Promise<unknown>): void => {
116
+ void promise.catch(() => {})
117
+ }
118
+
119
+ type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
120
+
121
+ /** Broad shape so the implementation signature is compatible with both typed overloads. */
122
+ type ErasedHandler = (ctx: never) => MaybePromise<HandlerResult>
123
+
124
+ /** A `derive` computes per-request context extensions; stored path-erased. */
125
+ type RawDerive = (ctx: RawContext) => MaybePromise<object>
126
+ type RawBeforeHandle = (ctx: RawContext) => MaybePromise<unknown>
127
+ type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown>
128
+ type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
129
+ type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
130
+ export type OnRequestResult = Response | Request | undefined
131
+ type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
132
+ type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
133
+
134
+ interface RouteEntry {
135
+ readonly handler: InternalHandler
136
+ readonly schema: RouteSchema | undefined
137
+ /** Per-request context extensions captured at registration (order-scoped). */
138
+ readonly derives: ReadonlyArray<RawDerive>
139
+ /** Static context extensions captured at registration. */
140
+ readonly decorations: Record<string, unknown>
141
+ /** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
142
+ * `Object.assign` on the (common) no-decoration route. */
143
+ readonly hasDecorations: boolean
144
+ /** Lifecycle hooks captured at registration (order-scoped). */
145
+ readonly beforeHandle: ReadonlyArray<RawBeforeHandle>
146
+ readonly afterHandle: ReadonlyArray<RawAfterHandle>
147
+ readonly onError: ReadonlyArray<RawErrorHandler>
148
+ /** Wraps the matched route lifecycle. Empty for the common no-around path. */
149
+ readonly around: ReadonlyArray<RawAround>
150
+ /**
151
+ * Precomputed: the route has no body/query schema and no derive/beforeHandle/afterHandle/onError
152
+ * hooks — so its lifecycle reduces to `finalize(handler(ctx), set)` with simple error handling, no
153
+ * `await` unless the handler itself is async. Such routes take the **synchronous fast path**
154
+ * ({@link Server.runBare}), which skips the `async` machinery of the full {@link Server.runLifecycle}
155
+ * (≈2 promise frames/req — the per-request tax codegen routers avoid; nifra avoids it here without
156
+ * `eval`, so it stays edge-safe). Static decorations are still applied (they're a sync `Object.assign`).
157
+ */
158
+ readonly bare: boolean
159
+ /**
160
+ * A narrower bare-route path for syntactic zero-parameter arrow handlers (`() => ...`). Unlike
161
+ * `handler.length === 0`, this intentionally excludes `function () { arguments }`, rest params,
162
+ * default params, bound/native functions, and anything else that can observe the passed context.
163
+ */
164
+ readonly contextlessBare: boolean
165
+ /** Precomputed body-schema-only path: JSON body validation + handler, no other lifecycle hooks. */
166
+ readonly bodyOnly: boolean
167
+ /** Precomputed query-schema-only path: query validation + handler, no other lifecycle hooks. */
168
+ readonly queryOnly: boolean
169
+ /**
170
+ * Registration-time-fused Web lane for a {@link bare} route with no `around` hooks: context (when
171
+ * the handler can observe one), handler, and the JSON respond collapsed into ONE monomorphic
172
+ * closure — no per-request branch ladder, no `responseSet` lookup, no codegen (`new Function`
173
+ * measured equivalent to closures on JSC). Only the Web finalizer
174
+ * (`fetch`) uses it — the node-direct path keeps the generic lifecycle.
175
+ */
176
+ readonly fusedWeb: FusedWebRunner | undefined
177
+ }
178
+
179
+ /** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
180
+ type FusedWebRunner = (
181
+ source: RequestSource,
182
+ params: Record<string, string>,
183
+ search: string,
184
+ signal: AbortSignal,
185
+ platform: Platform | undefined,
186
+ ) => MaybePromise<Response>
187
+
188
+ /** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
189
+ interface WsEntry {
190
+ readonly handler: WebSocketHandler
191
+ }
192
+
193
+ /** Structural view of the Bun `Server` `upgrade` the `fetch` 2nd arg exposes. */
194
+ interface BunUpgradeServer {
195
+ upgrade(request: Request, options?: { data?: BunWsData }): boolean
196
+ }
197
+
198
+ const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
199
+
200
+ /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
201
+ function requireWsRuntime(): WsRuntime {
202
+ const runtime = getWsRuntime()
203
+ if (runtime === undefined) {
204
+ throw new FrameworkError(
205
+ "WS_RUNTIME_MISSING",
206
+ 'app.ws() needs the WebSocket runtime, which ships as a subpath so no-WebSocket apps stay lean. Add `import "@nifrajs/core/ws"` at your server entry.',
207
+ )
208
+ }
209
+ return runtime
210
+ }
211
+
212
+ /** The handler's permitted return type. When the route declares a `response` schema, the return is
213
+ * constrained to the contract's type (or a raw `Response`) — so the implementation can't drift from the
214
+ * declared contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
215
+ type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
216
+ ? InferOutput<R> | Response
217
+ : HandlerResult
218
+
219
+ /**
220
+ * Public handler shape: context typed from the path, the (optional) schema, and
221
+ * any accumulated middleware context `Ctx` (from `derive`/`decorate`).
222
+ */
223
+ export type Handler<
224
+ Path extends string,
225
+ S extends RouteSchema = RouteSchema,
226
+ Ctx = EmptyContext,
227
+ > = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
228
+
229
+ export interface ServerOptions {
230
+ /**
231
+ * Max request body size (bytes), enforced **only when a route declares a body schema** — the cap
232
+ * lives in the schema-validated read path. Default 1_000_000.
233
+ *
234
+ * A route WITHOUT a body schema (raw body, file upload, BYO-validation) that reads `c.req` directly
235
+ * is not auto-bounded — use **`c.boundedBody(maxBytes?)`** / **`c.boundedJson(maxBytes?)`**, which
236
+ * apply this same cap (override per route by passing `maxBytes` — larger for an upload endpoint,
237
+ * smaller to tighten one).
238
+ */
239
+ readonly maxBodyBytes?: number
240
+ /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
241
+ readonly requestTimeoutMs?: number
242
+ /** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
243
+ readonly gracefulSignals?: boolean
244
+ /** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
245
+ readonly logger?: Logger
246
+ }
247
+
248
+ /**
249
+ * A registered route's public descriptor — method, path, and input schemas. The
250
+ * router trie discards the original patterns, so this flat list is what lets tools
251
+ * (e.g. `toOpenAPI`) enumerate routes after registration.
252
+ */
253
+ export interface RouteDescriptor {
254
+ readonly method: Method
255
+ readonly path: string
256
+ readonly schema: RouteSchema | undefined
257
+ }
258
+
259
+ /**
260
+ * The handle `listen()` returns — the slice of Bun's server nifra holds and exposes.
261
+ * Declared explicitly (rather than `ReturnType<typeof Bun.serve>`) so the public type
262
+ * surface doesn't leak the ambient `Bun` global into consumers' `.d.ts` resolution.
263
+ */
264
+ export interface RunningServer {
265
+ readonly port: number
266
+ readonly hostname: string
267
+ readonly pendingRequests: number
268
+ stop(closeActiveConnections?: boolean): void
269
+ }
270
+
271
+ /**
272
+ * A bundle of lifecycle hooks applied together via {@link Server.use} — the unit
273
+ * `@nifrajs/middleware` ships (cors, security headers, rate-limit). Every hook is
274
+ * optional and wired to its lifecycle point. Middleware is context-agnostic (sees
275
+ * the base `Context`); `use` does no context-type merging — the full type-merging
276
+ * plugin system is deferred, and `.use` is reserved as its future entry point.
277
+ */
278
+ export interface Middleware {
279
+ readonly name?: string
280
+ readonly onRequest?: (req: Request) => MaybePromise<OnRequestResult>
281
+ readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
282
+ readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
283
+ readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
284
+ readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
285
+ readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
286
+ }
287
+
288
+ // A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
289
+ // (the precise threading happens at the `use` call site, which is generic over the *concrete* `this`).
290
+ // biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
291
+ export type AnyServer = Server<any, any>
292
+
293
+ /**
294
+ * A nifra **plugin**: a function that augments an app — calling `use`/`derive`/`decorate` and/or
295
+ * registering routes — and returns it. Because `derive`/`decorate` are type-threaded, an **inline**
296
+ * `app.use((a) => a.derive(...).decorate(...))` carries the added context to handlers defined after
297
+ * it (the `use` overload is generic over the concrete `this`). Wrap with {@link definePlugin} to
298
+ * attach a name for idempotent dedupe (applying the same named plugin twice — e.g. transitively — is
299
+ * a no-op).
300
+ */
301
+ export type NifraPlugin<In extends AnyServer = AnyServer, Out extends AnyServer = In> = ((
302
+ app: In,
303
+ ) => Out) & { readonly pluginName?: string }
304
+
305
+ /**
306
+ * A named type-identity plugin built with {@link defineIdentityPlugin}. It returns the same concrete
307
+ * server type it receives, preserving the caller's typed registry and context across `.use()` while
308
+ * still allowing the plugin to register runtime hooks or handlers.
309
+ */
310
+ export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
311
+ readonly pluginName?: string
312
+ }
313
+
314
+ /**
315
+ * Name + ergonomics for a plugin. `app.use(myPlugin)` applies it once; a second `use` of the same
316
+ * name is skipped (idempotent), so plugins can depend on each other without double-registering hooks.
317
+ *
318
+ * ```ts
319
+ * export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
320
+ * app.use(requestId) // downstream handlers see c.requestId
321
+ * ```
322
+ */
323
+ export function definePlugin<In extends AnyServer, Out extends AnyServer>(
324
+ name: string,
325
+ apply: (app: In) => Out,
326
+ ): NifraPlugin<In, Out> {
327
+ return Object.assign(apply, { pluginName: name }) as NifraPlugin<In, Out>
328
+ }
329
+
330
+ /**
331
+ * Define a type-**identity** plugin: it registers routes/hooks as a side effect but returns the app with
332
+ * its `Registry` + `Context` UNCHANGED. Use this (not {@link definePlugin}) for any plugin that doesn't
333
+ * add context types — e.g. one mounting an auth handler. It threads the caller's *concrete* server type
334
+ * through `use`, so routes declared after `app.use(plugin)` keep their types.
335
+ *
336
+ * Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
337
+ * `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
338
+ * (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
339
+ *
340
+ * ```ts
341
+ * export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
342
+ * const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
343
+ * ```
344
+ */
345
+ export function defineIdentityPlugin(
346
+ name: string,
347
+ apply: <S extends AnyServer>(app: S) => S,
348
+ ): IdentityPlugin {
349
+ return Object.assign(apply, { pluginName: name }) as IdentityPlugin
350
+ }
351
+
352
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000
353
+ const DEFAULT_DRAIN_MS = 10_000
354
+ const DRAIN_POLL_MS = 10
355
+ const TEXT_DECODER = new TextDecoder()
356
+ /**
357
+ * Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
358
+ * cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
359
+ * constructing one in global scope; the first request builds it inside the handler, then it's
360
+ * reused at zero per-request cost.
361
+ */
362
+ let neverAbortSignal: AbortSignal | undefined
363
+ const getNeverAbortSignal = (): AbortSignal => {
364
+ neverAbortSignal ??= new AbortController().signal
365
+ return neverAbortSignal
366
+ }
367
+
368
+ function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
369
+ return Response.json(
370
+ { ok: false, error },
371
+ headers !== undefined ? { status, headers } : { status },
372
+ )
373
+ }
374
+
375
+ function validationError(issues: ReadonlyArray<StandardIssue>): Response {
376
+ const serialized = issues.map((issue) => {
377
+ const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
378
+ return path !== undefined ? { message: issue.message, path } : { message: issue.message }
379
+ })
380
+ return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 400 })
381
+ }
382
+
383
+ interface UrlParts {
384
+ readonly pathname: string
385
+ readonly search: string
386
+ }
387
+
388
+ // Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
389
+ // `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
390
+ // the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
391
+ // string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
392
+ export function urlPartsOf(url: string): UrlParts {
393
+ const schemeEnd = url.indexOf("://")
394
+ const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
395
+ if (start === -1) return { pathname: "/", search: "" }
396
+
397
+ let pathEnd = url.length
398
+ let searchStart = -1
399
+ let searchEnd = url.length
400
+ for (let i = start; i < url.length; i++) {
401
+ const c = url.charCodeAt(i)
402
+ if (c === 63 /* ? */ && searchStart === -1) {
403
+ pathEnd = i
404
+ searchStart = i
405
+ } else if (c === 35 /* # */) {
406
+ if (searchStart === -1) pathEnd = i
407
+ searchEnd = i
408
+ break
409
+ }
410
+ }
411
+
412
+ return {
413
+ pathname: url.slice(start, pathEnd),
414
+ search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
415
+ }
416
+ }
417
+
418
+ // Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
419
+ // for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
420
+ export function pathnameOf(url: string): string {
421
+ return urlPartsOf(url).pathname
422
+ }
423
+
424
+ // The query string ("?a=1", or "" when absent) — for lazily building `c.query` only when read.
425
+ // The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
426
+ // is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
427
+ // for provable equivalence with `new URL(req.url).search`.
428
+ export function searchOf(url: string): string {
429
+ return urlPartsOf(url).search
430
+ }
431
+
432
+ /** A query value: a single occurrence is a string; a repeated key promotes to a string[] so an
433
+ * array query schema (`t.array(t.string())`) can validate `?tag=a&tag=b` — last-wins silently
434
+ * dropped values before (audit 2026-06). Single-occurrence keys stay plain strings, so existing
435
+ * `t.string()` schemas are untouched; a repeated key against a string schema now FAILS validation
436
+ * (an explicit 400 beats silently picking one). */
437
+ export type QueryValue = string | string[]
438
+
439
+ /** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
440
+ * `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key — the
441
+ * promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
442
+ * case (direct assignment on a null-proto object creates an own property). */
443
+ function setQueryValue(out: Record<string, QueryValue>, key: string, value: string): void {
444
+ const existing = out[key]
445
+ if (existing === undefined) {
446
+ out[key] = value
447
+ } else if (typeof existing === "string") {
448
+ out[key] = [existing, value]
449
+ } else {
450
+ existing.push(value)
451
+ }
452
+ }
453
+
454
+ function queryObjectFallback(search: string): Record<string, QueryValue> {
455
+ // Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
456
+ // (fromEntries is last-wins), and __proto__ needs the same own-property guard.
457
+ const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
458
+ for (const [key, value] of new URLSearchParams(search)) {
459
+ setQueryValue(out, key, value)
460
+ }
461
+ return out
462
+ }
463
+
464
+ /**
465
+ * Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
466
+ * `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
467
+ * percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
468
+ * to `string[]` on BOTH paths (see {@link setQueryValue}).
469
+ */
470
+ // Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
471
+ // populated path), allocated once instead of per request.
472
+ const EMPTY_QUERY = Object.freeze(Object.create(null)) as Record<string, QueryValue>
473
+
474
+ export function queryObjectOf(search: string): Record<string, QueryValue> {
475
+ const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0
476
+ if (start >= search.length) return EMPTY_QUERY
477
+
478
+ for (let i = start; i < search.length; i++) {
479
+ const c = search.charCodeAt(i)
480
+ if (c === 37 /* % */ || c === 43 /* + */) return queryObjectFallback(search)
481
+ }
482
+
483
+ const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
484
+ let pos = start
485
+ while (pos <= search.length) {
486
+ const amp = search.indexOf("&", pos)
487
+ const end = amp === -1 ? search.length : amp
488
+ if (end > pos) {
489
+ const eq = search.indexOf("=", pos)
490
+ const split = eq !== -1 && eq < end ? eq : end
491
+ const key = search.slice(pos, split)
492
+ const value = split === end ? "" : search.slice(split + 1, end)
493
+ setQueryValue(out, key, value)
494
+ }
495
+ if (amp === -1) break
496
+ pos = amp + 1
497
+ }
498
+ return out
499
+ }
500
+
501
+ /** `application/x-www-form-urlencoded`, with or without a charset suffix. */
502
+ function isUrlEncodedForm(contentType: string): boolean {
503
+ return (
504
+ contentType === "application/x-www-form-urlencoded" ||
505
+ contentType.startsWith("application/x-www-form-urlencoded;")
506
+ )
507
+ }
508
+
509
+ const FORM_DECODER = new TextDecoder()
510
+
511
+ /**
512
+ * Read an HTML-form body (urlencoded) into the plain object a body schema validates — same byte
513
+ * cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
514
+ * repeated-key → `string[]` promotion as query parsing, same `__proto__` guard.
515
+ */
516
+ async function readBoundedForm(
517
+ req: RequestSource,
518
+ maxBytes: number,
519
+ ): Promise<Record<string, QueryValue> | Response> {
520
+ const read = await readBoundedBytes(req, maxBytes)
521
+ if (!read.ok) {
522
+ return read.status === 413
523
+ ? jsonError(413, "payload_too_large")
524
+ : jsonError(400, "invalid_content_length")
525
+ }
526
+ const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
527
+ // URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
528
+ // it never throws on junk input, so no try/catch is needed here.
529
+ for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
530
+ setQueryValue(out, key, value)
531
+ }
532
+ return out
533
+ }
534
+
535
+ function decodeParams(raw: Record<string, string>): Record<string, string> | null {
536
+ let out: Record<string, string> | undefined
537
+ for (const key in raw) {
538
+ const value = raw[key]!
539
+ // `decodeURIComponent` only changes strings containing `%`; for the common
540
+ // unencoded param (`/users/123`) skip the call and its throw-check entirely —
541
+ // semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
542
+ // param is already plain, return the router's per-request object directly and skip a clone.
543
+ if (!value.includes("%")) {
544
+ continue
545
+ }
546
+ try {
547
+ out ??= { ...raw }
548
+ out[key] = decodeURIComponent(value)
549
+ } catch {
550
+ return null
551
+ }
552
+ }
553
+ return out ?? raw
554
+ }
555
+
556
+ /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
557
+ * anything when no handler touched `c.set.*`. Server-internal. */
558
+ type CtxSet = ResponseControls & {
559
+ _headers?: Record<string, string>
560
+ /** Accumulated `Set-Cookie` values — a list, since a `Record` would collapse multiple cookies. */
561
+ _cookies?: string[]
562
+ }
563
+
564
+ class LazyResponseControls implements CtxSet {
565
+ status?: number
566
+ _headers?: Record<string, string>
567
+ _cookies?: string[]
568
+
569
+ get headers(): Record<string, string> {
570
+ this._headers ??= {}
571
+ return this._headers
572
+ }
573
+
574
+ cookie(name: string, value: string, options?: CookieOptions): void {
575
+ // Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
576
+ const merged: CookieOptions = {
577
+ httpOnly: true,
578
+ secure: true,
579
+ sameSite: "lax",
580
+ path: "/",
581
+ ...options,
582
+ }
583
+ this._cookies ??= []
584
+ this._cookies.push(serializeCookie(name, value, merged))
585
+ }
586
+
587
+ deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
588
+ // Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
589
+ this._cookies ??= []
590
+ this._cookies.push(
591
+ serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
592
+ )
593
+ }
594
+ }
595
+
596
+ // Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
597
+ // live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
598
+ const EMPTY_RESPONSE_CONTROLS = Object.freeze({}) as CtxSet
599
+
600
+ function responseSet(ctx: RawContext): CtxSet {
601
+ return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
602
+ }
603
+
604
+ function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
605
+ if (handler.length !== 0) return false
606
+ try {
607
+ return CONTEXTLESS_ARROW.test(functionToString.call(handler))
608
+ } catch {
609
+ return false
610
+ }
611
+ }
612
+
613
+ class RequestContext implements RawContext {
614
+ readonly params: Record<string, string>
615
+ readonly signal: AbortSignal
616
+ readonly env: unknown
617
+ readonly waitUntil: (promise: Promise<unknown>) => void
618
+ body: unknown
619
+ readonly [CONTEXT_SEARCH]: string
620
+
621
+ private setValue: CtxSet | undefined
622
+ private queryValue: unknown
623
+ private queryReady: boolean
624
+ private cookiesValue: Readonly<Record<string, string>> | undefined
625
+ private readonly source: RequestSource
626
+ private readonly maxBodyBytes: number
627
+
628
+ constructor(
629
+ source: RequestSource,
630
+ params: Record<string, string>,
631
+ search: string,
632
+ signal: AbortSignal,
633
+ platform: Platform | undefined,
634
+ maxBodyBytes: number,
635
+ ) {
636
+ this.source = source
637
+ this.params = params
638
+ this[CONTEXT_SEARCH] = search
639
+ this.signal = signal
640
+ this.env = platform?.env
641
+ this.waitUntil = platform?.waitUntil ?? fallbackWaitUntil
642
+ this.maxBodyBytes = maxBodyBytes
643
+ this.setValue = undefined
644
+ this.body = undefined
645
+ this.queryValue = undefined
646
+ this.queryReady = false
647
+ this.cookiesValue = undefined
648
+ }
649
+
650
+ [CONTEXT_SET](): CtxSet | undefined {
651
+ return this.setValue
652
+ }
653
+
654
+ get set(): CtxSet {
655
+ this.setValue ??= new LazyResponseControls()
656
+ return this.setValue
657
+ }
658
+
659
+ get req(): Request {
660
+ return requestOf(this.source)
661
+ }
662
+
663
+ get query(): unknown {
664
+ if (!this.queryReady) {
665
+ this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH])
666
+ this.queryReady = true
667
+ }
668
+ return this.queryValue
669
+ }
670
+
671
+ set query(v: unknown) {
672
+ this.queryValue = v
673
+ this.queryReady = true
674
+ }
675
+
676
+ get cookies(): Readonly<Record<string, string>> {
677
+ this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"))
678
+ return this.cookiesValue
679
+ }
680
+
681
+ boundedBody(maxBytes?: number): Promise<Uint8Array> {
682
+ return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
683
+ }
684
+
685
+ boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
686
+ return readBoundedJsonBodyOrThrow<T>(this.source, this.maxBodyBytes, maxBytes)
687
+ }
688
+ }
689
+
690
+ /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
691
+ * `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
692
+ * The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
693
+ async function readBoundedBodyOrThrow(
694
+ req: RequestSource,
695
+ maxBodyBytes: number,
696
+ maxBytes?: number,
697
+ ): Promise<Uint8Array> {
698
+ const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
699
+ if (r.ok) return r.bytes
700
+ throw r.status === 413
701
+ ? jsonError(413, "payload_too_large")
702
+ : jsonError(400, "invalid_content_length")
703
+ }
704
+
705
+ /** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
706
+ async function readBoundedJsonBodyOrThrow<T>(
707
+ req: RequestSource,
708
+ maxBodyBytes: number,
709
+ maxBytes?: number,
710
+ ): Promise<T> {
711
+ const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
712
+ if (parsed instanceof Response) throw parsed
713
+ return parsed as T
714
+ }
715
+
716
+ /**
717
+ * Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
718
+ * Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
719
+ * chunked or length-less requests fall back to the streaming byte-cap guard.
720
+ */
721
+ async function readBoundedJsonSource(
722
+ req: RequestSource,
723
+ maxBytes: number,
724
+ ): Promise<unknown | Response> {
725
+ const declared = headerOf(req, "content-length")
726
+ if (declared !== null) {
727
+ // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
728
+ // non-numeric / negative / fractional / exponential value (`Number()` would happily accept
729
+ // "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
730
+ // through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
731
+ // would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
732
+ // hardens hand-built Requests (tests, the in-process client) and crafted input.
733
+ const length = parseContentLength(declared)
734
+ if (length === undefined) return jsonError(400, "invalid_content_length")
735
+ if (length > maxBytes) return jsonError(413, "payload_too_large")
736
+ const chunked = headerOf(req, "transfer-encoding") !== null
737
+ if (!chunked) {
738
+ try {
739
+ return await req.json()
740
+ } catch {
741
+ return jsonError(400, "invalid_json")
742
+ }
743
+ }
744
+ }
745
+ const body = req.body
746
+ if (body === null) return jsonError(400, "invalid_json")
747
+ const drained = await drainCapped(body, maxBytes)
748
+ if (!drained.ok) return jsonError(413, "payload_too_large")
749
+ try {
750
+ return JSON.parse(TEXT_DECODER.decode(drained.bytes))
751
+ } catch {
752
+ return jsonError(400, "invalid_json")
753
+ }
754
+ }
755
+
756
+ /** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
757
+ * argless `new Date()`. */
758
+ const EPOCH = new Date(0)
759
+
760
+ /** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
761
+ * gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
762
+ * `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
763
+ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined {
764
+ const cookies = set._cookies
765
+ if (cookies === undefined || cookies.length === 0) return set._headers
766
+ const headers = new Headers(set._headers)
767
+ for (const cookie of cookies) headers.append("set-cookie", cookie)
768
+ return headers
769
+ }
770
+
771
+ // Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
772
+ // scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
773
+ // across responses: the Response constructor copies `init.headers` into its own list.
774
+ const JSON_CT_HEADERS = new Headers({
775
+ "content-type": "application/json;charset=utf-8",
776
+ })
777
+ const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
778
+
779
+ /** Fused-lane respond when the handler ran with NO context (so `c.set` can't exist): the fast
780
+ * JSON respond directly, with `toResponse` + the empty controls as the exact-semantics fallback. */
781
+ function fusedRespondNoSet(result: unknown): Response {
782
+ if (
783
+ result !== undefined &&
784
+ !(result instanceof Response) &&
785
+ typeof result === "object" &&
786
+ result !== null &&
787
+ !isResponseResult(result)
788
+ ) {
789
+ const body = JSON.stringify(result) as string | undefined
790
+ if (body !== undefined) return new Response(body, JSON_INIT_200)
791
+ }
792
+ return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
793
+ }
794
+
795
+ /** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
796
+ * JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
797
+ function fusedRespond(result: unknown, ctx: RawContext): Response {
798
+ const set = ctx[CONTEXT_SET]()
799
+ if (set === undefined) return fusedRespondNoSet(result)
800
+ return toResponse(result as HandlerResult, set)
801
+ }
802
+
803
+ function toResponse(result: HandlerResult, set: CtxSet): Response {
804
+ if (isResponseResult(result)) {
805
+ return appendCookiesToResponse(result.toResponse(), set)
806
+ }
807
+ if (result instanceof Response) {
808
+ return appendCookiesToResponse(result, set)
809
+ }
810
+ const headers = headersInit(set)
811
+ const status = set.status ?? (result === undefined ? 204 : 200)
812
+ if (headers === undefined && result !== undefined) {
813
+ // Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
814
+ // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
815
+ // same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
816
+ // result) delegates to Response.json so its TypeError contract stays the single source.
817
+ const body = JSON.stringify(result) as string | undefined
818
+ if (body !== undefined) {
819
+ return new Response(
820
+ body,
821
+ status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
822
+ )
823
+ }
824
+ }
825
+ const init: ResponseInit = headers === undefined ? { status } : { status, headers }
826
+ return result === undefined ? new Response(null, init) : Response.json(result, init)
827
+ }
828
+
829
+ function appendCookiesToResponse(response: Response, set: CtxSet): Response {
830
+ // A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
831
+ // (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
832
+ // returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
833
+ // the cookie. (Other `c.set` fields stay the returned Response's own concern.)
834
+ const cookies = set._cookies
835
+ if (cookies !== undefined && cookies.length > 0) {
836
+ for (const cookie of cookies) response.headers.append("set-cookie", cookie)
837
+ }
838
+ return response
839
+ }
840
+
841
+ /**
842
+ * What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
843
+ * to the socket directly (`kind: "json"` — status + headers + cookies + a pre-stringified body, **no**
844
+ * undici `Response` built or drained), a marked buffered response body (`kind: "body"` — e.g.
845
+ * @nifrajs/web's non-deferred SSR HTML), or a `Response` (`kind: "response"`) for everything else
846
+ * (redirects, 404/405/errors, unmarked or streaming bodies). Internal to the nifra↔node bridge.
847
+ */
848
+ export type NodeServeOutcome =
849
+ | { readonly kind: "response"; readonly response: Response }
850
+ | {
851
+ readonly kind: "json"
852
+ readonly status: number
853
+ /** `c.set.headers` backing, or `undefined` when the handler never set a header. */
854
+ readonly headers: Readonly<Record<string, string>> | undefined
855
+ /** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
856
+ readonly cookies: readonly string[] | undefined
857
+ /** The JSON body already stringified, or `null` for an empty (204) response. */
858
+ readonly body: string | null
859
+ }
860
+ | {
861
+ readonly kind: "body"
862
+ readonly status: number
863
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
864
+ readonly body: string | Uint8Array
865
+ }
866
+
867
+ /**
868
+ * `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
869
+ * a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
870
+ * via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
871
+ * cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
872
+ * works on Node).
873
+ */
874
+ function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
875
+ if (isResponseResult(result)) {
876
+ const body = result.toNodeBody?.()
877
+ if (body !== undefined) {
878
+ return {
879
+ kind: "body",
880
+ status: body.status,
881
+ headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
882
+ body: body.body,
883
+ }
884
+ }
885
+ return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set))
886
+ }
887
+ if (result instanceof Response) {
888
+ return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
889
+ }
890
+ const status = set.status ?? (result === undefined ? 204 : 200)
891
+ return {
892
+ kind: "json",
893
+ status,
894
+ headers: set._headers,
895
+ cookies: set._cookies,
896
+ body: result === undefined ? null : JSON.stringify(result),
897
+ }
898
+ }
899
+
900
+ // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
901
+ const IDENTITY_RESPONSE = (response: Response): Response => response
902
+ const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
903
+ const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
904
+ const NODE_FROM_RESPONSE = (response: Response): NodeServeOutcome =>
905
+ nodeOutcomeFromResponse(response)
906
+ const NODE_TIMEOUT = (): NodeServeOutcome => ({
907
+ kind: "response",
908
+ response: jsonError(503, "request_timeout"),
909
+ })
910
+
911
+ function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
912
+ const body = nodeResponseBody(response)
913
+ return body === undefined
914
+ ? { kind: "response", response }
915
+ : { kind: "body", status: response.status, headers: responseHeadersForNode(response), body }
916
+ }
917
+
918
+ function nodeResponseBody(response: Response): string | Uint8Array | undefined {
919
+ if (response.bodyUsed) return undefined
920
+ const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
921
+ return typeof body === "string" || body instanceof Uint8Array ? body : undefined
922
+ }
923
+
924
+ function responseHeadersForNode(
925
+ response: Response,
926
+ ): Readonly<Record<string, string | readonly string[]>> | undefined {
927
+ let headers: Record<string, string | readonly string[]> | undefined
928
+ response.headers.forEach((value, key) => {
929
+ headers ??= {}
930
+ headers[key] = value
931
+ })
932
+ const setCookies = response.headers.getSetCookie?.()
933
+ if (setCookies !== undefined && setCookies.length > 0) {
934
+ headers ??= {}
935
+ headers["set-cookie"] = setCookies
936
+ }
937
+ return headers
938
+ }
939
+
940
+ function appendCookiesToNodeHeaders(
941
+ headers: Readonly<Record<string, string | readonly string[]>> | undefined,
942
+ cookies: readonly string[] | undefined,
943
+ ): Readonly<Record<string, string | readonly string[]>> | undefined {
944
+ if (cookies === undefined || cookies.length === 0) return headers
945
+ const out: Record<string, string | readonly string[]> =
946
+ headers === undefined ? {} : { ...headers }
947
+ const existing = out["set-cookie"]
948
+ const setCookies =
949
+ existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
950
+ out["set-cookie"] = [...setCookies, ...cookies]
951
+ return out
952
+ }
953
+
954
+ /**
955
+ * The inline server. Routes are chainable and fully type-inferred. `derive`/
956
+ * `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
957
+ * with full types; `Ctx` is server-only and never touches the client registry.
958
+ *
959
+ * app.decorate("db", db).derive((c) => ({ user: auth(c) }))
960
+ * .get("/me", (c) => c.user) // c.user + c.db are typed
961
+ */
962
+ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
963
+ private readonly router: Router<RouteEntry>
964
+ /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
965
+ private readonly wsRouter: Router<WsEntry>
966
+ private wsRouteCount: number
967
+ /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
968
+ * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
969
+ * no-WebSocket app never constructs (or bundles) it. */
970
+ private topics: TopicRegistry | undefined
971
+ private readonly routeList: RouteDescriptor[]
972
+ private readonly maxBodyBytes: number
973
+ private readonly requestTimeoutMs: number
974
+ private readonly gracefulSignals: boolean
975
+ private readonly logger: Logger
976
+ private bunServer: RunningServer | undefined
977
+ private readonly derives: RawDerive[]
978
+ private readonly decorations: Record<string, unknown>
979
+ private readonly beforeHandleHooks: RawBeforeHandle[]
980
+ private readonly afterHandleHooks: RawAfterHandle[]
981
+ private readonly onErrorHooks: RawErrorHandler[]
982
+ private readonly aroundHooks: RawAround[]
983
+ private readonly onRequestHooks: RawOnRequest[]
984
+ private readonly onResponseHooks: RawOnResponse[]
985
+ private readonly responseRequests: WeakMap<Request, Request>
986
+ /** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
987
+ private readonly appliedPlugins: Set<string>
988
+
989
+ constructor(options: ServerOptions = {}) {
990
+ this.router = new Router<RouteEntry>()
991
+ this.wsRouter = new Router<WsEntry>()
992
+ this.wsRouteCount = 0
993
+ this.topics = undefined
994
+ this.routeList = []
995
+ this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
996
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 0
997
+ this.gracefulSignals = options.gracefulSignals ?? false
998
+ this.logger = options.logger ?? jsonLogger()
999
+ this.bunServer = undefined
1000
+ this.derives = []
1001
+ this.decorations = {}
1002
+ this.beforeHandleHooks = []
1003
+ this.afterHandleHooks = []
1004
+ this.onErrorHooks = []
1005
+ this.aroundHooks = []
1006
+ this.onRequestHooks = []
1007
+ this.onResponseHooks = []
1008
+ this.responseRequests = new WeakMap()
1009
+ this.appliedPlugins = new Set()
1010
+ }
1011
+
1012
+ /** Add a per-request, computed context extension for subsequent routes. */
1013
+ derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
1014
+ this.derives.push(fn as unknown as RawDerive)
1015
+ return this as unknown as Server<R, Ctx & D>
1016
+ }
1017
+
1018
+ /** Add a static context value for subsequent routes. */
1019
+ decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
1020
+ this.decorations[key] = value
1021
+ return this as unknown as Server<R, Ctx & Record<K, V>>
1022
+ }
1023
+
1024
+ /**
1025
+ * Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
1026
+ * `Request` to continue routing with a rewritten method/URL/headers. Global.
1027
+ */
1028
+ onRequest(fn: (req: Request) => MaybePromise<OnRequestResult>): this {
1029
+ this.onRequestHooks.push(fn)
1030
+ return this
1031
+ }
1032
+
1033
+ /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
1034
+ beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
1035
+ this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
1036
+ return this
1037
+ }
1038
+
1039
+ /**
1040
+ * Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
1041
+ * route output, so wrappers like async context storage do not force Node's direct JSON path through
1042
+ * a Web `Response`. The first registered wrapper is outermost.
1043
+ */
1044
+ around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
1045
+ this.aroundHooks.push(fn as unknown as RawAround)
1046
+ return this
1047
+ }
1048
+
1049
+ /** Transform the handler's result before it is serialized. Order-scoped. */
1050
+ afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1051
+ this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1052
+ return this
1053
+ }
1054
+
1055
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
1056
+ onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1057
+ this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1058
+ return this
1059
+ }
1060
+
1061
+ /** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
1062
+ onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
1063
+ this.onResponseHooks.push(fn)
1064
+ return this
1065
+ }
1066
+
1067
+ /**
1068
+ * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) — it
1069
+ * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
1070
+ * and context fully intact. This overload exists specifically so a *named* identity plugin (e.g.
1071
+ * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
1072
+ * defeat the generic inference of the transforming overload below and collapse the result to `any`.
1073
+ */
1074
+ use(plugin: IdentityPlugin): this
1075
+ /**
1076
+ * Apply a **plugin function** — `(app) => app`, typically built with {@link definePlugin}. It's
1077
+ * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
1078
+ * the added context to handlers defined after `use` (the overload is generic over the concrete
1079
+ * `this`). A named plugin already applied is skipped (idempotent dedupe).
1080
+ */
1081
+ use<Out extends AnyServer>(plugin: (app: this) => Out): Out
1082
+ /**
1083
+ * Apply a {@link Middleware} bundle — wire each hook it provides to its lifecycle point. Returns
1084
+ * `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
1085
+ * should cover (those are order-scoped; `onRequest`/`onResponse` are global). A named bundle already
1086
+ * applied is skipped (idempotent).
1087
+ */
1088
+ use(mw: Middleware): this
1089
+ use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
1090
+ if (typeof arg === "function") {
1091
+ const name = (arg as { pluginName?: string }).pluginName
1092
+ if (name !== undefined) {
1093
+ if (this.appliedPlugins.has(name)) return this // idempotent: already applied
1094
+ this.appliedPlugins.add(name)
1095
+ }
1096
+ return arg(this)
1097
+ }
1098
+ if (arg.name !== undefined) {
1099
+ if (this.appliedPlugins.has(arg.name)) return this
1100
+ this.appliedPlugins.add(arg.name)
1101
+ }
1102
+ if (arg.onRequest !== undefined) this.onRequest(arg.onRequest)
1103
+ if (arg.around !== undefined) this.around(arg.around)
1104
+ if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
1105
+ if (arg.afterHandle !== undefined) this.afterHandle(arg.afterHandle)
1106
+ if (arg.onResponse !== undefined) this.onResponse(arg.onResponse)
1107
+ if (arg.onError !== undefined) this.onError(arg.onError)
1108
+ return this
1109
+ }
1110
+
1111
+ get<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
1112
+ path: Path,
1113
+ schema: S,
1114
+ handler: H,
1115
+ ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1116
+ get<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1117
+ path: Path,
1118
+ handler: H,
1119
+ ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1120
+ get(
1121
+ path: string,
1122
+ schemaOrHandler: RouteSchema | ErasedHandler,
1123
+ handler?: ErasedHandler,
1124
+ ): Server<Registry, Ctx> {
1125
+ return this.route("GET", path, schemaOrHandler, handler)
1126
+ }
1127
+
1128
+ post<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
1129
+ path: Path,
1130
+ schema: S,
1131
+ handler: H,
1132
+ ): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1133
+ post<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1134
+ path: Path,
1135
+ handler: H,
1136
+ ): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1137
+ post(
1138
+ path: string,
1139
+ schemaOrHandler: RouteSchema | ErasedHandler,
1140
+ handler?: ErasedHandler,
1141
+ ): Server<Registry, Ctx> {
1142
+ return this.route("POST", path, schemaOrHandler, handler)
1143
+ }
1144
+
1145
+ put<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
1146
+ path: Path,
1147
+ schema: S,
1148
+ handler: H,
1149
+ ): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1150
+ put<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1151
+ path: Path,
1152
+ handler: H,
1153
+ ): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1154
+ put(
1155
+ path: string,
1156
+ schemaOrHandler: RouteSchema | ErasedHandler,
1157
+ handler?: ErasedHandler,
1158
+ ): Server<Registry, Ctx> {
1159
+ return this.route("PUT", path, schemaOrHandler, handler)
1160
+ }
1161
+
1162
+ patch<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
1163
+ path: Path,
1164
+ schema: S,
1165
+ handler: H,
1166
+ ): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1167
+ patch<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1168
+ path: Path,
1169
+ handler: H,
1170
+ ): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1171
+ patch(
1172
+ path: string,
1173
+ schemaOrHandler: RouteSchema | ErasedHandler,
1174
+ handler?: ErasedHandler,
1175
+ ): Server<Registry, Ctx> {
1176
+ return this.route("PATCH", path, schemaOrHandler, handler)
1177
+ }
1178
+
1179
+ delete<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
1180
+ path: Path,
1181
+ schema: S,
1182
+ handler: H,
1183
+ ): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1184
+ delete<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1185
+ path: Path,
1186
+ handler: H,
1187
+ ): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1188
+ delete(
1189
+ path: string,
1190
+ schemaOrHandler: RouteSchema | ErasedHandler,
1191
+ handler?: ErasedHandler,
1192
+ ): Server<Registry, Ctx> {
1193
+ return this.route("DELETE", path, schemaOrHandler, handler)
1194
+ }
1195
+
1196
+ /**
1197
+ * Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
1198
+ * `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
1199
+ * reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
1200
+ * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
1201
+ * has no socket (a WS path through `app.fetch` is a normal HTTP response).
1202
+ *
1203
+ * app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
1204
+ */
1205
+ ws<Data = unknown, Schema extends StandardSchemaV1 | undefined = undefined>(
1206
+ path: string,
1207
+ handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema>,
1208
+ ): this {
1209
+ // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1210
+ // bundle it. Registration is the loud, early failure point — never the first connection.
1211
+ const runtime = requireWsRuntime()
1212
+ this.topics ??= runtime.createTopics()
1213
+ // A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
1214
+ // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
1215
+ this.wsRouter.add("GET", path, {
1216
+ handler: runtime.wrapHandler(handler as WebSocketHandler),
1217
+ })
1218
+ this.wsRouteCount += 1
1219
+ return this
1220
+ }
1221
+
1222
+ /**
1223
+ * Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
1224
+ * In-process and **single-instance** (see {@link TopicRegistry}) — a multi-instance deploy must bridge
1225
+ * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
1226
+ */
1227
+ publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
1228
+ // No `app.ws()` yet ⇒ no registry and necessarily no subscribers — a publish is a no-op anyway.
1229
+ this.topics?.publish(topic, data)
1230
+ }
1231
+
1232
+ private route(
1233
+ method: Method,
1234
+ path: string,
1235
+ schemaOrHandler: RouteSchema | ErasedHandler,
1236
+ handler?: ErasedHandler,
1237
+ ): Server<Registry, Ctx> {
1238
+ let rawHandler: ErasedHandler
1239
+ let schema: RouteSchema | undefined
1240
+ if (handler !== undefined) {
1241
+ schema = schemaOrHandler as RouteSchema
1242
+ rawHandler = handler
1243
+ } else {
1244
+ schema = undefined
1245
+ rawHandler = schemaOrHandler as ErasedHandler
1246
+ }
1247
+ this.register(method, path, schema, rawHandler)
1248
+ // The accumulated registry type is compile-time only; the same instance
1249
+ // carries every route, so the public methods re-type `this` per call.
1250
+ return this as unknown as Server<Registry, Ctx>
1251
+ }
1252
+
1253
+ /**
1254
+ * Low-level route registration shared by the inline builder and `implement()`.
1255
+ * Captures the server's current `derive`/`decorate` chain into the route — this
1256
+ * is the "compiled", order-scoped per-route chain.
1257
+ */
1258
+ register(
1259
+ method: Method,
1260
+ path: string,
1261
+ schema: RouteSchema | undefined,
1262
+ handler: (context: never) => unknown,
1263
+ ): void {
1264
+ const bare =
1265
+ schema?.body === undefined &&
1266
+ schema?.query === undefined &&
1267
+ this.derives.length === 0 &&
1268
+ this.beforeHandleHooks.length === 0 &&
1269
+ this.afterHandleHooks.length === 0 &&
1270
+ this.onErrorHooks.length === 0
1271
+ const fusedWeb =
1272
+ bare && this.aroundHooks.length === 0
1273
+ ? this.buildFusedWeb(
1274
+ handler as unknown as InternalHandler,
1275
+ Object.keys(this.decorations).length > 0 ? { ...this.decorations } : undefined,
1276
+ isContextlessNoArgArrow(handler),
1277
+ )
1278
+ : undefined
1279
+ this.router.add(method, path, {
1280
+ // (context: never) => unknown -> InternalHandler: the framework invokes it
1281
+ // with the concrete RawContext the typed handler expects, so this is sound.
1282
+ handler: handler as unknown as InternalHandler,
1283
+ schema,
1284
+ fusedWeb,
1285
+ derives: [...this.derives],
1286
+ decorations: { ...this.decorations },
1287
+ hasDecorations: Object.keys(this.decorations).length > 0,
1288
+ beforeHandle: [...this.beforeHandleHooks],
1289
+ afterHandle: [...this.afterHandleHooks],
1290
+ onError: [...this.onErrorHooks],
1291
+ around: [...this.aroundHooks],
1292
+ // Sync-fast-path eligibility (see RouteEntry.bare). Validation + every hook kind must be absent;
1293
+ // decorations and generic around wrappers are fine: around wraps the inner bare route in
1294
+ // `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
1295
+ // Most GET/no-schema routes qualify.
1296
+ bare,
1297
+ contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
1298
+ bodyOnly:
1299
+ schema?.body !== undefined &&
1300
+ schema.query === undefined &&
1301
+ this.derives.length === 0 &&
1302
+ this.beforeHandleHooks.length === 0 &&
1303
+ this.afterHandleHooks.length === 0 &&
1304
+ this.onErrorHooks.length === 0,
1305
+ queryOnly:
1306
+ schema?.body === undefined &&
1307
+ schema?.query !== undefined &&
1308
+ this.derives.length === 0 &&
1309
+ this.beforeHandleHooks.length === 0 &&
1310
+ this.afterHandleHooks.length === 0 &&
1311
+ this.onErrorHooks.length === 0,
1312
+ })
1313
+ this.routeList.push({ method, path, schema })
1314
+ }
1315
+
1316
+ /**
1317
+ * Enumerate the registered routes (method, path, input schemas), in registration
1318
+ * order. Powers `toOpenAPI` and other introspection; the router trie itself no
1319
+ * longer holds the original patterns.
1320
+ */
1321
+ routes(): ReadonlyArray<RouteDescriptor> {
1322
+ return this.routeList
1323
+ }
1324
+
1325
+ /**
1326
+ * Resolve a `Request` to a `Response` — the whole lifecycle, testable without a port. The
1327
+ * optional `platform` carries edge inputs (`env`, `waitUntil`); edge adapters pass it, and
1328
+ * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
1329
+ */
1330
+ fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
1331
+ // A real `Request` satisfies `RequestSource`, so it's passed straight through — no per-request
1332
+ // wrapper allocation on the Web/Bun hot path.
1333
+ return this.fetchSource(req, platform)
1334
+ }
1335
+
1336
+ private fetchSource(
1337
+ source: RequestSource,
1338
+ platform?: Platform<EnvOf<Ctx>>,
1339
+ ): MaybePromise<Response> {
1340
+ // Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
1341
+ // path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
1342
+ // promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
1343
+ // `await app.fetch(...)` continues to work exactly as before.
1344
+ const outcome = this.dispatch<Response>(
1345
+ source,
1346
+ platform,
1347
+ toResponse,
1348
+ IDENTITY_RESPONSE,
1349
+ RESPONSE_TIMEOUT,
1350
+ true,
1351
+ )
1352
+ if (this.onResponseHooks.length === 0) {
1353
+ return outcome
1354
+ }
1355
+ // onResponse sees every response — success, validation error, 404/405, timeout, onRequest
1356
+ // short-circuit; normalize to a promise, then thread through the hooks.
1357
+ return outcome instanceof Promise
1358
+ ? outcome.then((response) => this.applyOnResponse(response, this.takeResponseRequest(source)))
1359
+ : this.applyOnResponse(outcome, this.takeResponseRequest(source))
1360
+ }
1361
+
1362
+ /**
1363
+ * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1364
+ * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
1365
+ * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
1366
+ * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
1367
+ * full Web path (`{ kind: "response" }`), so behavior is identical — only the common JSON-data case is
1368
+ * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
1369
+ * render differs.
1370
+ */
1371
+ resolveNode(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<NodeServeOutcome> {
1372
+ return this.resolveNodeSource(req, platform)
1373
+ }
1374
+
1375
+ resolveNodeSource(
1376
+ source: RequestSource,
1377
+ platform?: Platform<EnvOf<Ctx>>,
1378
+ ): MaybePromise<NodeServeOutcome> {
1379
+ // onResponse hooks transform a Response, so they force the Web path; wrap its result.
1380
+ if (this.onResponseHooks.length > 0) {
1381
+ const response = this.fetchSource(source, platform)
1382
+ return response instanceof Promise
1383
+ ? response.then((settled) => ({ kind: "response", response: settled }))
1384
+ : { kind: "response", response }
1385
+ }
1386
+ // May resolve **synchronously** for a bare route + sync handler (RouteEntry.bare) — the `@nifrajs/node`
1387
+ // adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
1388
+ // at all on the Node hot path.
1389
+ return this.dispatch<NodeServeOutcome>(
1390
+ source,
1391
+ platform,
1392
+ toNodeOutcome,
1393
+ NODE_FROM_RESPONSE,
1394
+ NODE_TIMEOUT,
1395
+ false,
1396
+ )
1397
+ }
1398
+
1399
+ /**
1400
+ * Resolve a WebSocket upgrade — the seam every serving adapter uses. Returns `pass` (not a WS
1401
+ * upgrade for a registered route → handle as normal HTTP), `reject` (a WS route matched but
1402
+ * `upgrade()` rejected, or the path was malformed → return `response`), or `upgrade` (perform the
1403
+ * runtime upgrade, then dispatch the native socket's events to `handler`, seeding `ws.data` with
1404
+ * `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
1405
+ * `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
1406
+ */
1407
+ resolveWebSocketUpgrade(
1408
+ req: Request,
1409
+ platform?: Platform<EnvOf<Ctx>>,
1410
+ ): MaybePromise<WebSocketUpgradeOutcome> {
1411
+ if (this.wsRouteCount === 0) return WS_PASS
1412
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
1413
+ const url = urlPartsOf(req.url)
1414
+ const match = this.wsRouter.find("GET", url.pathname)
1415
+ if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
1416
+ const params = match.params === EMPTY_PARAMS ? EMPTY_PARAMS : decodeParams(match.params)
1417
+ if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
1418
+ const handler = match.payload.handler
1419
+ // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
1420
+ const pubsub = this.topics as TopicRegistry
1421
+ // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
1422
+ // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
1423
+ // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
1424
+ if (handler.allowedOrigins !== undefined) {
1425
+ const origin = req.headers.get("origin")
1426
+ const allowed =
1427
+ typeof handler.allowedOrigins === "function"
1428
+ ? handler.allowedOrigins(origin)
1429
+ : origin !== null && handler.allowedOrigins.includes(origin)
1430
+ if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
1431
+ }
1432
+ if (handler.upgrade === undefined) {
1433
+ return { kind: "upgrade", handler, data: undefined, pubsub }
1434
+ }
1435
+ const ctx = new RequestContext(
1436
+ req,
1437
+ params,
1438
+ url.search,
1439
+ getNeverAbortSignal(),
1440
+ platform,
1441
+ this.maxBodyBytes,
1442
+ )
1443
+ const settle = (value: unknown): WebSocketUpgradeOutcome =>
1444
+ value instanceof Response
1445
+ ? { kind: "reject", response: value }
1446
+ : { kind: "upgrade", handler, data: value, pubsub }
1447
+ try {
1448
+ const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
1449
+ return result instanceof Promise
1450
+ ? result.then(settle, () => ({
1451
+ kind: "reject" as const,
1452
+ response: jsonError(500, "internal_error"),
1453
+ }))
1454
+ : settle(result)
1455
+ } catch {
1456
+ return { kind: "reject", response: jsonError(500, "internal_error") }
1457
+ }
1458
+ }
1459
+
1460
+ /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
1461
+ * `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
1462
+ * (The socket dispatch itself lives in `ws-bun.ts`, loaded via `@nifrajs/core/ws`.) */
1463
+ private bunFetchWithWebSocket(
1464
+ req: Request,
1465
+ server: BunUpgradeServer,
1466
+ ): MaybePromise<Response | undefined> {
1467
+ const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
1468
+ if (o.kind === "pass") return this.fetch(req)
1469
+ if (o.kind === "reject") return o.response
1470
+ return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
1471
+ ? undefined
1472
+ : jsonError(426, "upgrade_required")
1473
+ }
1474
+ const outcome = this.resolveWebSocketUpgrade(req)
1475
+ return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
1476
+ }
1477
+
1478
+ /**
1479
+ * The shared lifecycle, generic over how the final value is rendered: `finalize` turns a handler's
1480
+ * result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
1481
+ * primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
1482
+ * produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
1483
+ * context + lifecycle implementation — no duplication across the trust boundary.
1484
+ */
1485
+ private dispatch<T>(
1486
+ source: RequestSource,
1487
+ platform: Platform | undefined,
1488
+ finalize: (result: unknown, set: CtxSet) => T,
1489
+ wrapResponse: (response: Response) => T,
1490
+ onTimeout: () => T,
1491
+ // True only from the Web `fetch` path — unlocks each route's fused lane, whose output type IS
1492
+ // `Response` (`T = Response` there by construction; the node path always passes false).
1493
+ webFast: boolean,
1494
+ ): MaybePromise<T> {
1495
+ // onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
1496
+ // case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
1497
+ if (this.onRequestHooks.length === 0) {
1498
+ return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast)
1499
+ }
1500
+ return this.runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast)
1501
+ }
1502
+
1503
+ /**
1504
+ * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
1505
+ * common case — e.g. CORS returning `undefined` for a non-preflight request): an `async` version
1506
+ * here put EVERY request of any app with one onRequest hook onto the promise machinery, profiled
1507
+ * at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
1508
+ * hooks to the async continuation; behavior is identical.
1509
+ */
1510
+ private runWithOnRequest<T>(
1511
+ source: RequestSource,
1512
+ platform: Platform | undefined,
1513
+ finalize: (result: unknown, set: CtxSet) => T,
1514
+ wrapResponse: (response: Response) => T,
1515
+ onTimeout: () => T,
1516
+ webFast: boolean,
1517
+ ): MaybePromise<T> {
1518
+ const hooks = this.onRequestHooks
1519
+ const originalRequest = requestOf(source)
1520
+ let current: RequestSource = source
1521
+ for (let i = 0; i < hooks.length; i++) {
1522
+ const outcome = (hooks[i] as RawOnRequest)(requestOf(current))
1523
+ if (outcome instanceof Promise) {
1524
+ return outcome.then((early) =>
1525
+ this.continueOnRequest(
1526
+ early,
1527
+ i + 1,
1528
+ originalRequest,
1529
+ current,
1530
+ platform,
1531
+ finalize,
1532
+ wrapResponse,
1533
+ onTimeout,
1534
+ webFast,
1535
+ ),
1536
+ )
1537
+ }
1538
+ if (outcome instanceof Request) {
1539
+ current = outcome
1540
+ if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
1541
+ continue
1542
+ }
1543
+ if (outcome !== undefined) return wrapResponse(outcome)
1544
+ }
1545
+ return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
1546
+ }
1547
+
1548
+ /** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
1549
+ * the remaining hooks (awaiting freely — we're already async here). */
1550
+ private async continueOnRequest<T>(
1551
+ first: OnRequestResult,
1552
+ nextIndex: number,
1553
+ originalRequest: Request,
1554
+ sourceAtAwait: RequestSource,
1555
+ platform: Platform | undefined,
1556
+ finalize: (result: unknown, set: CtxSet) => T,
1557
+ wrapResponse: (response: Response) => T,
1558
+ onTimeout: () => T,
1559
+ webFast: boolean,
1560
+ ): Promise<T> {
1561
+ let current = sourceAtAwait
1562
+ let early = first
1563
+ let index = nextIndex
1564
+ for (;;) {
1565
+ if (early instanceof Request) {
1566
+ current = early
1567
+ if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
1568
+ } else if (early !== undefined) {
1569
+ return wrapResponse(early)
1570
+ }
1571
+ if (index >= this.onRequestHooks.length) break
1572
+ const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current))
1573
+ early = outcome instanceof Promise ? await outcome : outcome
1574
+ index++
1575
+ }
1576
+ return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
1577
+ }
1578
+
1579
+ private takeResponseRequest(source: RequestSource): Request {
1580
+ const request = requestOf(source)
1581
+ const rewritten = this.responseRequests.get(request)
1582
+ if (rewritten === undefined) return request
1583
+ this.responseRequests.delete(request)
1584
+ return rewritten
1585
+ }
1586
+
1587
+ /**
1588
+ * Route → build context → run. Synchronous through to the handler for a **bare** route
1589
+ * ({@link RouteEntry.bare}), so a sync handler produces its result with zero promise allocations;
1590
+ * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
1591
+ */
1592
+ private routeAndRun<T>(
1593
+ source: RequestSource,
1594
+ platform: Platform | undefined,
1595
+ finalize: (result: unknown, set: CtxSet) => T,
1596
+ wrapResponse: (response: Response) => T,
1597
+ onTimeout: () => T,
1598
+ webFast: boolean,
1599
+ ): MaybePromise<T> {
1600
+ const url = urlPartsOf(source.url)
1601
+ const match = this.router.find(source.method, url.pathname)
1602
+ if (!match.found) {
1603
+ if (match.reason === "method-not-allowed") {
1604
+ return wrapResponse(
1605
+ jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }),
1606
+ )
1607
+ }
1608
+ return wrapResponse(jsonError(404, "not_found"))
1609
+ }
1610
+
1611
+ const params = match.params === EMPTY_PARAMS ? EMPTY_PARAMS : decodeParams(match.params)
1612
+ if (params === null) {
1613
+ return wrapResponse(jsonError(400, "malformed_path"))
1614
+ }
1615
+
1616
+ const entry = match.payload
1617
+ // A per-request cancellation signal. Only allocate a real controller when a
1618
+ // timeout is armed; otherwise share a never-aborting signal so the (common)
1619
+ // no-timeout path pays nothing.
1620
+ let controller: AbortController | undefined
1621
+ let signal = getNeverAbortSignal()
1622
+ if (this.requestTimeoutMs > 0) {
1623
+ controller = new AbortController()
1624
+ signal = controller.signal
1625
+ }
1626
+ // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
1627
+ // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
1628
+ let outcome: MaybePromise<T>
1629
+ if (webFast && entry.fusedWeb !== undefined) {
1630
+ // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
1631
+ // passed only by the `fetch` path, where T = Response by construction.
1632
+ outcome = entry.fusedWeb(source, params, url.search, signal, platform) as MaybePromise<T>
1633
+ } else if (entry.contextlessBare) {
1634
+ outcome = this.runContextlessBare(
1635
+ entry,
1636
+ source,
1637
+ params,
1638
+ url.search,
1639
+ signal,
1640
+ platform,
1641
+ finalize,
1642
+ wrapResponse,
1643
+ )
1644
+ } else {
1645
+ // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
1646
+ // stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
1647
+ const ctx = new RequestContext(
1648
+ source,
1649
+ params,
1650
+ url.search,
1651
+ signal,
1652
+ platform,
1653
+ this.maxBodyBytes,
1654
+ )
1655
+
1656
+ if (entry.around.length === 0) {
1657
+ outcome = entry.bare
1658
+ ? this.runBare(entry, ctx, finalize, wrapResponse)
1659
+ : entry.bodyOnly
1660
+ ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
1661
+ : entry.queryOnly
1662
+ ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
1663
+ : this.runLifecycle(entry, source, ctx, finalize, wrapResponse)
1664
+ } else {
1665
+ outcome = this.runWithAround(
1666
+ entry,
1667
+ ctx,
1668
+ () =>
1669
+ entry.bare
1670
+ ? this.runBare(entry, ctx, finalize, wrapResponse)
1671
+ : entry.bodyOnly
1672
+ ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
1673
+ : entry.queryOnly
1674
+ ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
1675
+ : this.runLifecycle(entry, source, ctx, finalize, wrapResponse),
1676
+ finalize,
1677
+ wrapResponse,
1678
+ )
1679
+ }
1680
+ }
1681
+ // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1682
+ // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1683
+ if (controller !== undefined && outcome instanceof Promise) {
1684
+ return this.withTimeout(outcome, controller, onTimeout)
1685
+ }
1686
+ return outcome
1687
+ }
1688
+
1689
+ /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1690
+ * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
1691
+ private runContextlessBare<T>(
1692
+ entry: RouteEntry,
1693
+ source: RequestSource,
1694
+ params: Record<string, string>,
1695
+ search: string,
1696
+ signal: AbortSignal,
1697
+ platform: Platform | undefined,
1698
+ finalize: (result: unknown, set: CtxSet) => T,
1699
+ wrapResponse: (response: Response) => T,
1700
+ ): MaybePromise<T> {
1701
+ let result: unknown
1702
+ try {
1703
+ result = (entry.handler as unknown as ContextlessHandler)()
1704
+ } catch (err) {
1705
+ return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse)
1706
+ }
1707
+ if (result instanceof Promise) {
1708
+ return result.then(
1709
+ (value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
1710
+ (err) =>
1711
+ this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse),
1712
+ )
1713
+ }
1714
+ return finalize(result, EMPTY_RESPONSE_CONTROLS)
1715
+ }
1716
+
1717
+ private contextlessBareError<T>(
1718
+ err: unknown,
1719
+ source: RequestSource,
1720
+ params: Record<string, string>,
1721
+ search: string,
1722
+ signal: AbortSignal,
1723
+ platform: Platform | undefined,
1724
+ wrapResponse: (response: Response) => T,
1725
+ ): T {
1726
+ if (err instanceof Response) return wrapResponse(err)
1727
+ const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes)
1728
+ this.logRequestError(err, ctx)
1729
+ return wrapResponse(jsonError(500, "internal_error"))
1730
+ }
1731
+
1732
+ /**
1733
+ * The synchronous fast path for a {@link RouteEntry.bare} route: apply static decorations, call the
1734
+ * handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
1735
+ * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
1736
+ * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
1737
+ * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
1738
+ * the per-request async-frame tax — the same win codegen routers get, but without `eval`.
1739
+ */
1740
+ private runBare<T>(
1741
+ entry: RouteEntry,
1742
+ ctx: RawContext,
1743
+ finalize: (result: unknown, set: CtxSet) => T,
1744
+ wrapResponse: (response: Response) => T,
1745
+ ): MaybePromise<T> {
1746
+ let result: unknown
1747
+ try {
1748
+ if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
1749
+ result = entry.handler(ctx)
1750
+ } catch (err) {
1751
+ return this.bareError(err, ctx, wrapResponse)
1752
+ }
1753
+ if (result instanceof Promise) {
1754
+ // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
1755
+ return result.then(
1756
+ (value) => finalize(value, responseSet(ctx)),
1757
+ (err) => this.bareError(err, ctx, wrapResponse),
1758
+ )
1759
+ }
1760
+ return finalize(result, responseSet(ctx))
1761
+ }
1762
+
1763
+ /** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
1764
+ * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
1765
+ /**
1766
+ * Build a route's fused Web lane (see {@link RouteEntry.fusedWeb}). Composition happens once at
1767
+ * registration; the returned closure is what every request to the route runs. Behavior is
1768
+ * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
1769
+ * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
1770
+ * 500s), same respond semantics (the lifecycle parity suite pins it).
1771
+ */
1772
+ private buildFusedWeb(
1773
+ handler: InternalHandler,
1774
+ decorations: Record<string, unknown> | undefined,
1775
+ contextless: boolean,
1776
+ ): FusedWebRunner {
1777
+ const maxBodyBytes = this.maxBodyBytes
1778
+ const logError = (err: unknown, ctx: RawContext): Response => {
1779
+ if (err instanceof Response) return err
1780
+ this.logRequestError(err, ctx)
1781
+ return jsonError(500, "internal_error")
1782
+ }
1783
+ if (contextless && decorations === undefined) {
1784
+ // `() => ...` can't observe the context — skip allocating one entirely (errors still build
1785
+ // one for the structured log, exactly like runContextlessBare).
1786
+ const contextlessHandler = handler as unknown as ContextlessHandler
1787
+ return (source, params, search, signal, platform) => {
1788
+ let result: unknown
1789
+ try {
1790
+ result = contextlessHandler()
1791
+ } catch (err) {
1792
+ return logError(
1793
+ err,
1794
+ new RequestContext(source, params, search, signal, platform, maxBodyBytes),
1795
+ )
1796
+ }
1797
+ if (result instanceof Promise) {
1798
+ return result.then(fusedRespondNoSet, (err) =>
1799
+ logError(
1800
+ err,
1801
+ new RequestContext(source, params, search, signal, platform, maxBodyBytes),
1802
+ ),
1803
+ )
1804
+ }
1805
+ return fusedRespondNoSet(result)
1806
+ }
1807
+ }
1808
+ return (source, params, search, signal, platform) => {
1809
+ const ctx = new RequestContext(source, params, search, signal, platform, maxBodyBytes)
1810
+ if (decorations !== undefined) Object.assign(ctx, decorations)
1811
+ let result: unknown
1812
+ try {
1813
+ result = handler(ctx)
1814
+ } catch (err) {
1815
+ return logError(err, ctx)
1816
+ }
1817
+ if (result instanceof Promise) {
1818
+ return result.then(
1819
+ (value) => fusedRespond(value, ctx),
1820
+ (err) => logError(err, ctx),
1821
+ )
1822
+ }
1823
+ return fusedRespond(result, ctx)
1824
+ }
1825
+ }
1826
+
1827
+ private bareError<T>(err: unknown, ctx: RawContext, wrapResponse: (response: Response) => T): T {
1828
+ if (err instanceof Response) return wrapResponse(err)
1829
+ this.logRequestError(err, ctx)
1830
+ return wrapResponse(jsonError(500, "internal_error"))
1831
+ }
1832
+
1833
+ private runWithAround<T>(
1834
+ entry: RouteEntry,
1835
+ ctx: RawContext,
1836
+ run: () => MaybePromise<T>,
1837
+ finalize: (result: unknown, set: CtxSet) => T,
1838
+ wrapResponse: (response: Response) => T,
1839
+ ): MaybePromise<T> {
1840
+ let outcome: MaybePromise<T>
1841
+ try {
1842
+ outcome = this.runAround(entry.around, ctx, run)
1843
+ } catch (err) {
1844
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
1845
+ }
1846
+ return outcome instanceof Promise
1847
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
1848
+ : outcome
1849
+ }
1850
+
1851
+ private runAround<T>(
1852
+ hooks: ReadonlyArray<RawAround>,
1853
+ ctx: RawContext,
1854
+ run: () => MaybePromise<T>,
1855
+ ): MaybePromise<T> {
1856
+ const dispatch = (index: number): MaybePromise<T> => {
1857
+ if (index >= hooks.length) return run()
1858
+ const hook = hooks[index]!
1859
+ let called = false
1860
+ return hook(ctx, () => {
1861
+ if (called) throw new Error("around next() called multiple times")
1862
+ called = true
1863
+ return dispatch(index + 1)
1864
+ })
1865
+ }
1866
+ return dispatch(0)
1867
+ }
1868
+
1869
+ private runBodyOnly<T>(
1870
+ entry: RouteEntry,
1871
+ source: RequestSource,
1872
+ ctx: RawContext,
1873
+ finalize: (result: unknown, set: CtxSet) => T,
1874
+ wrapResponse: (response: Response) => T,
1875
+ ): Promise<T> {
1876
+ const contentType = headerOf(source, "content-type") ?? ""
1877
+ if (contentType !== "application/json" && !contentType.includes("application/json")) {
1878
+ if (isUrlEncodedForm(contentType)) {
1879
+ // HTML form posts validate through the same schema as JSON (parity with Elysia/Hono).
1880
+ // Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
1881
+ // bounded form reader shares the streaming guard. JSON requests are untouched.
1882
+ return readBoundedForm(source, this.maxBodyBytes).then((form) => {
1883
+ if (form instanceof Response) return wrapResponse(form)
1884
+ return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse) as Promise<T> | T
1885
+ }) as Promise<T>
1886
+ }
1887
+ return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
1888
+ }
1889
+
1890
+ // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
1891
+ // `req.json()` directly — one promise, one `.then` closure per request. The generic
1892
+ // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
1893
+ // closures) is kept for the chunked / length-less / oversized cases it exists for.
1894
+ // Semantics are identical to readBoundedJsonSource — same checks, same error codes.
1895
+ const declared = headerOf(source, "content-length")
1896
+ if (declared !== null) {
1897
+ const length = parseContentLength(declared)
1898
+ if (length === undefined) {
1899
+ return Promise.resolve(wrapResponse(jsonError(400, "invalid_content_length")))
1900
+ }
1901
+ if (length > this.maxBodyBytes) {
1902
+ return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")))
1903
+ }
1904
+ if (headerOf(source, "transfer-encoding") === null) {
1905
+ return source.json().then(
1906
+ (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
1907
+ // Native json() rejection = malformed JSON; anything past parse goes through
1908
+ // finishBodyOnly's own error handling.
1909
+ () => wrapResponse(jsonError(400, "invalid_json")),
1910
+ )
1911
+ }
1912
+ }
1913
+
1914
+ try {
1915
+ return this.readBoundedJson(source).then(
1916
+ (parsed) => {
1917
+ if (parsed instanceof Response) return wrapResponse(parsed)
1918
+ return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse)
1919
+ },
1920
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
1921
+ )
1922
+ } catch (err) {
1923
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
1924
+ }
1925
+ }
1926
+
1927
+ /** Validate + run the handler for the bodyOnly path — shared by the inline fast path and the
1928
+ * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
1929
+ * beyond the one `.then` continuation. */
1930
+ private finishBodyOnly<T>(
1931
+ entry: RouteEntry,
1932
+ parsed: unknown,
1933
+ ctx: RawContext,
1934
+ finalize: (result: unknown, set: CtxSet) => T,
1935
+ wrapResponse: (response: Response) => T,
1936
+ ): MaybePromise<T> {
1937
+ try {
1938
+ const bodySchema = entry.schema!.body!
1939
+ const validation = bodySchema["~standard"].validate(parsed)
1940
+ if (validation instanceof Promise) {
1941
+ return validation.then(
1942
+ (result) => {
1943
+ try {
1944
+ const outcome = this.applyBodyValidation(entry, result, ctx, finalize, wrapResponse)
1945
+ return outcome instanceof Promise
1946
+ ? outcome.catch((err) =>
1947
+ this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
1948
+ )
1949
+ : outcome
1950
+ } catch (err) {
1951
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
1952
+ }
1953
+ },
1954
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
1955
+ )
1956
+ }
1957
+ const outcome = this.applyBodyValidation(entry, validation, ctx, finalize, wrapResponse)
1958
+ return outcome instanceof Promise
1959
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
1960
+ : outcome
1961
+ } catch (err) {
1962
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
1963
+ }
1964
+ }
1965
+
1966
+ private applyBodyValidation<T>(
1967
+ entry: RouteEntry,
1968
+ result: StandardResult<unknown>,
1969
+ ctx: RawContext,
1970
+ finalize: (result: unknown, set: CtxSet) => T,
1971
+ wrapResponse: (response: Response) => T,
1972
+ ): MaybePromise<T> {
1973
+ if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
1974
+ ctx.body = result.value
1975
+ if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
1976
+ const handlerOutput = entry.handler(ctx)
1977
+ return handlerOutput instanceof Promise
1978
+ ? handlerOutput.then((value) => finalize(value, responseSet(ctx)))
1979
+ : finalize(handlerOutput, responseSet(ctx))
1980
+ }
1981
+
1982
+ private runQueryOnly<T>(
1983
+ entry: RouteEntry,
1984
+ ctx: RawContext,
1985
+ finalize: (result: unknown, set: CtxSet) => T,
1986
+ wrapResponse: (response: Response) => T,
1987
+ ): MaybePromise<T> {
1988
+ try {
1989
+ // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) — skip
1990
+ // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
1991
+ const validation = entry.schema!.query!["~standard"].validate(
1992
+ queryObjectOf(ctx[CONTEXT_SEARCH]),
1993
+ )
1994
+ if (validation instanceof Promise) {
1995
+ return validation.then(
1996
+ (settled) => {
1997
+ try {
1998
+ const outcome = this.applyQueryValidation(entry, settled, ctx, finalize, wrapResponse)
1999
+ return outcome instanceof Promise
2000
+ ? outcome.catch((err) =>
2001
+ this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
2002
+ )
2003
+ : outcome
2004
+ } catch (err) {
2005
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
2006
+ }
2007
+ },
2008
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
2009
+ )
2010
+ }
2011
+ return this.applyQueryValidation(entry, validation, ctx, finalize, wrapResponse)
2012
+ } catch (err) {
2013
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
2014
+ }
2015
+ }
2016
+
2017
+ /** Validate-result → set `ctx.query` → run handler. A method (not a per-request closure), the
2018
+ * query analogue of {@link applyBodyValidation}. */
2019
+ private applyQueryValidation<T>(
2020
+ entry: RouteEntry,
2021
+ result: StandardResult<unknown>,
2022
+ ctx: RawContext,
2023
+ finalize: (result: unknown, set: CtxSet) => T,
2024
+ wrapResponse: (response: Response) => T,
2025
+ ): MaybePromise<T> {
2026
+ if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
2027
+ ctx.query = result.value
2028
+ if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
2029
+ const handlerOutput = entry.handler(ctx)
2030
+ return handlerOutput instanceof Promise
2031
+ ? handlerOutput.then(
2032
+ (value) => finalize(value, responseSet(ctx)),
2033
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
2034
+ )
2035
+ : finalize(handlerOutput, responseSet(ctx))
2036
+ }
2037
+
2038
+ /**
2039
+ * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
2040
+ * actually returns a Promise — an `async` version forced a promise + microtask on EVERY response
2041
+ * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
2042
+ * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
2043
+ * rest to {@link continueOnResponse}.
2044
+ */
2045
+ private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
2046
+ const hooks = this.onResponseHooks
2047
+ let current = response
2048
+ for (let i = 0; i < hooks.length; i++) {
2049
+ const next = (hooks[i] as RawOnResponse)(current, req)
2050
+ if (next instanceof Promise) {
2051
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
2052
+ }
2053
+ current = next
2054
+ }
2055
+ return current
2056
+ }
2057
+
2058
+ /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
2059
+ private async continueOnResponse(
2060
+ response: Response,
2061
+ nextIndex: number,
2062
+ req: Request,
2063
+ ): Promise<Response> {
2064
+ let current = response
2065
+ for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
2066
+ const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
2067
+ current = next instanceof Promise ? await next : next
2068
+ }
2069
+ return current
2070
+ }
2071
+
2072
+ /**
2073
+ * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
2074
+ * handlers can bail) and return 503; the in-flight work keeps running but its
2075
+ * result is discarded — JS can't forcibly cancel a promise.
2076
+ */
2077
+ private async withTimeout<T>(
2078
+ work: Promise<T>,
2079
+ controller: AbortController,
2080
+ onTimeout: () => T,
2081
+ ): Promise<T> {
2082
+ let timer: ReturnType<typeof setTimeout> | undefined
2083
+ const timeout = new Promise<T>((resolve) => {
2084
+ timer = setTimeout(() => {
2085
+ controller.abort()
2086
+ resolve(onTimeout())
2087
+ }, this.requestTimeoutMs)
2088
+ })
2089
+ try {
2090
+ return await Promise.race([work, timeout])
2091
+ } finally {
2092
+ if (timer !== undefined) clearTimeout(timer)
2093
+ }
2094
+ }
2095
+
2096
+ /**
2097
+ * The post-match lifecycle: validate → derive → beforeHandle → handler → afterHandle, with onError.
2098
+ * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
2099
+ * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
2100
+ */
2101
+ private async runLifecycle<T>(
2102
+ entry: RouteEntry,
2103
+ source: RequestSource,
2104
+ ctx: RawContext,
2105
+ finalize: (result: unknown, set: CtxSet) => T,
2106
+ wrapResponse: (response: Response) => T,
2107
+ ): Promise<T> {
2108
+ try {
2109
+ if (entry.schema?.body !== undefined) {
2110
+ const bodyError = await this.readAndValidateBody(source, entry.schema.body, ctx)
2111
+ if (bodyError !== undefined) return wrapResponse(bodyError)
2112
+ }
2113
+ if (entry.schema?.query !== undefined) {
2114
+ // Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
2115
+ // single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
2116
+ // validateStandard wrapper alloc.
2117
+ const validation = entry.schema.query["~standard"].validate(
2118
+ queryObjectOf(ctx[CONTEXT_SEARCH]),
2119
+ )
2120
+ const result = validation instanceof Promise ? await validation : validation
2121
+ if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
2122
+ ctx.query = result.value
2123
+ }
2124
+
2125
+ // Context extensions: static decorations, then per-request derives.
2126
+ // Each hook below only awaits when it actually returns a Promise — a sync hook
2127
+ // skips the microtask tick (a fast-path; ~64 ns/hook).
2128
+ if (entry.hasDecorations) Object.assign(ctx, entry.decorations) // skip the no-op on bare routes
2129
+ // The `.length` guards skip iterator setup for the common no-hook route (most
2130
+ // routes have neither derives nor before/after hooks); a hooked route pays only
2131
+ // the length check.
2132
+ if (entry.derives.length > 0) {
2133
+ for (const derive of entry.derives) {
2134
+ const extension = derive(ctx)
2135
+ Object.assign(ctx, extension instanceof Promise ? await extension : extension)
2136
+ }
2137
+ }
2138
+
2139
+ // beforeHandle: a non-undefined return short-circuits, skipping the handler.
2140
+ if (entry.beforeHandle.length > 0) {
2141
+ for (const hook of entry.beforeHandle) {
2142
+ const outcome = hook(ctx)
2143
+ const early = outcome instanceof Promise ? await outcome : outcome
2144
+ if (early !== undefined) return finalize(early, responseSet(ctx))
2145
+ }
2146
+ }
2147
+
2148
+ const handlerOutput = entry.handler(ctx)
2149
+ let result = handlerOutput instanceof Promise ? await handlerOutput : handlerOutput
2150
+
2151
+ // afterHandle: transform the result before serialization.
2152
+ if (entry.afterHandle.length > 0) {
2153
+ for (const hook of entry.afterHandle) {
2154
+ const transformed = hook(result, ctx)
2155
+ result = transformed instanceof Promise ? await transformed : transformed
2156
+ }
2157
+ }
2158
+
2159
+ return finalize(result, responseSet(ctx))
2160
+ } catch (err) {
2161
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
2162
+ }
2163
+ }
2164
+
2165
+ private async handleLifecycleError<T>(
2166
+ entry: RouteEntry,
2167
+ err: unknown,
2168
+ ctx: RawContext,
2169
+ finalize: (result: unknown, set: CtxSet) => T,
2170
+ wrapResponse: (response: Response) => T,
2171
+ ): Promise<T> {
2172
+ // A *thrown* Response is deliberate control flow, not an error — a guard throws a redirect/401,
2173
+ // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
2174
+ // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
2175
+ // any handler or loader.
2176
+ if (err instanceof Response) return wrapResponse(err)
2177
+ // onError hooks may return a custom response; otherwise the default 500 stands.
2178
+ for (const hook of entry.onError) {
2179
+ const outcome = hook(err, ctx)
2180
+ const handled = outcome instanceof Promise ? await outcome : outcome
2181
+ if (handled !== undefined) return finalize(handled, responseSet(ctx))
2182
+ }
2183
+ // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
2184
+ // (redacting) logger. Body-read failures and around-hook failures land here too.
2185
+ this.logRequestError(err, ctx)
2186
+ return wrapResponse(jsonError(500, "internal_error"))
2187
+ }
2188
+
2189
+ /** Log an unhandled request error to the (redacting) logger — shared by {@link runLifecycle} and the
2190
+ * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
2191
+ private logRequestError(err: unknown, ctx: RawContext): void {
2192
+ this.logger.error("unhandled request error", {
2193
+ method: ctx.req.method,
2194
+ path: pathnameOf(ctx.req.url),
2195
+ name: err instanceof Error ? err.name : "Error",
2196
+ message: err instanceof Error ? err.message : String(err),
2197
+ stack: err instanceof Error ? err.stack : undefined,
2198
+ })
2199
+ }
2200
+
2201
+ private async readAndValidateBody(
2202
+ req: RequestSource,
2203
+ schema: StandardSchemaV1,
2204
+ ctx: RawContext,
2205
+ ): Promise<Response | undefined> {
2206
+ const contentType = headerOf(req, "content-type") ?? ""
2207
+ let parsed: unknown
2208
+ if (contentType === "application/json" || contentType.includes("application/json")) {
2209
+ const json = await this.readBoundedJson(req)
2210
+ if (json instanceof Response) return json
2211
+ parsed = json
2212
+ } else if (isUrlEncodedForm(contentType)) {
2213
+ const form = await readBoundedForm(req, this.maxBodyBytes)
2214
+ if (form instanceof Response) return form
2215
+ parsed = form
2216
+ } else {
2217
+ // multipart/form-data (file uploads) stays 415 on the schema path by design — files don't
2218
+ // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
2219
+ return jsonError(415, "unsupported_media_type")
2220
+ }
2221
+ const validation = schema["~standard"].validate(parsed)
2222
+ const result = validation instanceof Promise ? await validation : validation
2223
+ if (result.issues !== undefined) return validationError(result.issues)
2224
+ ctx.body = result.value
2225
+ return undefined
2226
+ }
2227
+
2228
+ /**
2229
+ * Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
2230
+ * `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
2231
+ * running byte count exceeds it — so a lying or absent length can't force us to
2232
+ * buffer an oversized payload.
2233
+ *
2234
+ * Fast path: when a non-chunked request carries a `Content-Length` within the cap,
2235
+ * a native `req.json()` is already bounded — under HTTP/1.1 + HTTP/2 framing the
2236
+ * runtime delivers at most `Content-Length` bytes — so we skip the manual stream
2237
+ * loop and a separate text decode. It trusts the wire
2238
+ * *framing*, not the header value: nifra only ever receives framed Requests from the
2239
+ * runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
2240
+ * length-less bodies fall through to the streaming byte-cap guard below.
2241
+ */
2242
+ private async readBoundedJson(req: RequestSource): Promise<unknown | Response> {
2243
+ return readBoundedJsonSource(req, this.maxBodyBytes)
2244
+ }
2245
+
2246
+ /**
2247
+ * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
2248
+ *
2249
+ * `reusePort` sets `SO_REUSEPORT` so **multiple processes can bind the same port** and the kernel
2250
+ * load-balances connections across them — the standard way to use every core (Bun is
2251
+ * single-threaded per process). Spawn one process per core, each calling
2252
+ * `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
2253
+ * and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
2254
+ * favor one socket (fine for dev, measure on Linux for production numbers).
2255
+ */
2256
+ listen(port: number, options?: { readonly reusePort?: boolean }): RunningServer {
2257
+ if (typeof Bun === "undefined") {
2258
+ // listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
2259
+ // than letting the Bun.serve call below throw a bare `ReferenceError: Bun is not
2260
+ // defined`. Exercised by the @nifrajs/deno suite (which runs on a non-Bun runtime).
2261
+ throw new FrameworkError(
2262
+ "BUN_REQUIRED",
2263
+ "listen() uses Bun.serve and runs only on Bun. Serve on Node with @nifrajs/node or on Deno with @nifrajs/deno, or hand app.fetch to any fetch-compatible runtime (Workers, etc.).",
2264
+ )
2265
+ }
2266
+ // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
2267
+ // subset so the public types don't depend on the ambient `Bun` global. The cast
2268
+ // bridges them — Bun's `.port` is `number | undefined` (undefined only for unix
2269
+ // sockets, never a TCP `listen`) and its `.stop` returns a promise we don't await.
2270
+ // Pass only the request — Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
2271
+ // With WS routes, hand Bun a `websocket` config + a fetch that upgrades matching requests (the
2272
+ // `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
2273
+ // `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
2274
+ // matched route's handler, set by `server.upgrade`.
2275
+ // With WS routes, the dispatcher comes from the `@nifrajs/core/ws` runtime — non-null because
2276
+ // wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
2277
+ const wsHandlers =
2278
+ this.wsRouteCount === 0
2279
+ ? undefined
2280
+ : (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
2281
+ const reusePort = options?.reusePort === true
2282
+ const running = (wsHandlers === undefined
2283
+ ? Bun.serve({ port, reusePort, fetch: (req: Request) => this.fetch(req) })
2284
+ : Bun.serve<BunWsData>({
2285
+ port,
2286
+ reusePort,
2287
+ fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
2288
+ // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
2289
+ // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
2290
+ // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
2291
+ websocket: {
2292
+ open: (ws) => wsHandlers.open(ws),
2293
+ message: (ws, message) => wsHandlers.message(ws, message),
2294
+ close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
2295
+ },
2296
+ })) as unknown as RunningServer
2297
+ this.bunServer = running
2298
+ if (this.gracefulSignals) this.installSignalHandlers()
2299
+ return running
2300
+ }
2301
+
2302
+ /**
2303
+ * Gracefully stop: wait for in-flight requests to finish (up to `drainMs`), then
2304
+ * issue a single terminal stop — graceful if everything drained, forced if
2305
+ * stragglers remain. Safe to call when not listening.
2306
+ *
2307
+ * The Bun semantics: poll `pendingRequests` (awaiting
2308
+ * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
2309
+ * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
2310
+ * New connections may be accepted during the drain window; in a real deploy the
2311
+ * load balancer has already stopped routing here, and `drainMs` bounds it.
2312
+ */
2313
+ async stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
2314
+ const server = this.bunServer
2315
+ if (server === undefined) return
2316
+ this.bunServer = undefined
2317
+ const deadline = Date.now() + drainMs
2318
+ while (server.pendingRequests > 0 && Date.now() < deadline) {
2319
+ await Bun.sleep(DRAIN_POLL_MS)
2320
+ }
2321
+ server.stop(server.pendingRequests > 0) // force-close iff stragglers remain past the deadline
2322
+ }
2323
+
2324
+ private installSignalHandlers(): void {
2325
+ // Drain, then let the process exit naturally — the stopped server no longer
2326
+ // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
2327
+ // signals is consented; we don't force `process.exit`.
2328
+ const onSignal = (): void => {
2329
+ void this.stop()
2330
+ }
2331
+ process.once("SIGTERM", onSignal)
2332
+ process.once("SIGINT", onSignal)
2333
+ }
2334
+ }
2335
+
2336
+ /**
2337
+ * Create a new {@link Server}. Pass an `Env` to type the platform bindings — `server<Env>()` makes
2338
+ * `c.env: Env` in every handler + middleware, and types the `env` argument of `app.fetch` /
2339
+ * `toFetchHandler`. Omit it and `c.env` is `unknown` (validate/cast before use).
2340
+ */
2341
+ export function server<Env = unknown>(
2342
+ options?: ServerOptions,
2343
+ ): Server<EmptyRegistry, { readonly env: Env }> {
2344
+ // `Env` is a phantom type-level marker: the runtime `env` arrives via `app.fetch(req, { env })` at
2345
+ // request time, not stored on the builder — so seed the context type with a cast (as `derive`/
2346
+ // `decorate` do for their `Ctx` extensions).
2347
+ return new Server(options) as unknown as Server<EmptyRegistry, { readonly env: Env }>
2348
+ }
2349
+
2350
+ /** A Cloudflare Workers-style execution context (the `fetch` 3rd arg). Structural — only
2351
+ * `waitUntil` is used; declared here so `@nifrajs/core` needs no Workers type dependency. */
2352
+ export interface ExecutionContext {
2353
+ waitUntil(promise: Promise<unknown>): void
2354
+ }
2355
+
2356
+ /** A Cloudflare Workers-style scheduled (cron) controller. Structural — no Workers type dependency. */
2357
+ export interface ScheduledController {
2358
+ /** Epoch ms the run was scheduled for. */
2359
+ readonly scheduledTime: number
2360
+ /** The matching cron expression from `wrangler.toml` `[triggers]`. */
2361
+ readonly cron: string
2362
+ /** Tell the platform not to retry this run on failure. */
2363
+ noRetry(): void
2364
+ }
2365
+
2366
+ /** A nifra cron handler: the platform controller + the same typed `env`/`waitUntil` nifra threads into
2367
+ * request handlers. Schedule background work with `waitUntil` so it outlives the trigger. */
2368
+ export type ScheduledHandler<Env = unknown> = (
2369
+ controller: ScheduledController,
2370
+ context: { readonly env: Env; waitUntil(promise: Promise<unknown>): void },
2371
+ ) => MaybePromise<void>
2372
+
2373
+ /**
2374
+ * Adapt a nifra app to an edge "ExportedHandler" — use it as a Cloudflare Workers (or any
2375
+ * `fetch(request, env, ctx)` runtime) default export. It threads `env` + `ctx.waitUntil` into the
2376
+ * nifra Context, so handlers read `c.env` and schedule background work via `c.waitUntil`:
2377
+ *
2378
+ * export default toFetchHandler(app)
2379
+ *
2380
+ * Pass `{ scheduled }` to also export a Workers cron handler (for a `[triggers]` schedule) — it
2381
+ * receives the platform controller plus the same typed `env`/`waitUntil`:
2382
+ *
2383
+ * export default toFetchHandler(app, {
2384
+ * scheduled: (controller, { env, waitUntil }) =>
2385
+ * waitUntil(env.KV.put("last-run", String(controller.scheduledTime))),
2386
+ * })
2387
+ *
2388
+ * No Workers-only deps — `app.fetch` stays a portable Web-standard handler; this is the thin
2389
+ * shim from the platform's 3-arg `fetch`/`scheduled` to it.
2390
+ */
2391
+ /** Cloudflare's `WebSocketPair` — feature-detected (absent off Workers). Yields `{ 0: client, 1: server }`. */
2392
+ type WebSocketPairCtor = new () => {
2393
+ readonly 0: unknown
2394
+ readonly 1: StandardWebSocket & { accept(): void }
2395
+ }
2396
+
2397
+ /** Structural view of a Cloudflare Durable Object namespace binding — keeps `@cloudflare/workers-types`
2398
+ * out of `@nifrajs/core`. The real `DurableObjectNamespace` satisfies it. */
2399
+ export interface DurableObjectNamespaceLike {
2400
+ idFromName(name: string): unknown
2401
+ get(id: unknown): { fetch(request: Request): Promise<Response> }
2402
+ }
2403
+
2404
+ /** The single hub Durable Object id nifra routes WS upgrades to (one hub per app — see `@nifrajs/workers`). */
2405
+ const NIFRA_WS_HUB_ID = "nifra-ws-hub"
2406
+
2407
+ export function toFetchHandler<Env = unknown>(
2408
+ app: {
2409
+ fetch(request: Request, platform?: Platform<Env>): MaybePromise<Response>
2410
+ resolveWebSocketUpgrade?(
2411
+ request: Request,
2412
+ platform?: Platform<Env>,
2413
+ ): MaybePromise<WebSocketUpgradeOutcome>
2414
+ },
2415
+ options?: {
2416
+ scheduled?: ScheduledHandler<Env>
2417
+ /**
2418
+ * Route WebSocket upgrades to a Durable Object that holds the connections and runs the app's
2419
+ * pub/sub — enabling cross-connection broadcast (`app.publish`) on Cloudflare Workers, where a
2420
+ * stateless isolate can't. Pass the DO namespace binding from `env`; pair with `@nifrajs/workers`'
2421
+ * `createWebSocketHub(app)` (the DO class) + a `wrangler.toml` binding. Without this, WS upgrades use
2422
+ * a per-connection `WebSocketPair` (no broadcast).
2423
+ */
2424
+ webSocketHub?: (env: Env) => DurableObjectNamespaceLike
2425
+ },
2426
+ ): {
2427
+ fetch(request: Request, env: Env, ctx: ExecutionContext): MaybePromise<Response>
2428
+ scheduled?(controller: ScheduledController, env: Env, ctx: ExecutionContext): MaybePromise<void>
2429
+ } {
2430
+ const scheduled = options?.scheduled
2431
+ const webSocketHub = options?.webSocketHub
2432
+ const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
2433
+ return {
2434
+ fetch: (request, env, ctx) => {
2435
+ const platform: Platform<Env> = { env, waitUntil: (promise) => ctx.waitUntil(promise) }
2436
+ // WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
2437
+ // connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
2438
+ // resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
2439
+ if (
2440
+ webSocketHub !== undefined &&
2441
+ request.headers.get("upgrade")?.toLowerCase() === "websocket"
2442
+ ) {
2443
+ const ns = webSocketHub(env)
2444
+ return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request)
2445
+ }
2446
+ // Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
2447
+ // Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` — e.g. Deno Deploy
2448
+ // uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
2449
+ const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor }).WebSocketPair
2450
+ if (resolveWs !== undefined && Pair !== undefined) {
2451
+ const accept = (outcome: WebSocketUpgradeOutcome): MaybePromise<Response> => {
2452
+ if (outcome.kind === "pass") return app.fetch(request, platform)
2453
+ if (outcome.kind === "reject") return outcome.response
2454
+ const pair = new Pair()
2455
+ const server = pair[1]
2456
+ server.accept()
2457
+ // Non-null: an `upgrade` outcome only exists for an app with ws() routes, and ws()
2458
+ // requires the `@nifrajs/core/ws` runtime at registration.
2459
+ ;(getWsRuntime() as WsRuntime).attach(server, outcome.handler, outcome.data, {
2460
+ openNow: true,
2461
+ pubsub: outcome.pubsub,
2462
+ })
2463
+ // `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
2464
+ // 101 status is only valid on the Workers runtime — both gated by the `Pair` feature check.
2465
+ return new Response(null, { status: 101, webSocket: pair[0] } as unknown as ResponseInit)
2466
+ }
2467
+ const out = resolveWs(request, platform)
2468
+ return out instanceof Promise ? out.then(accept) : accept(out)
2469
+ }
2470
+ return app.fetch(request, platform)
2471
+ },
2472
+ ...(scheduled !== undefined
2473
+ ? {
2474
+ scheduled: (controller: ScheduledController, env: Env, ctx: ExecutionContext) =>
2475
+ scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
2476
+ }
2477
+ : {}),
2478
+ }
2479
+ }