@nifrajs/core 2.12.1 → 2.14.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 (70) hide show
  1. package/dist/binary.d.ts +32 -0
  2. package/dist/binary.d.ts.map +1 -1
  3. package/dist/binary.js +17 -0
  4. package/dist/binary.js.map +1 -1
  5. package/dist/durable-adapters.d.ts.map +1 -1
  6. package/dist/durable-adapters.js +21 -50
  7. package/dist/durable-adapters.js.map +1 -1
  8. package/dist/durable-execution.d.ts.map +1 -1
  9. package/dist/durable-execution.js +49 -69
  10. package/dist/durable-execution.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/internal/durable-approval-protocol.d.ts +13 -0
  14. package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
  15. package/dist/internal/durable-approval-protocol.js +66 -0
  16. package/dist/internal/durable-approval-protocol.js.map +1 -0
  17. package/dist/internal/durable-effect-protocol.d.ts +14 -0
  18. package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
  19. package/dist/internal/durable-effect-protocol.js +16 -0
  20. package/dist/internal/durable-effect-protocol.js.map +1 -0
  21. package/dist/internal/durable-saga-protocol.d.ts +8 -0
  22. package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
  23. package/dist/internal/durable-saga-protocol.js +21 -0
  24. package/dist/internal/durable-saga-protocol.js.map +1 -0
  25. package/dist/internal/route-catalog.d.ts +3 -0
  26. package/dist/internal/route-catalog.d.ts.map +1 -1
  27. package/dist/internal/route-catalog.js +5 -0
  28. package/dist/internal/route-catalog.js.map +1 -1
  29. package/dist/range.d.ts.map +1 -1
  30. package/dist/range.js +4 -2
  31. package/dist/range.js.map +1 -1
  32. package/dist/server/body.d.ts +5 -0
  33. package/dist/server/body.d.ts.map +1 -1
  34. package/dist/server/body.js +26 -9
  35. package/dist/server/body.js.map +1 -1
  36. package/dist/server/plugin.d.ts +1 -1
  37. package/dist/server/plugin.d.ts.map +1 -1
  38. package/dist/server/registry.d.ts +11 -2
  39. package/dist/server/registry.d.ts.map +1 -1
  40. package/dist/server/request-context.d.ts +12 -0
  41. package/dist/server/request-context.d.ts.map +1 -1
  42. package/dist/server/request-context.js +132 -20
  43. package/dist/server/request-context.js.map +1 -1
  44. package/dist/server/respond.d.ts +1 -0
  45. package/dist/server/respond.d.ts.map +1 -1
  46. package/dist/server/respond.js +1 -1
  47. package/dist/server/respond.js.map +1 -1
  48. package/dist/server/server-types.d.ts +15 -0
  49. package/dist/server/server-types.d.ts.map +1 -1
  50. package/dist/server/server.d.ts +43 -9
  51. package/dist/server/server.d.ts.map +1 -1
  52. package/dist/server/server.js +131 -6
  53. package/dist/server/server.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/binary.ts +35 -0
  56. package/src/durable-adapters.ts +26 -51
  57. package/src/durable-execution.ts +46 -88
  58. package/src/index.ts +1 -1
  59. package/src/internal/durable-approval-protocol.ts +92 -0
  60. package/src/internal/durable-effect-protocol.ts +37 -0
  61. package/src/internal/durable-saga-protocol.ts +55 -0
  62. package/src/internal/route-catalog.ts +6 -0
  63. package/src/range.ts +3 -1
  64. package/src/server/body.ts +25 -8
  65. package/src/server/plugin.ts +1 -1
  66. package/src/server/registry.ts +19 -4
  67. package/src/server/request-context.ts +138 -22
  68. package/src/server/respond.ts +1 -1
  69. package/src/server/server-types.ts +15 -0
  70. package/src/server/server.ts +182 -10
@@ -1,3 +1,4 @@
1
+ import type { BinaryResponse, RawResponse } from "../binary.ts"
1
2
  import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
2
3
  import type { Params, RouteSchema } from "./context.ts"
3
4
 
@@ -79,12 +80,26 @@ export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
79
80
  readonly sse: RegistrySse<S>
80
81
  }
81
82
 
82
- /** The client-visible output of a handler: its awaited return, minus raw `Response`. */
83
- export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
84
- Awaited<ReturnType<H>>,
85
- Response
83
+ /**
84
+ * The client-visible output of a handler: its awaited return, with an UNbranded raw `Response` removed
85
+ * (a route returning a bare `Response` has no describable body). A response branded by `bytes()`
86
+ * ({@link BinaryResponse}) or `raw()` ({@link RawResponse}) is KEPT, so the brand reaches the client's
87
+ * `Jsonify` and types `data` as `Blob` / `Jsonify<T>` rather than collapsing to `never`.
88
+ */
89
+ export type OutputOf<H extends (...args: never[]) => unknown> = KeepBrandedResponse<
90
+ Awaited<ReturnType<H>>
86
91
  >
87
92
 
93
+ /** Preserve a `bytes()`/`raw()`-branded response; drop only a bare `Response`. Distributes over a
94
+ * union return, so `Foo | BinaryResponse` keeps both arms. */
95
+ type KeepBrandedResponse<O> = O extends BinaryResponse
96
+ ? O
97
+ : O extends RawResponse<unknown>
98
+ ? O
99
+ : O extends Response
100
+ ? never
101
+ : O
102
+
88
103
  /**
89
104
  * The registry entry for a WebSocket route (stored under the pseudo-method key `"WS"`, so WS routes
90
105
  * ride the same path → method map as HTTP routes - no extra Server generic). `in` is the client →
@@ -19,6 +19,7 @@ import { headerObjectOf } from "./headers.ts"
19
19
  import { jsonError } from "./http.ts"
20
20
  import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
21
21
  import { searchOf } from "./query.ts"
22
+ import { responseJsonContentType } from "./respond.ts"
22
23
  import {
23
24
  CONTEXT_SEARCH,
24
25
  CONTEXT_SET,
@@ -88,6 +89,80 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
88
89
  return typeof init === "number" ? { status: init } : init
89
90
  }
90
91
 
92
+ /**
93
+ * Whether a body built here should be deferred instead of turned into a `Response` on the spot.
94
+ *
95
+ * Only the Node bridge can use a deferred body: it writes status + headers + bytes straight to the
96
+ * socket, so the undici `Response` those two helpers used to build (~2us, about a quarter of the
97
+ * request budget on a small text response) was pure loss. Bun and Deno hand the `Response` to their
98
+ * native server, where nothing reads the deferral. Probed once at module load, not per response.
99
+ */
100
+ const DEFERS_RESPONSE =
101
+ typeof (globalThis as { Deno?: unknown }).Deno === "undefined" &&
102
+ typeof (globalThis as { Bun?: unknown }).Bun === "undefined" &&
103
+ typeof (globalThis as { process?: { versions?: { node?: unknown } } }).process?.versions?.node ===
104
+ "string"
105
+
106
+ /**
107
+ * The Node adapter's deferred-response factory, received over the shared-symbol seam rather than a
108
+ * package import - `@nifrajs/node` stays dependency-free from core's runtime and talks to it only
109
+ * through `Symbol.for` marks, so the stand-in it builds (and the ~0.4 KB gzip the class costs) never
110
+ * ships in a Bun/Deno bundle that will never run this branch. `c.json`/`c.text` route the Node fast
111
+ * lane through it: given a status, an owned header record, and the bytes, it returns a `Response`-shaped
112
+ * value the bridge writes straight to the socket (no undici `Response` built, no body stream drained).
113
+ * Absent - no adapter loaded - the two helpers fall back to a real `Response`. Cached on first read;
114
+ * the adapter registers at its own module load, before any request reaches a handler.
115
+ */
116
+ const DEFERRED_RESPONDER_KEY = Symbol.for("nifra.deferred.responder")
117
+ type DeferredResponder = (body: string, status: number, headers: Record<string, string>) => Response
118
+ let deferredResponder: DeferredResponder | undefined
119
+ function deferredResponderFor(): DeferredResponder | undefined {
120
+ if (deferredResponder === undefined) {
121
+ deferredResponder = (globalThis as unknown as Record<symbol, DeferredResponder | undefined>)[
122
+ DEFERRED_RESPONDER_KEY
123
+ ]
124
+ }
125
+ return deferredResponder
126
+ }
127
+
128
+ /**
129
+ * A header record the deferred response can own outright - a fresh object with lowercase names, since
130
+ * the Node writer mutates it in place to declare `content-length`. `undefined` means the caller's
131
+ * shape (a `Headers`, an entries array) is not worth walking here: those take the `Response` path.
132
+ */
133
+ function ownHeaderRecord(
134
+ // `unknown`: the lib's `ResponseInit.headers` is a runtime-specific `HeadersInit` (Bun's vs
135
+ // undici's differ), and this only needs to recognize the plain-record case anyway.
136
+ headers: unknown,
137
+ contentType: string,
138
+ ): Record<string, string> | undefined {
139
+ if (headers === undefined) return { "content-type": contentType }
140
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return undefined
141
+ if (headers instanceof Headers) return undefined
142
+ const record: Record<string, string> = {}
143
+ let hasContentType = false
144
+ for (const name of Object.keys(headers)) {
145
+ const lower = name.toLowerCase()
146
+ if (lower === "content-type") hasContentType = true
147
+ record[lower] = (headers as Record<string, string>)[name] as string
148
+ }
149
+ if (!hasContentType) record["content-type"] = contentType
150
+ return record
151
+ }
152
+
153
+ /** The content-type the runtime's own `Response` puts on a string body, read off it once. A deferred
154
+ * text response fills it in itself, so it must fill in exactly what the `Response` would have. */
155
+ let stringBodyContentType: string | undefined
156
+ function responseTextContentType(): string {
157
+ stringBodyContentType ??=
158
+ new Response("").headers.get("content-type") ?? "text/plain;charset=UTF-8"
159
+ return stringBodyContentType
160
+ }
161
+
162
+ /** `c.text`'s own default, unchanged: the explicit one nifra has always declared when the caller
163
+ * passes no headers of their own. */
164
+ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
165
+
91
166
  export class RequestContext implements RawContext {
92
167
  // `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
93
168
  // constructor initializes only the eager request state, while lazy fields remain absent until used.
@@ -234,15 +309,53 @@ export class RequestContext implements RawContext {
234
309
  this.headersValue = value
235
310
  }
236
311
 
312
+ /**
313
+ * Return a JSON body. Prefer this (and {@link text}) over a hand-rolled `return new Response(...)`:
314
+ * on Node these helpers hand the adapter a status, a header record, and the bytes directly, so the
315
+ * Web `Response` is deferred and the reply lands on the fastest write lane. A raw `new Response` is
316
+ * fully supported and returns identical bytes, but it is the last-resort lane - the adapter has to
317
+ * drain its body stream, which no direct-write helper pays.
318
+ */
237
319
  json(body: unknown, init?: ResponseInit | number): Response {
238
- return Response.json(body, statusInit(init))
320
+ const i = statusInit(init)
321
+ if (!DEFERS_RESPONSE) return Response.json(body, i)
322
+ const defer = deferredResponderFor()
323
+ if (defer === undefined) return Response.json(body, i)
324
+ // The same bytes `Response.json` would produce, kept one step short of it so the direct writer
325
+ // can have them. `JSON.stringify` returns `undefined` for a value with no JSON form (`undefined`,
326
+ // a function, a symbol) - exactly the case where `Response.json` throws, so hand it back the
327
+ // throw rather than inventing a body. The content-type is the runtime's own, read off
328
+ // `Response.json` once, so a deferred response carries the same one byte for byte.
329
+ const text = JSON.stringify(body) as string | undefined
330
+ if (text === undefined) return Response.json(body, i)
331
+ const headers = ownHeaderRecord(i?.headers, responseJsonContentType())
332
+ if (headers === undefined) return Response.json(body, i)
333
+ return defer(text, i?.status ?? 200, headers)
239
334
  }
240
335
 
336
+ /**
337
+ * Return a text body. The fast lane on Node - see {@link json}. Reach for a raw `return new
338
+ * Response(body)` only when you need a Response shape these helpers do not build; it costs the
339
+ * adapter a body-stream drain that this path skips.
340
+ */
241
341
  text(body: string, init?: ResponseInit | number): Response {
242
342
  const i = statusInit(init)
343
+ if (DEFERS_RESPONSE) {
344
+ const defer = deferredResponderFor()
345
+ if (defer !== undefined) {
346
+ // The content-type each shape would have ended up with: nifra's declared default when the
347
+ // caller brought no headers, the runtime's own string-body default when they did (there the
348
+ // caller owns the content-type, and only fills in for one they did not set).
349
+ const headers = ownHeaderRecord(
350
+ i?.headers,
351
+ i?.headers === undefined ? TEXT_CONTENT_TYPE : responseTextContentType(),
352
+ )
353
+ if (headers !== undefined) return defer(body, i?.status ?? 200, headers)
354
+ }
355
+ }
243
356
  // Default to text/plain; if the caller passes their own headers, they own the content-type.
244
357
  if (i?.headers !== undefined) return new Response(body, i)
245
- return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } })
358
+ return new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } })
246
359
  }
247
360
 
248
361
  get query(): unknown {
@@ -386,31 +499,34 @@ export function readBoundedJsonSource<T>(
386
499
  return onResult === undefined
387
500
  ? Promise.resolve(jsonError(413, "payload_too_large"))
388
501
  : Promise.resolve(jsonError(413, "payload_too_large")).then(onResult)
502
+ // Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
503
+ // delimited the bytes, so the declared length IS the transport frame and chunked routing is
504
+ // moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
505
+ // decode+parse straight away, before the transfer-encoding probe that only exists to route
506
+ // *untrusted* Node/adapter sources to the streaming lane. Skipping that probe drops one
507
+ // `Headers.get()` from every Bun/Deno JSON POST; Node/adapter sources are never marked and fall
508
+ // through below. The parsed-value poisoning walk still applies.
509
+ if (hasTrustedBodyFraming(req)) {
510
+ return raw.json().then(
511
+ (parsed) => {
512
+ let guarded: unknown
513
+ try {
514
+ guarded = guardParsedValue(parsed, protoPoisoning)
515
+ } catch {
516
+ return onResult === undefined
517
+ ? jsonError(400, "invalid_json")
518
+ : onResult(jsonError(400, "invalid_json"))
519
+ }
520
+ return onResult === undefined ? guarded : onResult(guarded)
521
+ },
522
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
523
+ )
524
+ }
389
525
  const chunked =
390
526
  (sourceHeader === undefined
391
527
  ? req.headers.get("transfer-encoding")
392
528
  : sourceHeader.call(req, "transfer-encoding")) !== null
393
529
  if (!chunked) {
394
- // Bun's compiled native route table receives a Request whose bytes were already delimited by
395
- // Bun's HTTP parser. Keep the exact-byte path for every portable/adapted source, but preserve
396
- // Bun's fused decode+parse on this trusted ingress; the declared length is the transport frame
397
- // there, not an attacker-controlled hint. The parsed-value poisoning walk still applies.
398
- if (hasTrustedBodyFraming(req)) {
399
- return raw.json().then(
400
- (parsed) => {
401
- let guarded: unknown
402
- try {
403
- guarded = guardParsedValue(parsed, protoPoisoning)
404
- } catch {
405
- return onResult === undefined
406
- ? jsonError(400, "invalid_json")
407
- : onResult(jsonError(400, "invalid_json"))
408
- }
409
- return onResult === undefined ? guarded : onResult(guarded)
410
- },
411
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
412
- )
413
- }
414
530
  const jsonWithByteLength = raw.jsonWithByteLength
415
531
  if (jsonWithByteLength !== undefined) {
416
532
  return jsonWithByteLength.call(raw).then(
@@ -150,7 +150,7 @@ function withStaticHeaderInit(
150
150
  const IS_DENO = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
151
151
  let denoJsonContentType: string | undefined
152
152
 
153
- function responseJsonContentType(): string {
153
+ export function responseJsonContentType(): string {
154
154
  if (denoJsonContentType === undefined) {
155
155
  denoJsonContentType = Response.json(0).headers.get("content-type") ?? "application/json"
156
156
  }
@@ -144,6 +144,21 @@ export interface ServerOptions {
144
144
  * Fastify's `setErrorHandler`) instead of repeating a formatter per route.
145
145
  */
146
146
  readonly onValidationError?: RouteSchema["onValidationError"]
147
+ /**
148
+ * What to do when an order-scoped hook (`derive`/`beforeHandle`/`around`/`aroundCapability`/
149
+ * `afterHandle`/`onError`/`decorate`) covers no route because it was added after the last route was
150
+ * registered. `register()` snapshots the current chain into each route as it is declared, so a hook
151
+ * appended after every route reaches nothing and is silently dead. `"warn"` (default) logs once at
152
+ * seal (first `listen()`/`fetch`/`resolveNode`); `"error"` throws a `FrameworkError`
153
+ * (`UNUSED_SCOPED_HOOKS`); `"off"` skips the bookkeeping entirely, including the one-frame stack
154
+ * capture. App-global hooks (`onRequest`/`onResponse`/...) are unaffected in every mode.
155
+ *
156
+ * This is a development-time diagnostic: the whole audit is guarded by `process.env.NODE_ENV !==
157
+ * "production"`, so any bundler that defines `NODE_ENV` (vite/esbuild/webpack/next, `bun build
158
+ * --production`) dead-code-eliminates it, leaving zero bytes and zero per-request cost in the shipped
159
+ * bundle. It fires in development, test, and CI - where you catch the mistake - not in production.
160
+ */
161
+ readonly unusedScopedHooks?: "warn" | "error" | "off"
147
162
  }
148
163
 
149
164
  /** A fetch-compatible handler used by {@link Server.mountFetch}. */
@@ -49,7 +49,12 @@ import type {
49
49
  StandardResult,
50
50
  StandardSchemaV1,
51
51
  } from "../schema/standard.ts"
52
- import { assertByteLimit, markTransportCap, markTrustedBodyFraming } from "./body.ts"
52
+ import {
53
+ assertByteLimit,
54
+ markTransportCap,
55
+ markTrustedBodyFraming,
56
+ type RawBodyReaders,
57
+ } from "./body.ts"
53
58
  import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
54
59
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
55
60
  import {
@@ -223,7 +228,12 @@ import {
223
228
  import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
224
229
  import { jsonLogger, type Logger } from "./logger.ts"
225
230
  import type { McpRuntime } from "./mcp-hook.ts"
226
- import type { ContextPlugin, IdentityPlugin } from "./plugin.ts"
231
+ import type {
232
+ ContextPlugin,
233
+ IdentityPlugin,
234
+ PluginTypeCollapsed,
235
+ ServerTypeUnpinned,
236
+ } from "./plugin.ts"
227
237
  import type {
228
238
  AddRoute,
229
239
  EmptyRegistry,
@@ -285,6 +295,11 @@ export interface RequestSource {
285
295
  /** Present only when materializing a `Request` is non-trivial (the Node lazy source); for a real
286
296
  * `Request` passed as the source it's absent and {@link requestOf} returns the source itself. */
287
297
  readonly request?: Request
298
+ /** Optional pre-`Request` reader surface for the transport cap. A source that reads its own
299
+ * transport bytes (the Node lazy source reads the socket) exposes them here so a capped direct
300
+ * read (`c.req.json()`) buffers off the transport instead of routing through the deferred
301
+ * `Request`. `body` must stay the live stream - the cap still guards a chunked body mid-flight. */
302
+ rawBodyReaders?(): RawBodyReaders
288
303
  }
289
304
 
290
305
  /** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
@@ -566,6 +581,104 @@ export type NifraFeatureVersion = FeatureVersionOf<Version>
566
581
  const IDENTITY_RESPONSE = (response: Response): Response => response
567
582
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
568
583
 
584
+ // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
585
+ // diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
586
+ // branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
587
+ // define only dead-code-eliminates the audit when the literal appears at the `if`; Bun does not
588
+ // propagate a module const's value into the branch. Every real production bundler (vite/esbuild/webpack/
589
+ // next, `bun build --production`) injects that define, turning each guard into `hookAuditRuntime &&
590
+ // false`, which folds away - dropping the whole audit and restoring the bare kernel size.
591
+ //
592
+ // `hookAuditRuntime` is the one shared flag: `typeof globalThis.Deno === "undefined"`. Deno ships a
593
+ // node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
594
+ // throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
595
+ // off) while a production define still folds `<flag> && false` to `false` on every other runtime.
596
+ const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
597
+
598
+ // This module's own path, captured once from a sentinel stack. Every internal frame of an
599
+ // order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
600
+ // so `captureCallerSite` skips anything starting with it and lands on the user's calling frame.
601
+ const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
602
+ const frame = new Error().stack?.split("\n")[1]
603
+ return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
604
+ })()
605
+
606
+ /** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
607
+ * report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
608
+ * non-trivial cost) is never constructed when the check is disabled. */
609
+ const captureCallerSite = (): string | undefined => {
610
+ const stack = new Error().stack
611
+ if (stack === undefined) return undefined
612
+ for (const line of stack.split("\n").slice(1)) {
613
+ const frame = line.match(/\(?([^()\s]+:\d+:\d+)\)?\s*$/)?.[1]
614
+ if (frame === undefined) continue
615
+ if (SERVER_MODULE_PATH !== undefined && frame.startsWith(`${SERVER_MODULE_PATH}:`)) continue
616
+ return frame
617
+ }
618
+ return undefined
619
+ }
620
+
621
+ /** Per-server bookkeeping for the unused-order-scoped-hook audit. Deliberately NOT `Server` fields or
622
+ * methods: those emit class members (a field declaration, a method shell) that a bundler cannot remove
623
+ * even after their bodies fold away. Held here in a module WeakMap and reached only through the
624
+ * functions below, every call guarded by `process.env.NODE_ENV !== "production"`, so a production define
625
+ * folds the guards away, leaves this map and these functions unreferenced, and the bundler eliminates
626
+ * the whole audit - restoring the bare kernel size. */
627
+ interface HookAuditState {
628
+ readonly policy: "warn" | "error" | "off"
629
+ readonly hasCustomLogger: boolean
630
+ readonly sites: {
631
+ readonly kind: string
632
+ readonly routesAtPush: number
633
+ readonly site: string | undefined
634
+ }[]
635
+ checked: boolean
636
+ }
637
+ const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
638
+
639
+ /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
640
+ const beginHookAudit = (
641
+ key: object,
642
+ policy: "warn" | "error" | "off",
643
+ hasCustomLogger: boolean,
644
+ ): void => {
645
+ hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false })
646
+ }
647
+
648
+ /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
649
+ * seal means no route was declared after it) and the caller's frame. */
650
+ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void => {
651
+ const state = hookAudit.get(key)
652
+ if (state === undefined || state.policy === "off") return
653
+ state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
654
+ }
655
+
656
+ /** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
657
+ * had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
658
+ * only later routes) always leaves at least one such route. Zero-route apps are exempt (different
659
+ * mistake, and the warning would be noise). */
660
+ const sealHookAudit = (
661
+ key: object,
662
+ total: number,
663
+ logger: { warn(message: string): void },
664
+ ): void => {
665
+ const state = hookAudit.get(key)
666
+ if (state === undefined || state.checked) return
667
+ state.checked = true
668
+ if (state.policy === "off" || total === 0) return
669
+ const dead = state.sites.filter((hook) => hook.routesAtPush === total)
670
+ if (dead.length === 0) return
671
+ const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
672
+ const message =
673
+ `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
674
+ `last route was registered, and register() snapshots the chain into each route as it is ` +
675
+ `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
676
+ `(onRequest/onResponse) are not order-scoped and can be added at any time.`
677
+ if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
678
+ if (state.hasCustomLogger) logger.warn(message)
679
+ else console.warn(message)
680
+ }
681
+
569
682
  /**
570
683
  * The inline server. Routes are chainable and fully type-inferred. `derive`/
571
684
  * `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
@@ -734,6 +847,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
734
847
  this.defaultOnValidationError = options.onValidationError
735
848
  this.bunServer = undefined
736
849
  this.sealed = false
850
+ // Guarded so a production define strips the call, leaving `beginHookAudit`/`hookAudit` unreferenced
851
+ // for the bundler to eliminate; a non-bundled production run skips it the same way.
852
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
853
+ beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
854
+ }
737
855
  this.derives = []
738
856
  this.decorations = {}
739
857
  this.beforeHandleHooks = []
@@ -802,17 +920,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
802
920
  return this
803
921
  }
804
922
 
805
- /** Add a per-request, computed context extension for subsequent routes. */
923
+ /**
924
+ * Add a per-request, computed context extension. Order-scoped: it is snapshotted into every route
925
+ * declared *after* this call, and reaches none declared before. One added after the last route
926
+ * (e.g. on the app returned by a route-registering factory) covers nothing - see
927
+ * {@link ServerOptions.unusedScopedHooks}.
928
+ */
806
929
  derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
807
930
  this.assertConfigurable("derive()")
808
931
  this.derives.push(fn as unknown as RawDerive)
932
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
933
+ recordScopedHook(this, this.catalog.size, "derive")
809
934
  return this as unknown as Server<R, Ctx & D>
810
935
  }
811
936
 
812
- /** Add a static context value for subsequent routes. */
937
+ /**
938
+ * Add a static context value. Order-scoped: captured by every route declared *after* this call, and
939
+ * by none before; one added after the last route reaches nothing
940
+ * (see {@link ServerOptions.unusedScopedHooks}).
941
+ */
813
942
  decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
814
943
  this.assertConfigurable("decorate()")
815
944
  this.decorations[key] = value
945
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
946
+ recordScopedHook(this, this.catalog.size, "decorate")
816
947
  return this as unknown as Server<R, Ctx & Record<K, V>>
817
948
  }
818
949
 
@@ -830,10 +961,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
830
961
  return this
831
962
  }
832
963
 
833
- /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
964
+ /**
965
+ * Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped:
966
+ * it is snapshotted into every route declared *after* this call and covers none declared before, so
967
+ * one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
968
+ */
834
969
  beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
835
970
  this.assertConfigurable("beforeHandle()")
836
971
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
972
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
973
+ recordScopedHook(this, this.catalog.size, "beforeHandle")
837
974
  return this
838
975
  }
839
976
 
@@ -841,10 +978,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
841
978
  * Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
842
979
  * route output, so wrappers like async context storage do not force Node's direct JSON path through
843
980
  * a Web `Response`. The first registered wrapper is outermost.
981
+ *
982
+ * Order-scoped: captured by every route declared *after* this call, none before; one added after the
983
+ * last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
844
984
  */
845
985
  around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
846
986
  this.assertConfigurable("around()")
847
987
  this.aroundHooks.push(fn as unknown as RawAround)
988
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
989
+ recordScopedHook(this, this.catalog.size, "around")
848
990
  return this
849
991
  }
850
992
 
@@ -869,6 +1011,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
869
1011
  this.capabilityInterceptors.push(
870
1012
  Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
871
1013
  )
1014
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1015
+ recordScopedHook(this, this.catalog.size, "aroundCapability")
872
1016
  return this
873
1017
  }
874
1018
 
@@ -882,17 +1026,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
882
1026
  return this
883
1027
  }
884
1028
 
885
- /** Transform the handler's result before it is serialized. Order-scoped. */
1029
+ /** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
1030
+ * *after* this call, none before; one added after the last route reaches nothing (see
1031
+ * {@link ServerOptions.unusedScopedHooks}). */
886
1032
  afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
887
1033
  this.assertConfigurable("afterHandle()")
888
1034
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1035
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1036
+ recordScopedHook(this, this.catalog.size, "afterHandle")
889
1037
  return this
890
1038
  }
891
1039
 
892
- /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
1040
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
1041
+ * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
1042
+ * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
893
1043
  onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
894
1044
  this.assertConfigurable("onError()")
895
1045
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1046
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1047
+ recordScopedHook(this, this.catalog.size, "onError")
896
1048
  return this
897
1049
  }
898
1050
 
@@ -1049,13 +1201,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1049
1201
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
1050
1202
  * the added context to handlers defined after `use` (the overload is generic over the concrete
1051
1203
  * `this`). A named plugin already applied is skipped (idempotent dedupe).
1204
+ *
1205
+ * If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
1206
+ * `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
1207
+ * returns the non-callable {@link PluginTypeCollapsed} instead of silently widening the whole typed
1208
+ * client to `any`. `.get()`/`.post()` then fail right here rather than surfacing as `any` hundreds of
1209
+ * lines away. Fix at the plugin: build it with {@link defineIdentityPlugin}/{@link defineContextPlugin},
1210
+ * or pin its input server type. This mirrors the guard {@link definePlugin} already applies.
1052
1211
  */
1053
- use<Out extends AnyServer>(plugin: (app: this) => Out): Out
1212
+ use<Out extends AnyServer>(
1213
+ plugin: (app: this) => Out,
1214
+ ): [ServerTypeUnpinned<Out>] extends [true] ? PluginTypeCollapsed : Out
1054
1215
  /**
1055
1216
  * Apply a {@link Middleware} bundle - wire each hook it provides to its lifecycle point. Returns
1056
1217
  * `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
1057
- * should cover (those are order-scoped; `onRequest`/`onResponse` are global). A named bundle already
1058
- * applied is skipped (idempotent).
1218
+ * should cover (those are order-scoped; `onRequest`/`onResponse` are global). A bundle applied after
1219
+ * the last route reaches nothing - {@link ServerOptions.unusedScopedHooks} logs that at seal. A named
1220
+ * bundle already applied is skipped (idempotent).
1059
1221
  */
1060
1222
  use(mw: Middleware): this
1061
1223
  use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
@@ -2017,6 +2179,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2017
2179
  source: RequestSource,
2018
2180
  platform?: Platform<EnvOf<Ctx>>,
2019
2181
  ): MaybePromise<Response> {
2182
+ // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
2183
+ // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
2184
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2185
+ sealHookAudit(this, this.catalog.size, this.logger)
2020
2186
  // Off path (default): straight through - one property check, no closure, no promise.
2021
2187
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
2022
2188
  return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
@@ -2141,6 +2307,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2141
2307
  platform?: Platform<EnvOf<Ctx>>,
2142
2308
  suppliedRuntime?: NodeOutcomeRuntime,
2143
2309
  ): MaybePromise<NodeServeOutcome> {
2310
+ // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
2311
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2312
+ sealHookAudit(this, this.catalog.size, this.logger)
2144
2313
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
2145
2314
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
2146
2315
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -4863,6 +5032,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4863
5032
  "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.).",
4864
5033
  )
4865
5034
  }
5035
+ // Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
5036
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
5037
+ sealHookAudit(this, this.catalog.size, this.logger)
4866
5038
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
4867
5039
  // subset so the public types don't depend on the ambient `Bun` global. The cast
4868
5040
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix