@nifrajs/core 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +5 -0
  3. package/dist/binary.d.ts +15 -1
  4. package/dist/binary.d.ts.map +1 -1
  5. package/dist/binary.js +1 -0
  6. package/dist/binary.js.map +1 -1
  7. package/dist/edge-kit.d.ts +1 -0
  8. package/dist/edge-kit.d.ts.map +1 -1
  9. package/dist/edge-kit.js +1 -0
  10. package/dist/edge-kit.js.map +1 -1
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js.map +1 -1
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.d.ts.map +1 -1
  16. package/dist/evidence.js +2 -0
  17. package/dist/evidence.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/hook-origins.d.ts +2 -0
  23. package/dist/internal/hook-origins.d.ts.map +1 -1
  24. package/dist/internal/hook-origins.js +12 -0
  25. package/dist/internal/hook-origins.js.map +1 -1
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +12 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/server/answer-step.d.ts +27 -0
  31. package/dist/server/answer-step.d.ts.map +1 -0
  32. package/dist/server/answer-step.js +33 -0
  33. package/dist/server/answer-step.js.map +1 -0
  34. package/dist/server/base64-answers.d.ts +5 -0
  35. package/dist/server/base64-answers.d.ts.map +1 -0
  36. package/dist/server/base64-answers.js +132 -0
  37. package/dist/server/base64-answers.js.map +1 -0
  38. package/dist/server/core-copies.d.ts.map +1 -1
  39. package/dist/server/core-copies.js +5 -2
  40. package/dist/server/core-copies.js.map +1 -1
  41. package/dist/server/install.d.ts +2 -0
  42. package/dist/server/install.d.ts.map +1 -1
  43. package/dist/server/install.js +2 -0
  44. package/dist/server/install.js.map +1 -1
  45. package/dist/server/json-response.d.ts +3 -0
  46. package/dist/server/json-response.d.ts.map +1 -0
  47. package/dist/server/json-response.js +14 -0
  48. package/dist/server/json-response.js.map +1 -0
  49. package/dist/server/json-text.d.ts +16 -0
  50. package/dist/server/json-text.d.ts.map +1 -0
  51. package/dist/server/json-text.js +34 -0
  52. package/dist/server/json-text.js.map +1 -0
  53. package/dist/server/node-outcome.d.ts +5 -0
  54. package/dist/server/node-outcome.d.ts.map +1 -1
  55. package/dist/server/node-outcome.js +3 -4
  56. package/dist/server/node-outcome.js.map +1 -1
  57. package/dist/server/request-context.d.ts.map +1 -1
  58. package/dist/server/request-context.js +4 -2
  59. package/dist/server/request-context.js.map +1 -1
  60. package/dist/server/respond.d.ts +2 -1
  61. package/dist/server/respond.d.ts.map +1 -1
  62. package/dist/server/respond.js +27 -24
  63. package/dist/server/respond.js.map +1 -1
  64. package/dist/server/response-contract-lane.d.ts.map +1 -1
  65. package/dist/server/response-contract-lane.js +2 -1
  66. package/dist/server/response-contract-lane.js.map +1 -1
  67. package/dist/server/route-template.d.ts +10 -0
  68. package/dist/server/route-template.d.ts.map +1 -0
  69. package/dist/server/route-template.js +21 -0
  70. package/dist/server/route-template.js.map +1 -0
  71. package/dist/server/runtime-core.d.ts.map +1 -1
  72. package/dist/server/runtime-core.js +2 -1
  73. package/dist/server/runtime-core.js.map +1 -1
  74. package/dist/server/server-types.d.ts +14 -6
  75. package/dist/server/server-types.d.ts.map +1 -1
  76. package/dist/server/server.d.ts +21 -10
  77. package/dist/server/server.d.ts.map +1 -1
  78. package/dist/server/server.js +94 -37
  79. package/dist/server/server.js.map +1 -1
  80. package/dist/server/sse.d.ts.map +1 -1
  81. package/dist/server/sse.js +2 -1
  82. package/dist/server/sse.js.map +1 -1
  83. package/dist/server/text-slots.d.ts +8 -0
  84. package/dist/server/text-slots.d.ts.map +1 -0
  85. package/dist/server/text-slots.js +19 -0
  86. package/dist/server/text-slots.js.map +1 -0
  87. package/dist/server.d.ts +1 -0
  88. package/dist/server.d.ts.map +1 -1
  89. package/dist/server.js +1 -0
  90. package/dist/server.js.map +1 -1
  91. package/dist/single-copy.d.ts +5 -0
  92. package/dist/single-copy.d.ts.map +1 -1
  93. package/dist/single-copy.js +26 -17
  94. package/dist/single-copy.js.map +1 -1
  95. package/dist/transport-codec.d.ts.map +1 -1
  96. package/dist/transport-codec.js +2 -1
  97. package/dist/transport-codec.js.map +1 -1
  98. package/package.json +1 -1
  99. package/src/binary.ts +17 -0
  100. package/src/edge-kit.ts +1 -0
  101. package/src/errors.ts +1 -0
  102. package/src/evidence.ts +4 -0
  103. package/src/index.ts +2 -1
  104. package/src/internal/hook-origins.ts +10 -0
  105. package/src/reflection.ts +18 -0
  106. package/src/server/answer-step.ts +68 -0
  107. package/src/server/base64-answers.ts +138 -0
  108. package/src/server/core-copies.ts +5 -2
  109. package/src/server/install.ts +3 -0
  110. package/src/server/json-response.ts +13 -0
  111. package/src/server/json-text.ts +34 -0
  112. package/src/server/node-outcome.ts +4 -5
  113. package/src/server/request-context.ts +4 -2
  114. package/src/server/respond.ts +27 -18
  115. package/src/server/response-contract-lane.ts +2 -1
  116. package/src/server/route-template.ts +24 -0
  117. package/src/server/runtime-core.ts +2 -1
  118. package/src/server/server-types.ts +15 -4
  119. package/src/server/server.ts +114 -54
  120. package/src/server/sse.ts +2 -1
  121. package/src/server/text-slots.ts +21 -0
  122. package/src/server.ts +1 -0
  123. package/src/single-copy.ts +31 -15
  124. package/src/transport-codec.ts +3 -1
@@ -69,7 +69,12 @@ import type {
69
69
  StandardResult,
70
70
  StandardSchemaV1,
71
71
  } from "../schema/standard.ts"
72
- import type { AuthenticationStage } from "./auth.ts"
72
+ import { withAnswerSteps } from "./answer-step.ts"
73
+ import {
74
+ type AuthenticationStage,
75
+ isAuthenticatedRequest,
76
+ markAuthenticatedRequest,
77
+ } from "./auth.ts"
73
78
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
74
79
  import { LAST_BEFORE_HANDLE } from "./before-handler.ts"
75
80
  import {
@@ -158,6 +163,7 @@ import {
158
163
  type StatusResponse,
159
164
  } from "./runtime-core.ts"
160
165
  import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
166
+ import { withTextSlots } from "./text-slots.ts"
161
167
  import { plainValidationError } from "./validation.ts"
162
168
 
163
169
  // NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
@@ -191,6 +197,7 @@ import {
191
197
  NODE_NATIVE_MOUNT,
192
198
  REPORT_ERROR,
193
199
  RESOLVE_NODE_MOUNT,
200
+ SERVER_LIFECYCLE,
194
201
  } from "./install.ts"
195
202
  import type { EffectLedgerRuntime } from "./ledger-lane.ts"
196
203
  import { jsonLogger, type Logger } from "./logger.ts"
@@ -233,6 +240,7 @@ import type {
233
240
  RunningServer,
234
241
  ServerOptions,
235
242
  StopHook,
243
+ StopOptions,
236
244
  ToolAnnotations,
237
245
  } from "./server-types.ts"
238
246
  import type { SSEInit, TypedSSEStream } from "./sse.ts"
@@ -572,6 +580,7 @@ export type {
572
580
  RunningServer,
573
581
  ServerOptions,
574
582
  StopHook,
583
+ StopOptions,
575
584
  ToolAnnotations,
576
585
  }
577
586
 
@@ -778,12 +787,15 @@ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
778
787
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
779
788
  }
780
789
 
790
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
781
791
  function stripMountPrefix(request: Request, prefix: string): Request {
782
792
  if (prefix === "/") return request
783
793
  const url = new URL(request.url)
784
794
  const rest = url.pathname.slice(prefix.length)
785
795
  url.pathname = rest === "" ? "/" : rest
786
- return new Request(url.href, request)
796
+ const childRequest = new Request(url.href, request)
797
+ if (isAuthenticatedRequest(request)) markAuthenticatedRequest(childRequest)
798
+ return childRequest
787
799
  }
788
800
 
789
801
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
@@ -1269,8 +1281,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1269
1281
  private readonly acceptInboundDeadlines: boolean
1270
1282
  private readonly maxInboundDeadlineMs: number
1271
1283
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
1272
- private readonly gracefulSignals: { readonly drainMs?: number } | undefined
1284
+ private readonly gracefulSignals: StopOptions | undefined
1273
1285
  private readonly stopHooks: StopHook[]
1286
+ private readonly stoppingHooks: StopHook[]
1274
1287
  private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
1275
1288
  private mountOrder: number
1276
1289
  /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
@@ -1365,6 +1378,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1365
1378
  this.staticResponseHeaders,
1366
1379
  // Response hooks write next, and they run for this same request object.
1367
1380
  ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
1381
+ this.onResponseHooks.length === 0,
1368
1382
  )
1369
1383
  private readonly responseRequests: WeakMap<Request, Request>
1370
1384
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
@@ -1433,6 +1447,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1433
1447
  this.gracefulSignals =
1434
1448
  options.gracefulSignals === true ? {} : options.gracefulSignals || undefined
1435
1449
  this.stopHooks = []
1450
+ this.stoppingHooks = []
1436
1451
  this.fetchMounts = []
1437
1452
  this.mountOrder = 0
1438
1453
  this.preRouteMountCount = 0
@@ -1508,6 +1523,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1508
1523
  return this
1509
1524
  }
1510
1525
 
1526
+ /**
1527
+ * Register a callback awaited as soon as `stop()` begins, while the server still serves: readiness
1528
+ * should fail from here, and work that outlives a request (a poller, a queue consumer) should stop
1529
+ * taking more. Hung callbacks are bounded.
1530
+ */
1531
+ onStopping(fn: StopHook): this {
1532
+ this.assertConfigurable("onStopping()")
1533
+ if (typeof fn !== "function") throw new TypeError("onStopping callback must be a function")
1534
+ this.stoppingHooks.push(fn)
1535
+ return this
1536
+ }
1537
+
1511
1538
  /**
1512
1539
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
1513
1540
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
@@ -2570,6 +2597,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2570
2597
  schema: RouteSchema | undefined,
2571
2598
  handler: (context: never) => unknown,
2572
2599
  ): CatalogRoute {
2600
+ schema = withTextSlots(schema)
2573
2601
  // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
2574
2602
  // app: the module that registered the route, read where a static scan cannot see the path.
2575
2603
  if (
@@ -2617,6 +2645,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2617
2645
  lanes,
2618
2646
  routeAssurance,
2619
2647
  } = compiled
2648
+ // A schema that writes its answer its own way (`t.bytes()`) wraps every stage that can answer.
2649
+ const stages = withAnswerSteps(schema, method, path, {
2650
+ handler,
2651
+ beforeHandle: this.beforeHandleHooks,
2652
+ afterHandle: this.afterHandleHooks,
2653
+ onError: this.onErrorHooks,
2654
+ })
2655
+ handler = stages.handler
2656
+ const { beforeHandle, afterHandle, onError } = stages
2620
2657
  if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2621
2658
  handler = this.idempotencyRuntime.wrapHandler(handler)
2622
2659
  }
@@ -2644,8 +2681,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2644
2681
  ? this.buildFusedBodyDeriveBeforeAfterRunner(
2645
2682
  handler as unknown as InternalHandler,
2646
2683
  this.derives[0]!,
2647
- this.beforeHandleHooks[0]!,
2648
- this.afterHandleHooks[0],
2684
+ beforeHandle[0]!,
2685
+ afterHandle[0],
2649
2686
  schema?.body as StandardSchemaV1,
2650
2687
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2651
2688
  )
@@ -2653,7 +2690,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2653
2690
  ? this.buildFusedDeriveBeforeRunner(
2654
2691
  handler as unknown as InternalHandler,
2655
2692
  this.derives[0]!,
2656
- this.beforeHandleHooks[0]!,
2693
+ beforeHandle[0]!,
2657
2694
  undefined,
2658
2695
  schema?.query,
2659
2696
  bodyLimit ?? UNLIMITED_BODY_BYTES,
@@ -2662,8 +2699,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2662
2699
  ? this.buildFusedDeriveBeforeRunner(
2663
2700
  handler as unknown as InternalHandler,
2664
2701
  this.derives[0]!,
2665
- this.beforeHandleHooks[0]!,
2666
- this.afterHandleHooks[0],
2702
+ beforeHandle[0]!,
2703
+ afterHandle[0],
2667
2704
  schema?.query,
2668
2705
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2669
2706
  )
@@ -2689,8 +2726,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2689
2726
  ? this.buildFusedBodyDeriveBeforeAfter(
2690
2727
  handler as unknown as InternalHandler,
2691
2728
  this.derives[0]!,
2692
- this.beforeHandleHooks[0]!,
2693
- this.afterHandleHooks[0],
2729
+ beforeHandle[0]!,
2730
+ afterHandle[0],
2694
2731
  schema?.body as StandardSchemaV1,
2695
2732
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2696
2733
  )
@@ -2698,7 +2735,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2698
2735
  ? this.buildFusedDeriveBefore(
2699
2736
  handler as unknown as InternalHandler,
2700
2737
  this.derives[0]!,
2701
- this.beforeHandleHooks[0]!,
2738
+ beforeHandle[0]!,
2702
2739
  schema?.query,
2703
2740
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2704
2741
  )
@@ -2706,8 +2743,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2706
2743
  ? this.buildFusedDeriveBeforeAfter(
2707
2744
  handler as unknown as InternalHandler,
2708
2745
  this.derives[0]!,
2709
- this.beforeHandleHooks[0]!,
2710
- this.afterHandleHooks[0]!,
2746
+ beforeHandle[0]!,
2747
+ afterHandle[0]!,
2711
2748
  schema?.query,
2712
2749
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2713
2750
  )
@@ -2717,9 +2754,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2717
2754
  handler: handler as unknown as InternalHandler,
2718
2755
  derives: this.derives,
2719
2756
  authStages: compiled.authStages,
2720
- beforeHandle: this.beforeHandleHooks,
2721
- afterHandle: this.afterHandleHooks,
2722
- onError: this.onErrorHooks,
2757
+ beforeHandle,
2758
+ afterHandle,
2759
+ onError,
2723
2760
  decorations: routeDecorations,
2724
2761
  hasDecorations,
2725
2762
  bodySchema: schema?.body,
@@ -2748,9 +2785,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2748
2785
  authStages: [...compiled.authStages],
2749
2786
  decorations: routeDecorations,
2750
2787
  hasDecorations,
2751
- beforeHandle: [...this.beforeHandleHooks],
2752
- afterHandle: [...this.afterHandleHooks],
2753
- onError: [...this.onErrorHooks],
2788
+ beforeHandle: [...beforeHandle],
2789
+ afterHandle: [...afterHandle],
2790
+ onError: [...onError],
2754
2791
  lifecycleHookLane: lanes.lifecycleHookLane,
2755
2792
  around: [...this.aroundHooks],
2756
2793
  execution,
@@ -3190,6 +3227,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3190
3227
  // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
3191
3228
  // its routes; dropping it would leak the resource past `stop()`.
3192
3229
  this.stopHooks.push(...source.stopHooks)
3230
+ this.stoppingHooks.push(...source.stoppingHooks)
3193
3231
  this.mcpResourceList.push(...source.mcpResourceList)
3194
3232
  this.mcpPromptList.push(...source.mcpPromptList)
3195
3233
  }
@@ -3995,14 +4033,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3995
4033
  return refuse(jsonError(500, "internal_error"))
3996
4034
  }
3997
4035
  }
3998
- const child =
3999
- mount.authStages.length === 0
4000
- ? upgrade()
4001
- : thenMaybe(
4002
- authenticateMount(mount.authStages, request, platform, this.logger),
4003
- (refusal): MaybePromise<WebSocketUpgradeOutcome> =>
4004
- refusal === undefined ? upgrade() : refuse(refusal),
4005
- )
4006
4036
  const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
4007
4037
  outcome.kind === "pass" ||
4008
4038
  (outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
@@ -4010,7 +4040,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4010
4040
  : outcome.kind === "reject"
4011
4041
  ? refuse(outcome.response)
4012
4042
  : outcome
4013
- return child instanceof Promise ? child.then(settle) : settle(child)
4043
+ const run = (): MaybePromise<WebSocketUpgradeOutcome> => {
4044
+ const child = upgrade()
4045
+ return child instanceof Promise ? child.then(settle) : settle(child)
4046
+ }
4047
+ // Only a child's miss may fall through, never an authentication refusal.
4048
+ return mount.authStages.length === 0
4049
+ ? run()
4050
+ : thenMaybe(
4051
+ authenticateMount(mount.authStages, request, platform, this.logger),
4052
+ (refusal) => (refusal === undefined ? run() : refuse(refusal)),
4053
+ )
4014
4054
  }
4015
4055
  return dispatch(0)
4016
4056
  }
@@ -4517,25 +4557,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4517
4557
  if (mount === undefined) return fallback ?? jsonError(404, "not_found")
4518
4558
  const selected = mount
4519
4559
  const run = (): MaybePromise<Response> => {
4560
+ const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined
4520
4561
  const childRequest = selected.stripPrefix
4521
- ? (selected.wsPassed?.get(request) ?? stripMountPrefix(request, selected.path))
4562
+ ? (reused ?? stripMountPrefix(request, selected.path))
4522
4563
  : request
4523
- return selected.handler(childRequest, platform)
4524
- }
4525
- const outcome =
4526
- mount.authStages.length === 0
4527
- ? run()
4528
- : thenMaybe(
4529
- authenticateMount(mount.authStages, request, platform, this.logger),
4530
- (refusal) => refusal ?? run(),
4531
- )
4532
- if (!replayable || !mount.fallbackOn404) return outcome
4533
- if (outcome instanceof Promise) {
4534
- return outcome.then((response) =>
4535
- response.status === 404 ? dispatch(index + 1, response) : response,
4536
- )
4564
+ // HTTP authentication may have marked the original since the WebSocket pass.
4565
+ if (reused !== undefined && isAuthenticatedRequest(request)) {
4566
+ markAuthenticatedRequest(reused)
4567
+ }
4568
+ const outcome = selected.handler(childRequest, platform)
4569
+ if (!replayable || !selected.fallbackOn404) return outcome
4570
+ if (outcome instanceof Promise) {
4571
+ return outcome.then((response) =>
4572
+ response.status === 404 ? dispatch(index + 1, response) : response,
4573
+ )
4574
+ }
4575
+ return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
4537
4576
  }
4538
- return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
4577
+ // Keep gate refusals outside the child's 404 fallthrough path.
4578
+ return mount.authStages.length === 0
4579
+ ? run()
4580
+ : thenMaybe(
4581
+ authenticateMount(mount.authStages, request, platform, this.logger),
4582
+ (refusal) => refusal ?? run(),
4583
+ )
4539
4584
  }
4540
4585
  return dispatch(first)
4541
4586
  }
@@ -7205,11 +7250,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7205
7250
  * The Bun semantics: poll `pendingRequests` (awaiting
7206
7251
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
7207
7252
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
7208
- * New connections may be accepted during the drain window; in a real deploy the
7209
- * load balancer has already stopped routing here, and `drainMs` bounds it. Open WebSockets
7210
- * get a 1001 close at once and the same window to finish it, before any `onStop` hook runs.
7253
+ * New connections may be accepted during the drain window; `delayMs` is the time a load balancer
7254
+ * gets to stop routing here first. The `onStopping` hooks run before that delay, the `onStop`
7255
+ * hooks after the drain. Open WebSockets get a 1001 close when the drain starts and the same
7256
+ * window to finish it; a socket opened after `stop()` began is closed at once.
7211
7257
  */
7212
- async stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
7258
+ async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 }: StopOptions = {}): Promise<void> {
7213
7259
  const onSignal = this.signalHandler
7214
7260
  if (onSignal !== undefined) {
7215
7261
  this.signalHandler = undefined
@@ -7218,6 +7264,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7218
7264
  const server = this.bunServer
7219
7265
  if (server === undefined) return
7220
7266
  this.bunServer = undefined
7267
+ await this[SERVER_LIFECYCLE]("stopping")
7268
+ if (delayMs > 0) await Bun.sleep(delayMs)
7221
7269
  const sockets = this.bunSockets
7222
7270
  this.bunSockets = undefined
7223
7271
  if (sockets !== undefined) for (const ws of sockets) ws.close(1001)
@@ -7227,16 +7275,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7227
7275
  await Bun.sleep(DRAIN_POLL_MS)
7228
7276
  }
7229
7277
  server.stop(busy()) // force-close iff stragglers remain past the deadline
7230
- if (this.stopHooks.length === 0) return
7231
- const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
7232
- const settled = Promise.allSettled(callbacks)
7278
+ await this[SERVER_LIFECYCLE]("stopped")
7279
+ }
7280
+
7281
+ /**
7282
+ * @internal Run the `onStopping` or the `onStop` hooks, each bounded. A runtime adapter that drains
7283
+ * its own listener calls it on either side of the drain, as `stop()` does here.
7284
+ */
7285
+ async [SERVER_LIFECYCLE](phase: "stopping" | "stopped"): Promise<void> {
7286
+ const hooks = phase === "stopping" ? this.stoppingHooks : this.stopHooks
7287
+ if (hooks.length === 0) return
7288
+ let timer: ReturnType<typeof setTimeout> | undefined
7233
7289
  await Promise.race([
7234
- settled,
7235
- new Promise<void>((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
7290
+ Promise.allSettled(hooks.map((hook) => Promise.resolve().then(hook))),
7291
+ new Promise<void>((resolve) => {
7292
+ timer = setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)
7293
+ }),
7236
7294
  ])
7295
+ // A pending bound would hold the event loop open after every hook settled.
7296
+ clearTimeout(timer)
7237
7297
  }
7238
7298
 
7239
- private installSignalHandlers(drain: { readonly drainMs?: number }): void {
7299
+ private installSignalHandlers(drain: StopOptions): void {
7240
7300
  // Drain, then let the process exit naturally - the stopped server no longer
7241
7301
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
7242
7302
  // signals is consented; we don't force `process.exit`.
package/src/server/sse.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  * }, { keepAlive: 15_000 }))
23
23
  */
24
24
  import { INSTALL_SSE } from "./install.ts"
25
+ import { jsonText } from "./json-text.ts"
25
26
  import type { IdentityPlugin } from "./plugin.ts"
26
27
  import type { AnyServer } from "./server.ts"
27
28
  import type { SseRuntime } from "./sse-hook.ts"
@@ -92,7 +93,7 @@ export interface TypedSSEStream<Event> {
92
93
  export function typedSSEStream<Event>(stream: SSEStream): TypedSSEStream<Event> {
93
94
  return {
94
95
  send(event, init = {}) {
95
- stream.send({ ...init, data: JSON.stringify(event) })
96
+ stream.send({ ...init, data: jsonText(event) })
96
97
  },
97
98
  comment(text) {
98
99
  stream.send({ comment: text })
@@ -0,0 +1,21 @@
1
+ import type { StandardSchemaV1 } from "../schema/standard.ts"
2
+ import type { RouteSchema } from "./context.ts"
3
+
4
+ // What a schema offers for a slot whose values arrive as text: `@nifrajs/schema` sets it on a
5
+ // `t.object` (and any `t` schema that does not coerce) to the coercing form of the same schema.
6
+ const TEXT_SLOT = Symbol.for("nifra.schema.textSlot")
7
+
8
+ /**
9
+ * `schema` with its `query`, `headers` and `cookies` schemas swapped for the text form each offers, or
10
+ * `schema` itself when none offers one. A query string, header values and a `Cookie` header are
11
+ * always text, so a schema that reads a boolean or a number there can only validate its coercing form.
12
+ */
13
+ export function withTextSlots(schema: RouteSchema | undefined): RouteSchema | undefined {
14
+ let out = schema
15
+ for (const slot of ["query", "headers", "cookies"] as const) {
16
+ const validate = out?.[slot]?.["~standard"].validate
17
+ const form: StandardSchemaV1 | undefined = validate && Reflect.get(validate, TEXT_SLOT)
18
+ if (form !== undefined) out = { ...out, [slot]: form }
19
+ }
20
+ return out
21
+ }
package/src/server.ts CHANGED
@@ -108,6 +108,7 @@ export type {
108
108
  } from "./server/registry.ts"
109
109
  export { replacedRequestOf } from "./server/request-lineage.ts"
110
110
  export { writableResponse } from "./server/respond.ts"
111
+ export { routeTemplateOf } from "./server/route-template.ts"
111
112
  export {
112
113
  type PlainRender,
113
114
  type ResponseResult,
@@ -78,13 +78,9 @@ export const IDENTITY_SENSITIVE_PACKAGES: readonly string[] = [
78
78
  /** Files a redirect may rewrite. Everything else in a package (json, css, wasm) loads unchanged. */
79
79
  const REDIRECTABLE = /\.(?:m|c)?[jt]sx?$/
80
80
 
81
- /** The loader a {@link REDIRECTABLE} file is parsed with. `.ts` is never `tsx`: under JSX rules a
82
- * generic arrow (`<T>(x: T) => x`) reads as an unclosed element and swallows the file. */
83
- function sourceLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
84
- if (path.endsWith(".tsx")) return "tsx"
85
- if (path.endsWith(".jsx")) return "jsx"
86
- return /\.[mc]?ts$/.test(path) ? "ts" : "js"
87
- }
81
+ /** Appended to a linked file the load hook hands back to Bun untouched; see the hook. */
82
+ const OWN_FILE_QUERY = "?nifra-single-copy"
83
+
88
84
  /** Runaway guards. A real `node_modules` top level is hundreds of entries, a repo walk is a few. */
89
85
  const MAX_SCANNED_ENTRIES = 4_096
90
86
  const MAX_ANCESTOR_DIRS = 16
@@ -233,6 +229,11 @@ export interface SingleCopyRegistration {
233
229
  readonly run: boolean
234
230
  /** `[test] preload`: covers `bun test`, the surface that runs app sources unbundled. */
235
231
  readonly test: boolean
232
+ /**
233
+ * Tables whose `preload` names the registrar although Bun reads it from neither place: below
234
+ * `[install]` the line is `install.preload`, so `bun run` loads every copy while the file looks armed.
235
+ */
236
+ readonly ignored?: readonly string[]
236
237
  /** The `bunfig.toml` that was read, if one exists. */
237
238
  readonly config?: string
238
239
  }
@@ -286,6 +287,7 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
286
287
  let section = ""
287
288
  let run = false
288
289
  let test = false
290
+ const ignored: string[] = []
289
291
  for (const line of text.split(/\r?\n/)) {
290
292
  const match = PRELOAD_ASSIGNMENT.exec(line)
291
293
  if (match?.groups === undefined) continue
@@ -298,8 +300,9 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
298
300
  continue
299
301
  if (section === "test") test = true
300
302
  else if (section === "") run = true
303
+ else if (!ignored.includes(section)) ignored.push(section)
301
304
  }
302
- return { run, test, config }
305
+ return ignored.length === 0 ? { run, test, config } : { run, test, ignored, config }
303
306
  }
304
307
 
305
308
  /** The repository a linked checkout resolves its own dependencies from: its nearest `.git` ancestor.
@@ -598,6 +601,8 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
598
601
  // hook has to return contents for everything it matches, and re-emitting a CommonJS entry as an
599
602
  // ES module is how `Export named 'useEffect' not found` happens.
600
603
  const filter = new RegExp(`^(?:${roots})[/\\\\].*${REDIRECTABLE.source}`)
604
+ // `Bun.plugin` leaves `config` unset although the type says otherwise; only `Bun.build` sets it.
605
+ const bundling = build.config !== undefined
601
606
  build.onLoad({ filter }, (args) => {
602
607
  for (const redirect of plan.redirects) {
603
608
  const target = counterpart(redirect, args.path)
@@ -616,10 +621,13 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
616
621
  detail: `${relative(owner.from, args.path)} has no file at the same path in the app's copy, so it loads from the linked copy and its module state is not shared`,
617
622
  })
618
623
  }
619
- // A runtime `onLoad` must always return an object - returning nothing fails the load rather
620
- // than falling through to the default. Hand back the file as it was, at its own path, so its
621
- // relative imports keep resolving exactly as they would have.
622
- return { contents: readFileSync(args.path, "utf8"), loader: sourceLoader(args.path) }
624
+ // The file loads as it is, in its own module format. A bundler takes `undefined` for that. A
625
+ // runtime `onLoad` must return an object, and source returned from here loads as an ES module,
626
+ // which empties a CommonJS file's exports - so the runtime gets a re-export of the file under a
627
+ // query the filter's `$` cannot match, which Bun then loads natively from its own directory.
628
+ // There its `__filename` and `import.meta.url` carry the query; the bundler cannot resolve it.
629
+ if (bundling) return undefined
630
+ return { contents: reexport(`${args.path}${OWN_FILE_QUERY}`), loader: "js" }
623
631
  })
624
632
  },
625
633
  }
@@ -646,12 +654,20 @@ const processSet = (key: symbol): Set<string> => {
646
654
  const copyLine = (label: string, path: string | undefined, version: string | undefined): string =>
647
655
  ` ${label}: ${path ?? "(not installed for the app)"}${version === undefined ? "" : ` (${version})`}`
648
656
 
649
- /** One report per package: both copies, both versions, the reason, and the fix. */
657
+ /**
658
+ * One report per package: both copies, both versions, the reason, and the fix. A version skew comes
659
+ * from the plan, which reads installs, so it says installed: nothing may ever load that copy, and a
660
+ * runtime `onLoad` cannot watch for it without re-emitting CommonJS files as ES modules.
661
+ */
650
662
  const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string => {
651
663
  const first = skips[0]
652
664
  const reason = first?.reason ?? "version-skew"
665
+ const effect =
666
+ reason === "version-skew"
667
+ ? "a second copy is installed at another version, and whatever loads it does not share this copy's module state."
668
+ : "a second copy loads, so its module state is not shared."
653
669
  const lines = [
654
- `[nifra] single-copy: ${name} is NOT deduplicated (${reason}) - a second copy loads, so its module state is not shared.`,
670
+ `[nifra] single-copy: ${name} is NOT deduplicated (${reason}) - ${effect}`,
655
671
  copyLine("app copy", first?.to, first?.toVersion),
656
672
  ]
657
673
  for (const skip of skips) {
@@ -683,7 +699,7 @@ const describeSkewGroup = (skips: readonly SingleCopySkip[]): string => {
683
699
  if (first === undefined || skips.length === 1) return describeSkips(first?.package ?? "", skips)
684
700
  const names = skips.map((skip) => skip.package).sort(codeUnitOrder)
685
701
  return [
686
- `[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each loads, so their module state is not shared.`,
702
+ `[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each is installed at another version, and whatever loads one does not share the app copy's module state.`,
687
703
  copyLine(
688
704
  "app copies",
689
705
  first.to === undefined ? undefined : modulesDirOf(first.to),
@@ -3,6 +3,8 @@
3
3
  * remains the zero-configuration fast path; rich wire is explicit and preserves non-JSON values.
4
4
  */
5
5
 
6
+ import { jsonText } from "./server/json-text.ts"
7
+
6
8
  const DEFAULT_MAX_BYTES = 16 * 1024 * 1024
7
9
  const TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/
8
10
 
@@ -81,7 +83,7 @@ export const plainJsonCodec: TransportCodec = Object.freeze({
81
83
  version: 1,
82
84
  mediaType: "application/json",
83
85
  encode(value: unknown): string {
84
- const encoded = JSON.stringify(value)
86
+ const encoded = jsonText(value)
85
87
  if (encoded === undefined) throw new TransportCodecError("value is not JSON serializable")
86
88
  return encoded
87
89
  },