@nifrajs/core 2.13.0 → 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 (54) 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/server/plugin.d.ts +1 -1
  30. package/dist/server/plugin.d.ts.map +1 -1
  31. package/dist/server/registry.d.ts +11 -2
  32. package/dist/server/registry.d.ts.map +1 -1
  33. package/dist/server/request-context.js +21 -18
  34. package/dist/server/request-context.js.map +1 -1
  35. package/dist/server/server-types.d.ts +15 -0
  36. package/dist/server/server-types.d.ts.map +1 -1
  37. package/dist/server/server.d.ts +37 -9
  38. package/dist/server/server.d.ts.map +1 -1
  39. package/dist/server/server.js +130 -5
  40. package/dist/server/server.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/binary.ts +35 -0
  43. package/src/durable-adapters.ts +26 -51
  44. package/src/durable-execution.ts +46 -88
  45. package/src/index.ts +1 -1
  46. package/src/internal/durable-approval-protocol.ts +92 -0
  47. package/src/internal/durable-effect-protocol.ts +37 -0
  48. package/src/internal/durable-saga-protocol.ts +55 -0
  49. package/src/internal/route-catalog.ts +6 -0
  50. package/src/server/plugin.ts +1 -1
  51. package/src/server/registry.ts +19 -4
  52. package/src/server/request-context.ts +23 -20
  53. package/src/server/server-types.ts +15 -0
  54. package/src/server/server.ts +171 -9
@@ -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}. */
@@ -228,7 +228,12 @@ import {
228
228
  import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
229
229
  import { jsonLogger, type Logger } from "./logger.ts"
230
230
  import type { McpRuntime } from "./mcp-hook.ts"
231
- import type { ContextPlugin, IdentityPlugin } from "./plugin.ts"
231
+ import type {
232
+ ContextPlugin,
233
+ IdentityPlugin,
234
+ PluginTypeCollapsed,
235
+ ServerTypeUnpinned,
236
+ } from "./plugin.ts"
232
237
  import type {
233
238
  AddRoute,
234
239
  EmptyRegistry,
@@ -576,6 +581,104 @@ export type NifraFeatureVersion = FeatureVersionOf<Version>
576
581
  const IDENTITY_RESPONSE = (response: Response): Response => response
577
582
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
578
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
+
579
682
  /**
580
683
  * The inline server. Routes are chainable and fully type-inferred. `derive`/
581
684
  * `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
@@ -744,6 +847,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
744
847
  this.defaultOnValidationError = options.onValidationError
745
848
  this.bunServer = undefined
746
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
+ }
747
855
  this.derives = []
748
856
  this.decorations = {}
749
857
  this.beforeHandleHooks = []
@@ -812,17 +920,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
812
920
  return this
813
921
  }
814
922
 
815
- /** 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
+ */
816
929
  derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
817
930
  this.assertConfigurable("derive()")
818
931
  this.derives.push(fn as unknown as RawDerive)
932
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
933
+ recordScopedHook(this, this.catalog.size, "derive")
819
934
  return this as unknown as Server<R, Ctx & D>
820
935
  }
821
936
 
822
- /** 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
+ */
823
942
  decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
824
943
  this.assertConfigurable("decorate()")
825
944
  this.decorations[key] = value
945
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
946
+ recordScopedHook(this, this.catalog.size, "decorate")
826
947
  return this as unknown as Server<R, Ctx & Record<K, V>>
827
948
  }
828
949
 
@@ -840,10 +961,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
840
961
  return this
841
962
  }
842
963
 
843
- /** 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
+ */
844
969
  beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
845
970
  this.assertConfigurable("beforeHandle()")
846
971
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
972
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
973
+ recordScopedHook(this, this.catalog.size, "beforeHandle")
847
974
  return this
848
975
  }
849
976
 
@@ -851,10 +978,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
851
978
  * Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
852
979
  * route output, so wrappers like async context storage do not force Node's direct JSON path through
853
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}).
854
984
  */
855
985
  around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
856
986
  this.assertConfigurable("around()")
857
987
  this.aroundHooks.push(fn as unknown as RawAround)
988
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
989
+ recordScopedHook(this, this.catalog.size, "around")
858
990
  return this
859
991
  }
860
992
 
@@ -879,6 +1011,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
879
1011
  this.capabilityInterceptors.push(
880
1012
  Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
881
1013
  )
1014
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1015
+ recordScopedHook(this, this.catalog.size, "aroundCapability")
882
1016
  return this
883
1017
  }
884
1018
 
@@ -892,17 +1026,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
892
1026
  return this
893
1027
  }
894
1028
 
895
- /** 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}). */
896
1032
  afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
897
1033
  this.assertConfigurable("afterHandle()")
898
1034
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1035
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1036
+ recordScopedHook(this, this.catalog.size, "afterHandle")
899
1037
  return this
900
1038
  }
901
1039
 
902
- /** 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}). */
903
1043
  onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
904
1044
  this.assertConfigurable("onError()")
905
1045
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1046
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1047
+ recordScopedHook(this, this.catalog.size, "onError")
906
1048
  return this
907
1049
  }
908
1050
 
@@ -1059,13 +1201,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1059
1201
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
1060
1202
  * the added context to handlers defined after `use` (the overload is generic over the concrete
1061
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.
1062
1211
  */
1063
- 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
1064
1215
  /**
1065
1216
  * Apply a {@link Middleware} bundle - wire each hook it provides to its lifecycle point. Returns
1066
1217
  * `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
1067
- * should cover (those are order-scoped; `onRequest`/`onResponse` are global). A named bundle already
1068
- * 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).
1069
1221
  */
1070
1222
  use(mw: Middleware): this
1071
1223
  use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
@@ -2027,6 +2179,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2027
2179
  source: RequestSource,
2028
2180
  platform?: Platform<EnvOf<Ctx>>,
2029
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)
2030
2186
  // Off path (default): straight through - one property check, no closure, no promise.
2031
2187
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
2032
2188
  return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
@@ -2151,6 +2307,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2151
2307
  platform?: Platform<EnvOf<Ctx>>,
2152
2308
  suppliedRuntime?: NodeOutcomeRuntime,
2153
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)
2154
2313
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
2155
2314
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
2156
2315
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -4873,6 +5032,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4873
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.).",
4874
5033
  )
4875
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)
4876
5038
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
4877
5039
  // subset so the public types don't depend on the ambient `Bun` global. The cast
4878
5040
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix