@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,1660 @@
1
+ import { FrameworkError } from "../errors.js";
2
+ import { EMPTY_PARAMS, Router } from "../router/router.js";
3
+ import { drainCapped, parseContentLength, readBoundedBytes } from "./body.js";
4
+ import { parseCookies, serializeCookie } from "./cookies.js";
5
+ import { jsonLogger } from "./logger.js";
6
+ import { getWsRuntime } from "./ws-hook.js";
7
+ /** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
8
+ * lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
9
+ * on the Web hot path. */
10
+ function requestOf(source) {
11
+ return source.request ?? source;
12
+ }
13
+ function headerOf(source, name) {
14
+ return source.header?.(name) ?? source.headers.get(name);
15
+ }
16
+ const RESPONSE_RESULT = Symbol.for("nifra.response.result");
17
+ const CONTEXT_SET = Symbol("nifra.context.set");
18
+ const CONTEXT_SEARCH = Symbol("nifra.context.search");
19
+ const functionToString = Function.prototype.toString;
20
+ const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
21
+ function isResponseResult(value) {
22
+ return (typeof value === "object" &&
23
+ value !== null &&
24
+ value[RESPONSE_RESULT] === true &&
25
+ typeof value.toResponse === "function");
26
+ }
27
+ /** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
28
+ * rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
29
+ const fallbackWaitUntil = (promise) => {
30
+ void promise.catch(() => { });
31
+ };
32
+ const WS_PASS = { kind: "pass" };
33
+ /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
34
+ function requireWsRuntime() {
35
+ const runtime = getWsRuntime();
36
+ if (runtime === undefined) {
37
+ throw new FrameworkError("WS_RUNTIME_MISSING", '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.');
38
+ }
39
+ return runtime;
40
+ }
41
+ /**
42
+ * Name + ergonomics for a plugin. `app.use(myPlugin)` applies it once; a second `use` of the same
43
+ * name is skipped (idempotent), so plugins can depend on each other without double-registering hooks.
44
+ *
45
+ * ```ts
46
+ * export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
47
+ * app.use(requestId) // downstream handlers see c.requestId
48
+ * ```
49
+ */
50
+ export function definePlugin(name, apply) {
51
+ return Object.assign(apply, { pluginName: name });
52
+ }
53
+ /**
54
+ * Define a type-**identity** plugin: it registers routes/hooks as a side effect but returns the app with
55
+ * its `Registry` + `Context` UNCHANGED. Use this (not {@link definePlugin}) for any plugin that doesn't
56
+ * add context types — e.g. one mounting an auth handler. It threads the caller's *concrete* server type
57
+ * through `use`, so routes declared after `app.use(plugin)` keep their types.
58
+ *
59
+ * Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
60
+ * `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
61
+ * (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
62
+ *
63
+ * ```ts
64
+ * export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
65
+ * const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
66
+ * ```
67
+ */
68
+ export function defineIdentityPlugin(name, apply) {
69
+ return Object.assign(apply, { pluginName: name });
70
+ }
71
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000;
72
+ const DEFAULT_DRAIN_MS = 10_000;
73
+ const DRAIN_POLL_MS = 10;
74
+ const TEXT_DECODER = new TextDecoder();
75
+ /**
76
+ * Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
77
+ * cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
78
+ * constructing one in global scope; the first request builds it inside the handler, then it's
79
+ * reused at zero per-request cost.
80
+ */
81
+ let neverAbortSignal;
82
+ const getNeverAbortSignal = () => {
83
+ neverAbortSignal ??= new AbortController().signal;
84
+ return neverAbortSignal;
85
+ };
86
+ function jsonError(status, error, headers) {
87
+ return Response.json({ ok: false, error }, headers !== undefined ? { status, headers } : { status });
88
+ }
89
+ function validationError(issues) {
90
+ const serialized = issues.map((issue) => {
91
+ const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg));
92
+ return path !== undefined ? { message: issue.message, path } : { message: issue.message };
93
+ });
94
+ return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 400 });
95
+ }
96
+ // Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
97
+ // `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
98
+ // the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
99
+ // string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
100
+ export function urlPartsOf(url) {
101
+ const schemeEnd = url.indexOf("://");
102
+ const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3);
103
+ if (start === -1)
104
+ return { pathname: "/", search: "" };
105
+ let pathEnd = url.length;
106
+ let searchStart = -1;
107
+ let searchEnd = url.length;
108
+ for (let i = start; i < url.length; i++) {
109
+ const c = url.charCodeAt(i);
110
+ if (c === 63 /* ? */ && searchStart === -1) {
111
+ pathEnd = i;
112
+ searchStart = i;
113
+ }
114
+ else if (c === 35 /* # */) {
115
+ if (searchStart === -1)
116
+ pathEnd = i;
117
+ searchEnd = i;
118
+ break;
119
+ }
120
+ }
121
+ return {
122
+ pathname: url.slice(start, pathEnd),
123
+ search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
124
+ };
125
+ }
126
+ // Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
127
+ // for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
128
+ export function pathnameOf(url) {
129
+ return urlPartsOf(url).pathname;
130
+ }
131
+ // The query string ("?a=1", or "" when absent) — for lazily building `c.query` only when read.
132
+ // The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
133
+ // is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
134
+ // for provable equivalence with `new URL(req.url).search`.
135
+ export function searchOf(url) {
136
+ return urlPartsOf(url).search;
137
+ }
138
+ /** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
139
+ * `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key — the
140
+ * promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
141
+ * case (direct assignment on a null-proto object creates an own property). */
142
+ function setQueryValue(out, key, value) {
143
+ const existing = out[key];
144
+ if (existing === undefined) {
145
+ out[key] = value;
146
+ }
147
+ else if (typeof existing === "string") {
148
+ out[key] = [existing, value];
149
+ }
150
+ else {
151
+ existing.push(value);
152
+ }
153
+ }
154
+ function queryObjectFallback(search) {
155
+ // Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
156
+ // (fromEntries is last-wins), and __proto__ needs the same own-property guard.
157
+ const out = Object.create(null);
158
+ for (const [key, value] of new URLSearchParams(search)) {
159
+ setQueryValue(out, key, value);
160
+ }
161
+ return out;
162
+ }
163
+ /**
164
+ * Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
165
+ * `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
166
+ * percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
167
+ * to `string[]` on BOTH paths (see {@link setQueryValue}).
168
+ */
169
+ // Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
170
+ // populated path), allocated once instead of per request.
171
+ const EMPTY_QUERY = Object.freeze(Object.create(null));
172
+ export function queryObjectOf(search) {
173
+ const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0;
174
+ if (start >= search.length)
175
+ return EMPTY_QUERY;
176
+ for (let i = start; i < search.length; i++) {
177
+ const c = search.charCodeAt(i);
178
+ if (c === 37 /* % */ || c === 43 /* + */)
179
+ return queryObjectFallback(search);
180
+ }
181
+ const out = Object.create(null);
182
+ let pos = start;
183
+ while (pos <= search.length) {
184
+ const amp = search.indexOf("&", pos);
185
+ const end = amp === -1 ? search.length : amp;
186
+ if (end > pos) {
187
+ const eq = search.indexOf("=", pos);
188
+ const split = eq !== -1 && eq < end ? eq : end;
189
+ const key = search.slice(pos, split);
190
+ const value = split === end ? "" : search.slice(split + 1, end);
191
+ setQueryValue(out, key, value);
192
+ }
193
+ if (amp === -1)
194
+ break;
195
+ pos = amp + 1;
196
+ }
197
+ return out;
198
+ }
199
+ /** `application/x-www-form-urlencoded`, with or without a charset suffix. */
200
+ function isUrlEncodedForm(contentType) {
201
+ return (contentType === "application/x-www-form-urlencoded" ||
202
+ contentType.startsWith("application/x-www-form-urlencoded;"));
203
+ }
204
+ const FORM_DECODER = new TextDecoder();
205
+ /**
206
+ * Read an HTML-form body (urlencoded) into the plain object a body schema validates — same byte
207
+ * cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
208
+ * repeated-key → `string[]` promotion as query parsing, same `__proto__` guard.
209
+ */
210
+ async function readBoundedForm(req, maxBytes) {
211
+ const read = await readBoundedBytes(req, maxBytes);
212
+ if (!read.ok) {
213
+ return read.status === 413
214
+ ? jsonError(413, "payload_too_large")
215
+ : jsonError(400, "invalid_content_length");
216
+ }
217
+ const out = Object.create(null);
218
+ // URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
219
+ // it never throws on junk input, so no try/catch is needed here.
220
+ for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
221
+ setQueryValue(out, key, value);
222
+ }
223
+ return out;
224
+ }
225
+ function decodeParams(raw) {
226
+ let out;
227
+ for (const key in raw) {
228
+ const value = raw[key];
229
+ // `decodeURIComponent` only changes strings containing `%`; for the common
230
+ // unencoded param (`/users/123`) skip the call and its throw-check entirely —
231
+ // semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
232
+ // param is already plain, return the router's per-request object directly and skip a clone.
233
+ if (!value.includes("%")) {
234
+ continue;
235
+ }
236
+ try {
237
+ out ??= { ...raw };
238
+ out[key] = decodeURIComponent(value);
239
+ }
240
+ catch {
241
+ return null;
242
+ }
243
+ }
244
+ return out ?? raw;
245
+ }
246
+ class LazyResponseControls {
247
+ status;
248
+ _headers;
249
+ _cookies;
250
+ get headers() {
251
+ this._headers ??= {};
252
+ return this._headers;
253
+ }
254
+ cookie(name, value, options) {
255
+ // Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
256
+ const merged = {
257
+ httpOnly: true,
258
+ secure: true,
259
+ sameSite: "lax",
260
+ path: "/",
261
+ ...options,
262
+ };
263
+ this._cookies ??= [];
264
+ this._cookies.push(serializeCookie(name, value, merged));
265
+ }
266
+ deleteCookie(name, options) {
267
+ // Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
268
+ this._cookies ??= [];
269
+ this._cookies.push(serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }));
270
+ }
271
+ }
272
+ // Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
273
+ // live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
274
+ const EMPTY_RESPONSE_CONTROLS = Object.freeze({});
275
+ function responseSet(ctx) {
276
+ return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
277
+ }
278
+ function isContextlessNoArgArrow(handler) {
279
+ if (handler.length !== 0)
280
+ return false;
281
+ try {
282
+ return CONTEXTLESS_ARROW.test(functionToString.call(handler));
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ }
288
+ class RequestContext {
289
+ params;
290
+ signal;
291
+ env;
292
+ waitUntil;
293
+ body;
294
+ [CONTEXT_SEARCH];
295
+ setValue;
296
+ queryValue;
297
+ queryReady;
298
+ cookiesValue;
299
+ source;
300
+ maxBodyBytes;
301
+ constructor(source, params, search, signal, platform, maxBodyBytes) {
302
+ this.source = source;
303
+ this.params = params;
304
+ this[CONTEXT_SEARCH] = search;
305
+ this.signal = signal;
306
+ this.env = platform?.env;
307
+ this.waitUntil = platform?.waitUntil ?? fallbackWaitUntil;
308
+ this.maxBodyBytes = maxBodyBytes;
309
+ this.setValue = undefined;
310
+ this.body = undefined;
311
+ this.queryValue = undefined;
312
+ this.queryReady = false;
313
+ this.cookiesValue = undefined;
314
+ }
315
+ [CONTEXT_SET]() {
316
+ return this.setValue;
317
+ }
318
+ get set() {
319
+ this.setValue ??= new LazyResponseControls();
320
+ return this.setValue;
321
+ }
322
+ get req() {
323
+ return requestOf(this.source);
324
+ }
325
+ get query() {
326
+ if (!this.queryReady) {
327
+ this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH]);
328
+ this.queryReady = true;
329
+ }
330
+ return this.queryValue;
331
+ }
332
+ set query(v) {
333
+ this.queryValue = v;
334
+ this.queryReady = true;
335
+ }
336
+ get cookies() {
337
+ this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"));
338
+ return this.cookiesValue;
339
+ }
340
+ boundedBody(maxBytes) {
341
+ return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes);
342
+ }
343
+ boundedJson(maxBytes) {
344
+ return readBoundedJsonBodyOrThrow(this.source, this.maxBodyBytes, maxBytes);
345
+ }
346
+ }
347
+ /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
348
+ * `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
349
+ * The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
350
+ async function readBoundedBodyOrThrow(req, maxBodyBytes, maxBytes) {
351
+ const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes);
352
+ if (r.ok)
353
+ return r.bytes;
354
+ throw r.status === 413
355
+ ? jsonError(413, "payload_too_large")
356
+ : jsonError(400, "invalid_content_length");
357
+ }
358
+ /** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
359
+ async function readBoundedJsonBodyOrThrow(req, maxBodyBytes, maxBytes) {
360
+ const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes);
361
+ if (parsed instanceof Response)
362
+ throw parsed;
363
+ return parsed;
364
+ }
365
+ /**
366
+ * Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
367
+ * Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
368
+ * chunked or length-less requests fall back to the streaming byte-cap guard.
369
+ */
370
+ async function readBoundedJsonSource(req, maxBytes) {
371
+ const declared = headerOf(req, "content-length");
372
+ if (declared !== null) {
373
+ // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
374
+ // non-numeric / negative / fractional / exponential value (`Number()` would happily accept
375
+ // "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
376
+ // through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
377
+ // would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
378
+ // hardens hand-built Requests (tests, the in-process client) and crafted input.
379
+ const length = parseContentLength(declared);
380
+ if (length === undefined)
381
+ return jsonError(400, "invalid_content_length");
382
+ if (length > maxBytes)
383
+ return jsonError(413, "payload_too_large");
384
+ const chunked = headerOf(req, "transfer-encoding") !== null;
385
+ if (!chunked) {
386
+ try {
387
+ return await req.json();
388
+ }
389
+ catch {
390
+ return jsonError(400, "invalid_json");
391
+ }
392
+ }
393
+ }
394
+ const body = req.body;
395
+ if (body === null)
396
+ return jsonError(400, "invalid_json");
397
+ const drained = await drainCapped(body, maxBytes);
398
+ if (!drained.ok)
399
+ return jsonError(413, "payload_too_large");
400
+ try {
401
+ return JSON.parse(TEXT_DECODER.decode(drained.bytes));
402
+ }
403
+ catch {
404
+ return jsonError(400, "invalid_json");
405
+ }
406
+ }
407
+ /** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
408
+ * argless `new Date()`. */
409
+ const EPOCH = new Date(0);
410
+ /** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
411
+ * gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
412
+ * `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
413
+ function headersInit(set) {
414
+ const cookies = set._cookies;
415
+ if (cookies === undefined || cookies.length === 0)
416
+ return set._headers;
417
+ const headers = new Headers(set._headers);
418
+ for (const cookie of cookies)
419
+ headers.append("set-cookie", cookie);
420
+ return headers;
421
+ }
422
+ // Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
423
+ // scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
424
+ // across responses: the Response constructor copies `init.headers` into its own list.
425
+ const JSON_CT_HEADERS = new Headers({
426
+ "content-type": "application/json;charset=utf-8",
427
+ });
428
+ const JSON_INIT_200 = { status: 200, headers: JSON_CT_HEADERS };
429
+ /** Fused-lane respond when the handler ran with NO context (so `c.set` can't exist): the fast
430
+ * JSON respond directly, with `toResponse` + the empty controls as the exact-semantics fallback. */
431
+ function fusedRespondNoSet(result) {
432
+ if (result !== undefined &&
433
+ !(result instanceof Response) &&
434
+ typeof result === "object" &&
435
+ result !== null &&
436
+ !isResponseResult(result)) {
437
+ const body = JSON.stringify(result);
438
+ if (body !== undefined)
439
+ return new Response(body, JSON_INIT_200);
440
+ }
441
+ return toResponse(result, EMPTY_RESPONSE_CONTROLS);
442
+ }
443
+ /** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
444
+ * JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
445
+ function fusedRespond(result, ctx) {
446
+ const set = ctx[CONTEXT_SET]();
447
+ if (set === undefined)
448
+ return fusedRespondNoSet(result);
449
+ return toResponse(result, set);
450
+ }
451
+ function toResponse(result, set) {
452
+ if (isResponseResult(result)) {
453
+ return appendCookiesToResponse(result.toResponse(), set);
454
+ }
455
+ if (result instanceof Response) {
456
+ return appendCookiesToResponse(result, set);
457
+ }
458
+ const headers = headersInit(set);
459
+ const status = set.status ?? (result === undefined ? 204 : 200);
460
+ if (headers === undefined && result !== undefined) {
461
+ // Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
462
+ // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
463
+ // same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
464
+ // result) delegates to Response.json so its TypeError contract stays the single source.
465
+ const body = JSON.stringify(result);
466
+ if (body !== undefined) {
467
+ return new Response(body, status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS });
468
+ }
469
+ }
470
+ const init = headers === undefined ? { status } : { status, headers };
471
+ return result === undefined ? new Response(null, init) : Response.json(result, init);
472
+ }
473
+ function appendCookiesToResponse(response, set) {
474
+ // A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
475
+ // (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
476
+ // returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
477
+ // the cookie. (Other `c.set` fields stay the returned Response's own concern.)
478
+ const cookies = set._cookies;
479
+ if (cookies !== undefined && cookies.length > 0) {
480
+ for (const cookie of cookies)
481
+ response.headers.append("set-cookie", cookie);
482
+ }
483
+ return response;
484
+ }
485
+ /**
486
+ * `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
487
+ * a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
488
+ * via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
489
+ * cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
490
+ * works on Node).
491
+ */
492
+ function toNodeOutcome(result, set) {
493
+ if (isResponseResult(result)) {
494
+ const body = result.toNodeBody?.();
495
+ if (body !== undefined) {
496
+ return {
497
+ kind: "body",
498
+ status: body.status,
499
+ headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
500
+ body: body.body,
501
+ };
502
+ }
503
+ return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set));
504
+ }
505
+ if (result instanceof Response) {
506
+ return nodeOutcomeFromResponse(appendCookiesToResponse(result, set));
507
+ }
508
+ const status = set.status ?? (result === undefined ? 204 : 200);
509
+ return {
510
+ kind: "json",
511
+ status,
512
+ headers: set._headers,
513
+ cookies: set._cookies,
514
+ body: result === undefined ? null : JSON.stringify(result),
515
+ };
516
+ }
517
+ // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
518
+ const IDENTITY_RESPONSE = (response) => response;
519
+ const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
520
+ const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
521
+ const NODE_FROM_RESPONSE = (response) => nodeOutcomeFromResponse(response);
522
+ const NODE_TIMEOUT = () => ({
523
+ kind: "response",
524
+ response: jsonError(503, "request_timeout"),
525
+ });
526
+ function nodeOutcomeFromResponse(response) {
527
+ const body = nodeResponseBody(response);
528
+ return body === undefined
529
+ ? { kind: "response", response }
530
+ : { kind: "body", status: response.status, headers: responseHeadersForNode(response), body };
531
+ }
532
+ function nodeResponseBody(response) {
533
+ if (response.bodyUsed)
534
+ return undefined;
535
+ const body = response[NODE_RESPONSE_BODY];
536
+ return typeof body === "string" || body instanceof Uint8Array ? body : undefined;
537
+ }
538
+ function responseHeadersForNode(response) {
539
+ let headers;
540
+ response.headers.forEach((value, key) => {
541
+ headers ??= {};
542
+ headers[key] = value;
543
+ });
544
+ const setCookies = response.headers.getSetCookie?.();
545
+ if (setCookies !== undefined && setCookies.length > 0) {
546
+ headers ??= {};
547
+ headers["set-cookie"] = setCookies;
548
+ }
549
+ return headers;
550
+ }
551
+ function appendCookiesToNodeHeaders(headers, cookies) {
552
+ if (cookies === undefined || cookies.length === 0)
553
+ return headers;
554
+ const out = headers === undefined ? {} : { ...headers };
555
+ const existing = out["set-cookie"];
556
+ const setCookies = existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing];
557
+ out["set-cookie"] = [...setCookies, ...cookies];
558
+ return out;
559
+ }
560
+ /**
561
+ * The inline server. Routes are chainable and fully type-inferred. `derive`/
562
+ * `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
563
+ * with full types; `Ctx` is server-only and never touches the client registry.
564
+ *
565
+ * app.decorate("db", db).derive((c) => ({ user: auth(c) }))
566
+ * .get("/me", (c) => c.user) // c.user + c.db are typed
567
+ */
568
+ export class Server {
569
+ router;
570
+ /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
571
+ wsRouter;
572
+ wsRouteCount;
573
+ /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
574
+ * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
575
+ * no-WebSocket app never constructs (or bundles) it. */
576
+ topics;
577
+ routeList;
578
+ maxBodyBytes;
579
+ requestTimeoutMs;
580
+ gracefulSignals;
581
+ logger;
582
+ bunServer;
583
+ derives;
584
+ decorations;
585
+ beforeHandleHooks;
586
+ afterHandleHooks;
587
+ onErrorHooks;
588
+ aroundHooks;
589
+ onRequestHooks;
590
+ onResponseHooks;
591
+ responseRequests;
592
+ /** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
593
+ appliedPlugins;
594
+ constructor(options = {}) {
595
+ this.router = new Router();
596
+ this.wsRouter = new Router();
597
+ this.wsRouteCount = 0;
598
+ this.topics = undefined;
599
+ this.routeList = [];
600
+ this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
601
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
602
+ this.gracefulSignals = options.gracefulSignals ?? false;
603
+ this.logger = options.logger ?? jsonLogger();
604
+ this.bunServer = undefined;
605
+ this.derives = [];
606
+ this.decorations = {};
607
+ this.beforeHandleHooks = [];
608
+ this.afterHandleHooks = [];
609
+ this.onErrorHooks = [];
610
+ this.aroundHooks = [];
611
+ this.onRequestHooks = [];
612
+ this.onResponseHooks = [];
613
+ this.responseRequests = new WeakMap();
614
+ this.appliedPlugins = new Set();
615
+ }
616
+ /** Add a per-request, computed context extension for subsequent routes. */
617
+ derive(fn) {
618
+ this.derives.push(fn);
619
+ return this;
620
+ }
621
+ /** Add a static context value for subsequent routes. */
622
+ decorate(key, value) {
623
+ this.decorations[key] = value;
624
+ return this;
625
+ }
626
+ /**
627
+ * Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
628
+ * `Request` to continue routing with a rewritten method/URL/headers. Global.
629
+ */
630
+ onRequest(fn) {
631
+ this.onRequestHooks.push(fn);
632
+ return this;
633
+ }
634
+ /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
635
+ beforeHandle(fn) {
636
+ this.beforeHandleHooks.push(fn);
637
+ return this;
638
+ }
639
+ /**
640
+ * Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
641
+ * route output, so wrappers like async context storage do not force Node's direct JSON path through
642
+ * a Web `Response`. The first registered wrapper is outermost.
643
+ */
644
+ around(fn) {
645
+ this.aroundHooks.push(fn);
646
+ return this;
647
+ }
648
+ /** Transform the handler's result before it is serialized. Order-scoped. */
649
+ afterHandle(fn) {
650
+ this.afterHandleHooks.push(fn);
651
+ return this;
652
+ }
653
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
654
+ onError(fn) {
655
+ this.onErrorHooks.push(fn);
656
+ return this;
657
+ }
658
+ /** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
659
+ onResponse(fn) {
660
+ this.onResponseHooks.push(fn);
661
+ return this;
662
+ }
663
+ use(arg) {
664
+ if (typeof arg === "function") {
665
+ const name = arg.pluginName;
666
+ if (name !== undefined) {
667
+ if (this.appliedPlugins.has(name))
668
+ return this; // idempotent: already applied
669
+ this.appliedPlugins.add(name);
670
+ }
671
+ return arg(this);
672
+ }
673
+ if (arg.name !== undefined) {
674
+ if (this.appliedPlugins.has(arg.name))
675
+ return this;
676
+ this.appliedPlugins.add(arg.name);
677
+ }
678
+ if (arg.onRequest !== undefined)
679
+ this.onRequest(arg.onRequest);
680
+ if (arg.around !== undefined)
681
+ this.around(arg.around);
682
+ if (arg.beforeHandle !== undefined)
683
+ this.beforeHandle(arg.beforeHandle);
684
+ if (arg.afterHandle !== undefined)
685
+ this.afterHandle(arg.afterHandle);
686
+ if (arg.onResponse !== undefined)
687
+ this.onResponse(arg.onResponse);
688
+ if (arg.onError !== undefined)
689
+ this.onError(arg.onError);
690
+ return this;
691
+ }
692
+ get(path, schemaOrHandler, handler) {
693
+ return this.route("GET", path, schemaOrHandler, handler);
694
+ }
695
+ post(path, schemaOrHandler, handler) {
696
+ return this.route("POST", path, schemaOrHandler, handler);
697
+ }
698
+ put(path, schemaOrHandler, handler) {
699
+ return this.route("PUT", path, schemaOrHandler, handler);
700
+ }
701
+ patch(path, schemaOrHandler, handler) {
702
+ return this.route("PATCH", path, schemaOrHandler, handler);
703
+ }
704
+ delete(path, schemaOrHandler, handler) {
705
+ return this.route("DELETE", path, schemaOrHandler, handler);
706
+ }
707
+ /**
708
+ * Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
709
+ * `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
710
+ * reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
711
+ * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
712
+ * has no socket (a WS path through `app.fetch` is a normal HTTP response).
713
+ *
714
+ * app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
715
+ */
716
+ ws(path, handler) {
717
+ // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
718
+ // bundle it. Registration is the loud, early failure point — never the first connection.
719
+ const runtime = requireWsRuntime();
720
+ this.topics ??= runtime.createTopics();
721
+ // A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
722
+ // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
723
+ this.wsRouter.add("GET", path, {
724
+ handler: runtime.wrapHandler(handler),
725
+ });
726
+ this.wsRouteCount += 1;
727
+ return this;
728
+ }
729
+ /**
730
+ * Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
731
+ * In-process and **single-instance** (see {@link TopicRegistry}) — a multi-instance deploy must bridge
732
+ * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
733
+ */
734
+ publish(topic, data) {
735
+ // No `app.ws()` yet ⇒ no registry and necessarily no subscribers — a publish is a no-op anyway.
736
+ this.topics?.publish(topic, data);
737
+ }
738
+ route(method, path, schemaOrHandler, handler) {
739
+ let rawHandler;
740
+ let schema;
741
+ if (handler !== undefined) {
742
+ schema = schemaOrHandler;
743
+ rawHandler = handler;
744
+ }
745
+ else {
746
+ schema = undefined;
747
+ rawHandler = schemaOrHandler;
748
+ }
749
+ this.register(method, path, schema, rawHandler);
750
+ // The accumulated registry type is compile-time only; the same instance
751
+ // carries every route, so the public methods re-type `this` per call.
752
+ return this;
753
+ }
754
+ /**
755
+ * Low-level route registration shared by the inline builder and `implement()`.
756
+ * Captures the server's current `derive`/`decorate` chain into the route — this
757
+ * is the "compiled", order-scoped per-route chain.
758
+ */
759
+ register(method, path, schema, handler) {
760
+ const bare = schema?.body === undefined &&
761
+ schema?.query === undefined &&
762
+ this.derives.length === 0 &&
763
+ this.beforeHandleHooks.length === 0 &&
764
+ this.afterHandleHooks.length === 0 &&
765
+ this.onErrorHooks.length === 0;
766
+ const fusedWeb = bare && this.aroundHooks.length === 0
767
+ ? this.buildFusedWeb(handler, Object.keys(this.decorations).length > 0 ? { ...this.decorations } : undefined, isContextlessNoArgArrow(handler))
768
+ : undefined;
769
+ this.router.add(method, path, {
770
+ // (context: never) => unknown -> InternalHandler: the framework invokes it
771
+ // with the concrete RawContext the typed handler expects, so this is sound.
772
+ handler: handler,
773
+ schema,
774
+ fusedWeb,
775
+ derives: [...this.derives],
776
+ decorations: { ...this.decorations },
777
+ hasDecorations: Object.keys(this.decorations).length > 0,
778
+ beforeHandle: [...this.beforeHandleHooks],
779
+ afterHandle: [...this.afterHandleHooks],
780
+ onError: [...this.onErrorHooks],
781
+ around: [...this.aroundHooks],
782
+ // Sync-fast-path eligibility (see RouteEntry.bare). Validation + every hook kind must be absent;
783
+ // decorations and generic around wrappers are fine: around wraps the inner bare route in
784
+ // `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
785
+ // Most GET/no-schema routes qualify.
786
+ bare,
787
+ contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
788
+ bodyOnly: schema?.body !== undefined &&
789
+ schema.query === undefined &&
790
+ this.derives.length === 0 &&
791
+ this.beforeHandleHooks.length === 0 &&
792
+ this.afterHandleHooks.length === 0 &&
793
+ this.onErrorHooks.length === 0,
794
+ queryOnly: schema?.body === undefined &&
795
+ schema?.query !== undefined &&
796
+ this.derives.length === 0 &&
797
+ this.beforeHandleHooks.length === 0 &&
798
+ this.afterHandleHooks.length === 0 &&
799
+ this.onErrorHooks.length === 0,
800
+ });
801
+ this.routeList.push({ method, path, schema });
802
+ }
803
+ /**
804
+ * Enumerate the registered routes (method, path, input schemas), in registration
805
+ * order. Powers `toOpenAPI` and other introspection; the router trie itself no
806
+ * longer holds the original patterns.
807
+ */
808
+ routes() {
809
+ return this.routeList;
810
+ }
811
+ /**
812
+ * Resolve a `Request` to a `Response` — the whole lifecycle, testable without a port. The
813
+ * optional `platform` carries edge inputs (`env`, `waitUntil`); edge adapters pass it, and
814
+ * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
815
+ */
816
+ fetch(req, platform) {
817
+ // A real `Request` satisfies `RequestSource`, so it's passed straight through — no per-request
818
+ // wrapper allocation on the Web/Bun hot path.
819
+ return this.fetchSource(req, platform);
820
+ }
821
+ fetchSource(source, platform) {
822
+ // Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
823
+ // path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
824
+ // promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
825
+ // `await app.fetch(...)` continues to work exactly as before.
826
+ const outcome = this.dispatch(source, platform, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
827
+ if (this.onResponseHooks.length === 0) {
828
+ return outcome;
829
+ }
830
+ // onResponse sees every response — success, validation error, 404/405, timeout, onRequest
831
+ // short-circuit; normalize to a promise, then thread through the hooks.
832
+ return outcome instanceof Promise
833
+ ? outcome.then((response) => this.applyOnResponse(response, this.takeResponseRequest(source)))
834
+ : this.applyOnResponse(outcome, this.takeResponseRequest(source));
835
+ }
836
+ /**
837
+ * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
838
+ * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
839
+ * `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
840
+ * returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
841
+ * full Web path (`{ kind: "response" }`), so behavior is identical — only the common JSON-data case is
842
+ * faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
843
+ * render differs.
844
+ */
845
+ resolveNode(req, platform) {
846
+ return this.resolveNodeSource(req, platform);
847
+ }
848
+ resolveNodeSource(source, platform) {
849
+ // onResponse hooks transform a Response, so they force the Web path; wrap its result.
850
+ if (this.onResponseHooks.length > 0) {
851
+ const response = this.fetchSource(source, platform);
852
+ return response instanceof Promise
853
+ ? response.then((settled) => ({ kind: "response", response: settled }))
854
+ : { kind: "response", response };
855
+ }
856
+ // May resolve **synchronously** for a bare route + sync handler (RouteEntry.bare) — the `@nifrajs/node`
857
+ // adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
858
+ // at all on the Node hot path.
859
+ return this.dispatch(source, platform, toNodeOutcome, NODE_FROM_RESPONSE, NODE_TIMEOUT, false);
860
+ }
861
+ /**
862
+ * Resolve a WebSocket upgrade — the seam every serving adapter uses. Returns `pass` (not a WS
863
+ * upgrade for a registered route → handle as normal HTTP), `reject` (a WS route matched but
864
+ * `upgrade()` rejected, or the path was malformed → return `response`), or `upgrade` (perform the
865
+ * runtime upgrade, then dispatch the native socket's events to `handler`, seeding `ws.data` with
866
+ * `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
867
+ * `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
868
+ */
869
+ resolveWebSocketUpgrade(req, platform) {
870
+ if (this.wsRouteCount === 0)
871
+ return WS_PASS;
872
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
873
+ return WS_PASS;
874
+ const url = urlPartsOf(req.url);
875
+ const match = this.wsRouter.find("GET", url.pathname);
876
+ if (!match.found)
877
+ return WS_PASS; // upgrade header, no WS route here → normal routing decides
878
+ const params = match.params === EMPTY_PARAMS ? EMPTY_PARAMS : decodeParams(match.params);
879
+ if (params === null)
880
+ return { kind: "reject", response: jsonError(400, "malformed_path") };
881
+ const handler = match.payload.handler;
882
+ // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
883
+ const pubsub = this.topics;
884
+ // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
885
+ // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
886
+ // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
887
+ if (handler.allowedOrigins !== undefined) {
888
+ const origin = req.headers.get("origin");
889
+ const allowed = typeof handler.allowedOrigins === "function"
890
+ ? handler.allowedOrigins(origin)
891
+ : origin !== null && handler.allowedOrigins.includes(origin);
892
+ if (!allowed)
893
+ return { kind: "reject", response: jsonError(403, "forbidden_origin") };
894
+ }
895
+ if (handler.upgrade === undefined) {
896
+ return { kind: "upgrade", handler, data: undefined, pubsub };
897
+ }
898
+ const ctx = new RequestContext(req, params, url.search, getNeverAbortSignal(), platform, this.maxBodyBytes);
899
+ const settle = (value) => value instanceof Response
900
+ ? { kind: "reject", response: value }
901
+ : { kind: "upgrade", handler, data: value, pubsub };
902
+ try {
903
+ const result = handler.upgrade(ctx);
904
+ return result instanceof Promise
905
+ ? result.then(settle, () => ({
906
+ kind: "reject",
907
+ response: jsonError(500, "internal_error"),
908
+ }))
909
+ : settle(result);
910
+ }
911
+ catch {
912
+ return { kind: "reject", response: jsonError(500, "internal_error") };
913
+ }
914
+ }
915
+ /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
916
+ * `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
917
+ * (The socket dispatch itself lives in `ws-bun.ts`, loaded via `@nifrajs/core/ws`.) */
918
+ bunFetchWithWebSocket(req, server) {
919
+ const handle = (o) => {
920
+ if (o.kind === "pass")
921
+ return this.fetch(req);
922
+ if (o.kind === "reject")
923
+ return o.response;
924
+ return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
925
+ ? undefined
926
+ : jsonError(426, "upgrade_required");
927
+ };
928
+ const outcome = this.resolveWebSocketUpgrade(req);
929
+ return outcome instanceof Promise ? outcome.then(handle) : handle(outcome);
930
+ }
931
+ /**
932
+ * The shared lifecycle, generic over how the final value is rendered: `finalize` turns a handler's
933
+ * result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
934
+ * primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
935
+ * produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
936
+ * context + lifecycle implementation — no duplication across the trust boundary.
937
+ */
938
+ dispatch(source, platform, finalize, wrapResponse, onTimeout,
939
+ // True only from the Web `fetch` path — unlocks each route's fused lane, whose output type IS
940
+ // `Response` (`T = Response` there by construction; the node path always passes false).
941
+ webFast) {
942
+ // onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
943
+ // case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
944
+ if (this.onRequestHooks.length === 0) {
945
+ return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast);
946
+ }
947
+ return this.runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast);
948
+ }
949
+ /**
950
+ * onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
951
+ * common case — e.g. CORS returning `undefined` for a non-preflight request): an `async` version
952
+ * here put EVERY request of any app with one onRequest hook onto the promise machinery, profiled
953
+ * at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
954
+ * hooks to the async continuation; behavior is identical.
955
+ */
956
+ runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast) {
957
+ const hooks = this.onRequestHooks;
958
+ const originalRequest = requestOf(source);
959
+ let current = source;
960
+ for (let i = 0; i < hooks.length; i++) {
961
+ const outcome = hooks[i](requestOf(current));
962
+ if (outcome instanceof Promise) {
963
+ return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
964
+ }
965
+ if (outcome instanceof Request) {
966
+ current = outcome;
967
+ if (outcome !== originalRequest)
968
+ this.responseRequests.set(originalRequest, outcome);
969
+ continue;
970
+ }
971
+ if (outcome !== undefined)
972
+ return wrapResponse(outcome);
973
+ }
974
+ return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
975
+ }
976
+ /** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
977
+ * the remaining hooks (awaiting freely — we're already async here). */
978
+ async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
979
+ let current = sourceAtAwait;
980
+ let early = first;
981
+ let index = nextIndex;
982
+ for (;;) {
983
+ if (early instanceof Request) {
984
+ current = early;
985
+ if (early !== originalRequest)
986
+ this.responseRequests.set(originalRequest, early);
987
+ }
988
+ else if (early !== undefined) {
989
+ return wrapResponse(early);
990
+ }
991
+ if (index >= this.onRequestHooks.length)
992
+ break;
993
+ const outcome = this.onRequestHooks[index](requestOf(current));
994
+ early = outcome instanceof Promise ? await outcome : outcome;
995
+ index++;
996
+ }
997
+ return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
998
+ }
999
+ takeResponseRequest(source) {
1000
+ const request = requestOf(source);
1001
+ const rewritten = this.responseRequests.get(request);
1002
+ if (rewritten === undefined)
1003
+ return request;
1004
+ this.responseRequests.delete(request);
1005
+ return rewritten;
1006
+ }
1007
+ /**
1008
+ * Route → build context → run. Synchronous through to the handler for a **bare** route
1009
+ * ({@link RouteEntry.bare}), so a sync handler produces its result with zero promise allocations;
1010
+ * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
1011
+ */
1012
+ routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
1013
+ const url = urlPartsOf(source.url);
1014
+ const match = this.router.find(source.method, url.pathname);
1015
+ if (!match.found) {
1016
+ if (match.reason === "method-not-allowed") {
1017
+ return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
1018
+ }
1019
+ return wrapResponse(jsonError(404, "not_found"));
1020
+ }
1021
+ const params = match.params === EMPTY_PARAMS ? EMPTY_PARAMS : decodeParams(match.params);
1022
+ if (params === null) {
1023
+ return wrapResponse(jsonError(400, "malformed_path"));
1024
+ }
1025
+ const entry = match.payload;
1026
+ // A per-request cancellation signal. Only allocate a real controller when a
1027
+ // timeout is armed; otherwise share a never-aborting signal so the (common)
1028
+ // no-timeout path pays nothing.
1029
+ let controller;
1030
+ let signal = getNeverAbortSignal();
1031
+ if (this.requestTimeoutMs > 0) {
1032
+ controller = new AbortController();
1033
+ signal = controller.signal;
1034
+ }
1035
+ // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
1036
+ // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
1037
+ let outcome;
1038
+ if (webFast && entry.fusedWeb !== undefined) {
1039
+ // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
1040
+ // passed only by the `fetch` path, where T = Response by construction.
1041
+ outcome = entry.fusedWeb(source, params, url.search, signal, platform);
1042
+ }
1043
+ else if (entry.contextlessBare) {
1044
+ outcome = this.runContextlessBare(entry, source, params, url.search, signal, platform, finalize, wrapResponse);
1045
+ }
1046
+ else {
1047
+ // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
1048
+ // stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
1049
+ const ctx = new RequestContext(source, params, url.search, signal, platform, this.maxBodyBytes);
1050
+ if (entry.around.length === 0) {
1051
+ outcome = entry.bare
1052
+ ? this.runBare(entry, ctx, finalize, wrapResponse)
1053
+ : entry.bodyOnly
1054
+ ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
1055
+ : entry.queryOnly
1056
+ ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
1057
+ : this.runLifecycle(entry, source, ctx, finalize, wrapResponse);
1058
+ }
1059
+ else {
1060
+ outcome = this.runWithAround(entry, ctx, () => entry.bare
1061
+ ? this.runBare(entry, ctx, finalize, wrapResponse)
1062
+ : entry.bodyOnly
1063
+ ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
1064
+ : entry.queryOnly
1065
+ ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
1066
+ : this.runLifecycle(entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse);
1067
+ }
1068
+ }
1069
+ // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1070
+ // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1071
+ if (controller !== undefined && outcome instanceof Promise) {
1072
+ return this.withTimeout(outcome, controller, onTimeout);
1073
+ }
1074
+ return outcome;
1075
+ }
1076
+ /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1077
+ * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
1078
+ runContextlessBare(entry, source, params, search, signal, platform, finalize, wrapResponse) {
1079
+ let result;
1080
+ try {
1081
+ result = entry.handler();
1082
+ }
1083
+ catch (err) {
1084
+ return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse);
1085
+ }
1086
+ if (result instanceof Promise) {
1087
+ return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse));
1088
+ }
1089
+ return finalize(result, EMPTY_RESPONSE_CONTROLS);
1090
+ }
1091
+ contextlessBareError(err, source, params, search, signal, platform, wrapResponse) {
1092
+ if (err instanceof Response)
1093
+ return wrapResponse(err);
1094
+ const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes);
1095
+ this.logRequestError(err, ctx);
1096
+ return wrapResponse(jsonError(500, "internal_error"));
1097
+ }
1098
+ /**
1099
+ * The synchronous fast path for a {@link RouteEntry.bare} route: apply static decorations, call the
1100
+ * handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
1101
+ * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
1102
+ * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
1103
+ * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
1104
+ * the per-request async-frame tax — the same win codegen routers get, but without `eval`.
1105
+ */
1106
+ runBare(entry, ctx, finalize, wrapResponse) {
1107
+ let result;
1108
+ try {
1109
+ if (entry.hasDecorations)
1110
+ Object.assign(ctx, entry.decorations);
1111
+ result = entry.handler(ctx);
1112
+ }
1113
+ catch (err) {
1114
+ return this.bareError(err, ctx, wrapResponse);
1115
+ }
1116
+ if (result instanceof Promise) {
1117
+ // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
1118
+ return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, wrapResponse));
1119
+ }
1120
+ return finalize(result, responseSet(ctx));
1121
+ }
1122
+ /** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
1123
+ * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
1124
+ /**
1125
+ * Build a route's fused Web lane (see {@link RouteEntry.fusedWeb}). Composition happens once at
1126
+ * registration; the returned closure is what every request to the route runs. Behavior is
1127
+ * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
1128
+ * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
1129
+ * 500s), same respond semantics (the lifecycle parity suite pins it).
1130
+ */
1131
+ buildFusedWeb(handler, decorations, contextless) {
1132
+ const maxBodyBytes = this.maxBodyBytes;
1133
+ const logError = (err, ctx) => {
1134
+ if (err instanceof Response)
1135
+ return err;
1136
+ this.logRequestError(err, ctx);
1137
+ return jsonError(500, "internal_error");
1138
+ };
1139
+ if (contextless && decorations === undefined) {
1140
+ // `() => ...` can't observe the context — skip allocating one entirely (errors still build
1141
+ // one for the structured log, exactly like runContextlessBare).
1142
+ const contextlessHandler = handler;
1143
+ return (source, params, search, signal, platform) => {
1144
+ let result;
1145
+ try {
1146
+ result = contextlessHandler();
1147
+ }
1148
+ catch (err) {
1149
+ return logError(err, new RequestContext(source, params, search, signal, platform, maxBodyBytes));
1150
+ }
1151
+ if (result instanceof Promise) {
1152
+ return result.then(fusedRespondNoSet, (err) => logError(err, new RequestContext(source, params, search, signal, platform, maxBodyBytes)));
1153
+ }
1154
+ return fusedRespondNoSet(result);
1155
+ };
1156
+ }
1157
+ return (source, params, search, signal, platform) => {
1158
+ const ctx = new RequestContext(source, params, search, signal, platform, maxBodyBytes);
1159
+ if (decorations !== undefined)
1160
+ Object.assign(ctx, decorations);
1161
+ let result;
1162
+ try {
1163
+ result = handler(ctx);
1164
+ }
1165
+ catch (err) {
1166
+ return logError(err, ctx);
1167
+ }
1168
+ if (result instanceof Promise) {
1169
+ return result.then((value) => fusedRespond(value, ctx), (err) => logError(err, ctx));
1170
+ }
1171
+ return fusedRespond(result, ctx);
1172
+ };
1173
+ }
1174
+ bareError(err, ctx, wrapResponse) {
1175
+ if (err instanceof Response)
1176
+ return wrapResponse(err);
1177
+ this.logRequestError(err, ctx);
1178
+ return wrapResponse(jsonError(500, "internal_error"));
1179
+ }
1180
+ runWithAround(entry, ctx, run, finalize, wrapResponse) {
1181
+ let outcome;
1182
+ try {
1183
+ outcome = this.runAround(entry.around, ctx, run);
1184
+ }
1185
+ catch (err) {
1186
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1187
+ }
1188
+ return outcome instanceof Promise
1189
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
1190
+ : outcome;
1191
+ }
1192
+ runAround(hooks, ctx, run) {
1193
+ const dispatch = (index) => {
1194
+ if (index >= hooks.length)
1195
+ return run();
1196
+ const hook = hooks[index];
1197
+ let called = false;
1198
+ return hook(ctx, () => {
1199
+ if (called)
1200
+ throw new Error("around next() called multiple times");
1201
+ called = true;
1202
+ return dispatch(index + 1);
1203
+ });
1204
+ };
1205
+ return dispatch(0);
1206
+ }
1207
+ runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
1208
+ const contentType = headerOf(source, "content-type") ?? "";
1209
+ if (contentType !== "application/json" && !contentType.includes("application/json")) {
1210
+ if (isUrlEncodedForm(contentType)) {
1211
+ // HTML form posts validate through the same schema as JSON (parity with Elysia/Hono).
1212
+ // Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
1213
+ // bounded form reader shares the streaming guard. JSON requests are untouched.
1214
+ return readBoundedForm(source, this.maxBodyBytes).then((form) => {
1215
+ if (form instanceof Response)
1216
+ return wrapResponse(form);
1217
+ return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse);
1218
+ });
1219
+ }
1220
+ return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
1221
+ }
1222
+ // Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
1223
+ // `req.json()` directly — one promise, one `.then` closure per request. The generic
1224
+ // `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
1225
+ // closures) is kept for the chunked / length-less / oversized cases it exists for.
1226
+ // Semantics are identical to readBoundedJsonSource — same checks, same error codes.
1227
+ const declared = headerOf(source, "content-length");
1228
+ if (declared !== null) {
1229
+ const length = parseContentLength(declared);
1230
+ if (length === undefined) {
1231
+ return Promise.resolve(wrapResponse(jsonError(400, "invalid_content_length")));
1232
+ }
1233
+ if (length > this.maxBodyBytes) {
1234
+ return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")));
1235
+ }
1236
+ if (headerOf(source, "transfer-encoding") === null) {
1237
+ return source.json().then((parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
1238
+ // Native json() rejection = malformed JSON; anything past parse goes through
1239
+ // finishBodyOnly's own error handling.
1240
+ () => wrapResponse(jsonError(400, "invalid_json")));
1241
+ }
1242
+ }
1243
+ try {
1244
+ return this.readBoundedJson(source).then((parsed) => {
1245
+ if (parsed instanceof Response)
1246
+ return wrapResponse(parsed);
1247
+ return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse);
1248
+ }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
1249
+ }
1250
+ catch (err) {
1251
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1252
+ }
1253
+ }
1254
+ /** Validate + run the handler for the bodyOnly path — shared by the inline fast path and the
1255
+ * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
1256
+ * beyond the one `.then` continuation. */
1257
+ finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse) {
1258
+ try {
1259
+ const bodySchema = entry.schema.body;
1260
+ const validation = bodySchema["~standard"].validate(parsed);
1261
+ if (validation instanceof Promise) {
1262
+ return validation.then((result) => {
1263
+ try {
1264
+ const outcome = this.applyBodyValidation(entry, result, ctx, finalize, wrapResponse);
1265
+ return outcome instanceof Promise
1266
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
1267
+ : outcome;
1268
+ }
1269
+ catch (err) {
1270
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1271
+ }
1272
+ }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
1273
+ }
1274
+ const outcome = this.applyBodyValidation(entry, validation, ctx, finalize, wrapResponse);
1275
+ return outcome instanceof Promise
1276
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
1277
+ : outcome;
1278
+ }
1279
+ catch (err) {
1280
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1281
+ }
1282
+ }
1283
+ applyBodyValidation(entry, result, ctx, finalize, wrapResponse) {
1284
+ if (result.issues !== undefined)
1285
+ return wrapResponse(validationError(result.issues));
1286
+ ctx.body = result.value;
1287
+ if (entry.hasDecorations)
1288
+ Object.assign(ctx, entry.decorations);
1289
+ const handlerOutput = entry.handler(ctx);
1290
+ return handlerOutput instanceof Promise
1291
+ ? handlerOutput.then((value) => finalize(value, responseSet(ctx)))
1292
+ : finalize(handlerOutput, responseSet(ctx));
1293
+ }
1294
+ runQueryOnly(entry, ctx, finalize, wrapResponse) {
1295
+ try {
1296
+ // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) — skip
1297
+ // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
1298
+ const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
1299
+ if (validation instanceof Promise) {
1300
+ return validation.then((settled) => {
1301
+ try {
1302
+ const outcome = this.applyQueryValidation(entry, settled, ctx, finalize, wrapResponse);
1303
+ return outcome instanceof Promise
1304
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
1305
+ : outcome;
1306
+ }
1307
+ catch (err) {
1308
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1309
+ }
1310
+ }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
1311
+ }
1312
+ return this.applyQueryValidation(entry, validation, ctx, finalize, wrapResponse);
1313
+ }
1314
+ catch (err) {
1315
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1316
+ }
1317
+ }
1318
+ /** Validate-result → set `ctx.query` → run handler. A method (not a per-request closure), the
1319
+ * query analogue of {@link applyBodyValidation}. */
1320
+ applyQueryValidation(entry, result, ctx, finalize, wrapResponse) {
1321
+ if (result.issues !== undefined)
1322
+ return wrapResponse(validationError(result.issues));
1323
+ ctx.query = result.value;
1324
+ if (entry.hasDecorations)
1325
+ Object.assign(ctx, entry.decorations);
1326
+ const handlerOutput = entry.handler(ctx);
1327
+ return handlerOutput instanceof Promise
1328
+ ? handlerOutput.then((value) => finalize(value, responseSet(ctx)), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
1329
+ : finalize(handlerOutput, responseSet(ctx));
1330
+ }
1331
+ /**
1332
+ * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
1333
+ * actually returns a Promise — an `async` version forced a promise + microtask on EVERY response
1334
+ * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
1335
+ * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
1336
+ * rest to {@link continueOnResponse}.
1337
+ */
1338
+ applyOnResponse(response, req) {
1339
+ const hooks = this.onResponseHooks;
1340
+ let current = response;
1341
+ for (let i = 0; i < hooks.length; i++) {
1342
+ const next = hooks[i](current, req);
1343
+ if (next instanceof Promise) {
1344
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req));
1345
+ }
1346
+ current = next;
1347
+ }
1348
+ return current;
1349
+ }
1350
+ /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
1351
+ async continueOnResponse(response, nextIndex, req) {
1352
+ let current = response;
1353
+ for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
1354
+ const next = this.onResponseHooks[i](current, req);
1355
+ current = next instanceof Promise ? await next : next;
1356
+ }
1357
+ return current;
1358
+ }
1359
+ /**
1360
+ * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
1361
+ * handlers can bail) and return 503; the in-flight work keeps running but its
1362
+ * result is discarded — JS can't forcibly cancel a promise.
1363
+ */
1364
+ async withTimeout(work, controller, onTimeout) {
1365
+ let timer;
1366
+ const timeout = new Promise((resolve) => {
1367
+ timer = setTimeout(() => {
1368
+ controller.abort();
1369
+ resolve(onTimeout());
1370
+ }, this.requestTimeoutMs);
1371
+ });
1372
+ try {
1373
+ return await Promise.race([work, timeout]);
1374
+ }
1375
+ finally {
1376
+ if (timer !== undefined)
1377
+ clearTimeout(timer);
1378
+ }
1379
+ }
1380
+ /**
1381
+ * The post-match lifecycle: validate → derive → beforeHandle → handler → afterHandle, with onError.
1382
+ * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
1383
+ * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
1384
+ */
1385
+ async runLifecycle(entry, source, ctx, finalize, wrapResponse) {
1386
+ try {
1387
+ if (entry.schema?.body !== undefined) {
1388
+ const bodyError = await this.readAndValidateBody(source, entry.schema.body, ctx);
1389
+ if (bodyError !== undefined)
1390
+ return wrapResponse(bodyError);
1391
+ }
1392
+ if (entry.schema?.query !== undefined) {
1393
+ // Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
1394
+ // single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
1395
+ // validateStandard wrapper alloc.
1396
+ const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
1397
+ const result = validation instanceof Promise ? await validation : validation;
1398
+ if (result.issues !== undefined)
1399
+ return wrapResponse(validationError(result.issues));
1400
+ ctx.query = result.value;
1401
+ }
1402
+ // Context extensions: static decorations, then per-request derives.
1403
+ // Each hook below only awaits when it actually returns a Promise — a sync hook
1404
+ // skips the microtask tick (a fast-path; ~64 ns/hook).
1405
+ if (entry.hasDecorations)
1406
+ Object.assign(ctx, entry.decorations); // skip the no-op on bare routes
1407
+ // The `.length` guards skip iterator setup for the common no-hook route (most
1408
+ // routes have neither derives nor before/after hooks); a hooked route pays only
1409
+ // the length check.
1410
+ if (entry.derives.length > 0) {
1411
+ for (const derive of entry.derives) {
1412
+ const extension = derive(ctx);
1413
+ Object.assign(ctx, extension instanceof Promise ? await extension : extension);
1414
+ }
1415
+ }
1416
+ // beforeHandle: a non-undefined return short-circuits, skipping the handler.
1417
+ if (entry.beforeHandle.length > 0) {
1418
+ for (const hook of entry.beforeHandle) {
1419
+ const outcome = hook(ctx);
1420
+ const early = outcome instanceof Promise ? await outcome : outcome;
1421
+ if (early !== undefined)
1422
+ return finalize(early, responseSet(ctx));
1423
+ }
1424
+ }
1425
+ const handlerOutput = entry.handler(ctx);
1426
+ let result = handlerOutput instanceof Promise ? await handlerOutput : handlerOutput;
1427
+ // afterHandle: transform the result before serialization.
1428
+ if (entry.afterHandle.length > 0) {
1429
+ for (const hook of entry.afterHandle) {
1430
+ const transformed = hook(result, ctx);
1431
+ result = transformed instanceof Promise ? await transformed : transformed;
1432
+ }
1433
+ }
1434
+ return finalize(result, responseSet(ctx));
1435
+ }
1436
+ catch (err) {
1437
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
1438
+ }
1439
+ }
1440
+ async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
1441
+ // A *thrown* Response is deliberate control flow, not an error — a guard throws a redirect/401,
1442
+ // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
1443
+ // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
1444
+ // any handler or loader.
1445
+ if (err instanceof Response)
1446
+ return wrapResponse(err);
1447
+ // onError hooks may return a custom response; otherwise the default 500 stands.
1448
+ for (const hook of entry.onError) {
1449
+ const outcome = hook(err, ctx);
1450
+ const handled = outcome instanceof Promise ? await outcome : outcome;
1451
+ if (handled !== undefined)
1452
+ return finalize(handled, responseSet(ctx));
1453
+ }
1454
+ // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
1455
+ // (redacting) logger. Body-read failures and around-hook failures land here too.
1456
+ this.logRequestError(err, ctx);
1457
+ return wrapResponse(jsonError(500, "internal_error"));
1458
+ }
1459
+ /** Log an unhandled request error to the (redacting) logger — shared by {@link runLifecycle} and the
1460
+ * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
1461
+ logRequestError(err, ctx) {
1462
+ this.logger.error("unhandled request error", {
1463
+ method: ctx.req.method,
1464
+ path: pathnameOf(ctx.req.url),
1465
+ name: err instanceof Error ? err.name : "Error",
1466
+ message: err instanceof Error ? err.message : String(err),
1467
+ stack: err instanceof Error ? err.stack : undefined,
1468
+ });
1469
+ }
1470
+ async readAndValidateBody(req, schema, ctx) {
1471
+ const contentType = headerOf(req, "content-type") ?? "";
1472
+ let parsed;
1473
+ if (contentType === "application/json" || contentType.includes("application/json")) {
1474
+ const json = await this.readBoundedJson(req);
1475
+ if (json instanceof Response)
1476
+ return json;
1477
+ parsed = json;
1478
+ }
1479
+ else if (isUrlEncodedForm(contentType)) {
1480
+ const form = await readBoundedForm(req, this.maxBodyBytes);
1481
+ if (form instanceof Response)
1482
+ return form;
1483
+ parsed = form;
1484
+ }
1485
+ else {
1486
+ // multipart/form-data (file uploads) stays 415 on the schema path by design — files don't
1487
+ // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
1488
+ return jsonError(415, "unsupported_media_type");
1489
+ }
1490
+ const validation = schema["~standard"].validate(parsed);
1491
+ const result = validation instanceof Promise ? await validation : validation;
1492
+ if (result.issues !== undefined)
1493
+ return validationError(result.issues);
1494
+ ctx.body = result.value;
1495
+ return undefined;
1496
+ }
1497
+ /**
1498
+ * Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
1499
+ * `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
1500
+ * running byte count exceeds it — so a lying or absent length can't force us to
1501
+ * buffer an oversized payload.
1502
+ *
1503
+ * Fast path: when a non-chunked request carries a `Content-Length` within the cap,
1504
+ * a native `req.json()` is already bounded — under HTTP/1.1 + HTTP/2 framing the
1505
+ * runtime delivers at most `Content-Length` bytes — so we skip the manual stream
1506
+ * loop and a separate text decode. It trusts the wire
1507
+ * *framing*, not the header value: nifra only ever receives framed Requests from the
1508
+ * runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
1509
+ * length-less bodies fall through to the streaming byte-cap guard below.
1510
+ */
1511
+ async readBoundedJson(req) {
1512
+ return readBoundedJsonSource(req, this.maxBodyBytes);
1513
+ }
1514
+ /**
1515
+ * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
1516
+ *
1517
+ * `reusePort` sets `SO_REUSEPORT` so **multiple processes can bind the same port** and the kernel
1518
+ * load-balances connections across them — the standard way to use every core (Bun is
1519
+ * single-threaded per process). Spawn one process per core, each calling
1520
+ * `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
1521
+ * and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
1522
+ * favor one socket (fine for dev, measure on Linux for production numbers).
1523
+ */
1524
+ listen(port, options) {
1525
+ if (typeof Bun === "undefined") {
1526
+ // listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
1527
+ // than letting the Bun.serve call below throw a bare `ReferenceError: Bun is not
1528
+ // defined`. Exercised by the @nifrajs/deno suite (which runs on a non-Bun runtime).
1529
+ throw new FrameworkError("BUN_REQUIRED", "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.).");
1530
+ }
1531
+ // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
1532
+ // subset so the public types don't depend on the ambient `Bun` global. The cast
1533
+ // bridges them — Bun's `.port` is `number | undefined` (undefined only for unix
1534
+ // sockets, never a TCP `listen`) and its `.stop` returns a promise we don't await.
1535
+ // Pass only the request — Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
1536
+ // With WS routes, hand Bun a `websocket` config + a fetch that upgrades matching requests (the
1537
+ // `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
1538
+ // `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
1539
+ // matched route's handler, set by `server.upgrade`.
1540
+ // With WS routes, the dispatcher comes from the `@nifrajs/core/ws` runtime — non-null because
1541
+ // wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
1542
+ const wsHandlers = this.wsRouteCount === 0
1543
+ ? undefined
1544
+ : getWsRuntime().bunHandlers(this.topics);
1545
+ const reusePort = options?.reusePort === true;
1546
+ const running = (wsHandlers === undefined
1547
+ ? Bun.serve({ port, reusePort, fetch: (req) => this.fetch(req) })
1548
+ : Bun.serve({
1549
+ port,
1550
+ reusePort,
1551
+ fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
1552
+ // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
1553
+ // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
1554
+ // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
1555
+ websocket: {
1556
+ open: (ws) => wsHandlers.open(ws),
1557
+ message: (ws, message) => wsHandlers.message(ws, message),
1558
+ close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
1559
+ },
1560
+ }));
1561
+ this.bunServer = running;
1562
+ if (this.gracefulSignals)
1563
+ this.installSignalHandlers();
1564
+ return running;
1565
+ }
1566
+ /**
1567
+ * Gracefully stop: wait for in-flight requests to finish (up to `drainMs`), then
1568
+ * issue a single terminal stop — graceful if everything drained, forced if
1569
+ * stragglers remain. Safe to call when not listening.
1570
+ *
1571
+ * The Bun semantics: poll `pendingRequests` (awaiting
1572
+ * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
1573
+ * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
1574
+ * New connections may be accepted during the drain window; in a real deploy the
1575
+ * load balancer has already stopped routing here, and `drainMs` bounds it.
1576
+ */
1577
+ async stop({ drainMs = DEFAULT_DRAIN_MS } = {}) {
1578
+ const server = this.bunServer;
1579
+ if (server === undefined)
1580
+ return;
1581
+ this.bunServer = undefined;
1582
+ const deadline = Date.now() + drainMs;
1583
+ while (server.pendingRequests > 0 && Date.now() < deadline) {
1584
+ await Bun.sleep(DRAIN_POLL_MS);
1585
+ }
1586
+ server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
1587
+ }
1588
+ installSignalHandlers() {
1589
+ // Drain, then let the process exit naturally — the stopped server no longer
1590
+ // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
1591
+ // signals is consented; we don't force `process.exit`.
1592
+ const onSignal = () => {
1593
+ void this.stop();
1594
+ };
1595
+ process.once("SIGTERM", onSignal);
1596
+ process.once("SIGINT", onSignal);
1597
+ }
1598
+ }
1599
+ /**
1600
+ * Create a new {@link Server}. Pass an `Env` to type the platform bindings — `server<Env>()` makes
1601
+ * `c.env: Env` in every handler + middleware, and types the `env` argument of `app.fetch` /
1602
+ * `toFetchHandler`. Omit it and `c.env` is `unknown` (validate/cast before use).
1603
+ */
1604
+ export function server(options) {
1605
+ // `Env` is a phantom type-level marker: the runtime `env` arrives via `app.fetch(req, { env })` at
1606
+ // request time, not stored on the builder — so seed the context type with a cast (as `derive`/
1607
+ // `decorate` do for their `Ctx` extensions).
1608
+ return new Server(options);
1609
+ }
1610
+ /** The single hub Durable Object id nifra routes WS upgrades to (one hub per app — see `@nifrajs/workers`). */
1611
+ const NIFRA_WS_HUB_ID = "nifra-ws-hub";
1612
+ export function toFetchHandler(app, options) {
1613
+ const scheduled = options?.scheduled;
1614
+ const webSocketHub = options?.webSocketHub;
1615
+ const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
1616
+ return {
1617
+ fetch: (request, env, ctx) => {
1618
+ const platform = { env, waitUntil: (promise) => ctx.waitUntil(promise) };
1619
+ // WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
1620
+ // connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
1621
+ // resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
1622
+ if (webSocketHub !== undefined &&
1623
+ request.headers.get("upgrade")?.toLowerCase() === "websocket") {
1624
+ const ns = webSocketHub(env);
1625
+ return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request);
1626
+ }
1627
+ // Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
1628
+ // Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` — e.g. Deno Deploy
1629
+ // uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
1630
+ const Pair = globalThis.WebSocketPair;
1631
+ if (resolveWs !== undefined && Pair !== undefined) {
1632
+ const accept = (outcome) => {
1633
+ if (outcome.kind === "pass")
1634
+ return app.fetch(request, platform);
1635
+ if (outcome.kind === "reject")
1636
+ return outcome.response;
1637
+ const pair = new Pair();
1638
+ const server = pair[1];
1639
+ server.accept();
1640
+ getWsRuntime().attach(server, outcome.handler, outcome.data, {
1641
+ openNow: true,
1642
+ pubsub: outcome.pubsub,
1643
+ });
1644
+ // `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
1645
+ // 101 status is only valid on the Workers runtime — both gated by the `Pair` feature check.
1646
+ return new Response(null, { status: 101, webSocket: pair[0] });
1647
+ };
1648
+ const out = resolveWs(request, platform);
1649
+ return out instanceof Promise ? out.then(accept) : accept(out);
1650
+ }
1651
+ return app.fetch(request, platform);
1652
+ },
1653
+ ...(scheduled !== undefined
1654
+ ? {
1655
+ scheduled: (controller, env, ctx) => scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
1656
+ }
1657
+ : {}),
1658
+ };
1659
+ }
1660
+ //# sourceMappingURL=server.js.map