@nifrajs/core 5.6.0 → 5.8.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 +99 -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 +26 -6
  75. package/dist/server/server-types.d.ts.map +1 -1
  76. package/dist/server/server.d.ts +22 -10
  77. package/dist/server/server.d.ts.map +1 -1
  78. package/dist/server/server.js +109 -45
  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 +27 -4
  119. package/src/server/server.ts +131 -63
  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
 
@@ -704,7 +713,8 @@ function resolvedSource(req: Request, url: string): RequestSource {
704
713
  const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i
705
714
 
706
715
  // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
707
- let lastValidHost = ""
716
+ // `null` until one passes, so an empty Host is never taken as the cached one.
717
+ let lastValidHost: string | null = null
708
718
 
709
719
  // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
710
720
  // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
@@ -724,10 +734,11 @@ const invalidHost = (host: string): boolean => {
724
734
  * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
725
735
  * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
726
736
  * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
727
- * header.
737
+ * header. On Bun only a target that needs resolving is checked unless `validateHost` is set.
728
738
  */
729
- const requestTarget = (req: Request): string | boolean => {
730
- if (!rawRequestTargets && !unparsedBunTargets) return false
739
+ const requestTarget = (req: Request, validateHost: boolean): string | boolean => {
740
+ if (!rawRequestTargets && !(unparsedBunTargets && (validateHost || needsResolving(req.url))))
741
+ return false
731
742
  const host = headerOf(req, "host")
732
743
  if (host !== null && invalidHost(host)) {
733
744
  if (rawRequestTargets) return true
@@ -778,12 +789,15 @@ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
778
789
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
779
790
  }
780
791
 
792
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
781
793
  function stripMountPrefix(request: Request, prefix: string): Request {
782
794
  if (prefix === "/") return request
783
795
  const url = new URL(request.url)
784
796
  const rest = url.pathname.slice(prefix.length)
785
797
  url.pathname = rest === "" ? "/" : rest
786
- return new Request(url.href, request)
798
+ const childRequest = new Request(url.href, request)
799
+ if (isAuthenticatedRequest(request)) markAuthenticatedRequest(childRequest)
800
+ return childRequest
787
801
  }
788
802
 
789
803
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
@@ -1258,6 +1272,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1258
1272
  private readonly formOrigins: FormOrigins | undefined
1259
1273
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
1260
1274
  private readonly trustBodyFraming: boolean
1275
+ private readonly validateHost: boolean
1261
1276
  private readonly wsMaxPayloadBytes: number
1262
1277
  private readonly requestTimeoutMs: number
1263
1278
  private readonly wsUpgradeTimeoutMs: number
@@ -1269,8 +1284,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1269
1284
  private readonly acceptInboundDeadlines: boolean
1270
1285
  private readonly maxInboundDeadlineMs: number
1271
1286
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
1272
- private readonly gracefulSignals: { readonly drainMs?: number } | undefined
1287
+ private readonly gracefulSignals: StopOptions | undefined
1273
1288
  private readonly stopHooks: StopHook[]
1289
+ private readonly stoppingHooks: StopHook[]
1274
1290
  private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
1275
1291
  private mountOrder: number
1276
1292
  /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
@@ -1365,6 +1381,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1365
1381
  this.staticResponseHeaders,
1366
1382
  // Response hooks write next, and they run for this same request object.
1367
1383
  ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
1384
+ this.onResponseHooks.length === 0,
1368
1385
  )
1369
1386
  private readonly responseRequests: WeakMap<Request, Request>
1370
1387
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
@@ -1400,6 +1417,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1400
1417
  this.protoPoisoning = options.protoPoisoning ?? "reject"
1401
1418
  this.formOrigins = options.formOrigins
1402
1419
  this.trustBodyFraming = options.trustBodyFraming ?? false
1420
+ this.validateHost = options.validateHost ?? false
1403
1421
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
1404
1422
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
1405
1423
  this.maxBodyBytes = maxBodyBytes
@@ -1433,6 +1451,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1433
1451
  this.gracefulSignals =
1434
1452
  options.gracefulSignals === true ? {} : options.gracefulSignals || undefined
1435
1453
  this.stopHooks = []
1454
+ this.stoppingHooks = []
1436
1455
  this.fetchMounts = []
1437
1456
  this.mountOrder = 0
1438
1457
  this.preRouteMountCount = 0
@@ -1508,6 +1527,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1508
1527
  return this
1509
1528
  }
1510
1529
 
1530
+ /**
1531
+ * Register a callback awaited as soon as `stop()` begins, while the server still serves: readiness
1532
+ * should fail from here, and work that outlives a request (a poller, a queue consumer) should stop
1533
+ * taking more. Hung callbacks are bounded.
1534
+ */
1535
+ onStopping(fn: StopHook): this {
1536
+ this.assertConfigurable("onStopping()")
1537
+ if (typeof fn !== "function") throw new TypeError("onStopping callback must be a function")
1538
+ this.stoppingHooks.push(fn)
1539
+ return this
1540
+ }
1541
+
1511
1542
  /**
1512
1543
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
1513
1544
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
@@ -2570,6 +2601,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2570
2601
  schema: RouteSchema | undefined,
2571
2602
  handler: (context: never) => unknown,
2572
2603
  ): CatalogRoute {
2604
+ schema = withTextSlots(schema)
2573
2605
  // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
2574
2606
  // app: the module that registered the route, read where a static scan cannot see the path.
2575
2607
  if (
@@ -2617,6 +2649,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2617
2649
  lanes,
2618
2650
  routeAssurance,
2619
2651
  } = compiled
2652
+ // A schema that writes its answer its own way (`t.bytes()`) wraps every stage that can answer.
2653
+ const stages = withAnswerSteps(schema, method, path, {
2654
+ handler,
2655
+ beforeHandle: this.beforeHandleHooks,
2656
+ afterHandle: this.afterHandleHooks,
2657
+ onError: this.onErrorHooks,
2658
+ })
2659
+ handler = stages.handler
2660
+ const { beforeHandle, afterHandle, onError } = stages
2620
2661
  if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2621
2662
  handler = this.idempotencyRuntime.wrapHandler(handler)
2622
2663
  }
@@ -2644,8 +2685,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2644
2685
  ? this.buildFusedBodyDeriveBeforeAfterRunner(
2645
2686
  handler as unknown as InternalHandler,
2646
2687
  this.derives[0]!,
2647
- this.beforeHandleHooks[0]!,
2648
- this.afterHandleHooks[0],
2688
+ beforeHandle[0]!,
2689
+ afterHandle[0],
2649
2690
  schema?.body as StandardSchemaV1,
2650
2691
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2651
2692
  )
@@ -2653,7 +2694,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2653
2694
  ? this.buildFusedDeriveBeforeRunner(
2654
2695
  handler as unknown as InternalHandler,
2655
2696
  this.derives[0]!,
2656
- this.beforeHandleHooks[0]!,
2697
+ beforeHandle[0]!,
2657
2698
  undefined,
2658
2699
  schema?.query,
2659
2700
  bodyLimit ?? UNLIMITED_BODY_BYTES,
@@ -2662,8 +2703,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2662
2703
  ? this.buildFusedDeriveBeforeRunner(
2663
2704
  handler as unknown as InternalHandler,
2664
2705
  this.derives[0]!,
2665
- this.beforeHandleHooks[0]!,
2666
- this.afterHandleHooks[0],
2706
+ beforeHandle[0]!,
2707
+ afterHandle[0],
2667
2708
  schema?.query,
2668
2709
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2669
2710
  )
@@ -2689,8 +2730,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2689
2730
  ? this.buildFusedBodyDeriveBeforeAfter(
2690
2731
  handler as unknown as InternalHandler,
2691
2732
  this.derives[0]!,
2692
- this.beforeHandleHooks[0]!,
2693
- this.afterHandleHooks[0],
2733
+ beforeHandle[0]!,
2734
+ afterHandle[0],
2694
2735
  schema?.body as StandardSchemaV1,
2695
2736
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2696
2737
  )
@@ -2698,7 +2739,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2698
2739
  ? this.buildFusedDeriveBefore(
2699
2740
  handler as unknown as InternalHandler,
2700
2741
  this.derives[0]!,
2701
- this.beforeHandleHooks[0]!,
2742
+ beforeHandle[0]!,
2702
2743
  schema?.query,
2703
2744
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2704
2745
  )
@@ -2706,8 +2747,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2706
2747
  ? this.buildFusedDeriveBeforeAfter(
2707
2748
  handler as unknown as InternalHandler,
2708
2749
  this.derives[0]!,
2709
- this.beforeHandleHooks[0]!,
2710
- this.afterHandleHooks[0]!,
2750
+ beforeHandle[0]!,
2751
+ afterHandle[0]!,
2711
2752
  schema?.query,
2712
2753
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2713
2754
  )
@@ -2717,9 +2758,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2717
2758
  handler: handler as unknown as InternalHandler,
2718
2759
  derives: this.derives,
2719
2760
  authStages: compiled.authStages,
2720
- beforeHandle: this.beforeHandleHooks,
2721
- afterHandle: this.afterHandleHooks,
2722
- onError: this.onErrorHooks,
2761
+ beforeHandle,
2762
+ afterHandle,
2763
+ onError,
2723
2764
  decorations: routeDecorations,
2724
2765
  hasDecorations,
2725
2766
  bodySchema: schema?.body,
@@ -2748,9 +2789,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2748
2789
  authStages: [...compiled.authStages],
2749
2790
  decorations: routeDecorations,
2750
2791
  hasDecorations,
2751
- beforeHandle: [...this.beforeHandleHooks],
2752
- afterHandle: [...this.afterHandleHooks],
2753
- onError: [...this.onErrorHooks],
2792
+ beforeHandle: [...beforeHandle],
2793
+ afterHandle: [...afterHandle],
2794
+ onError: [...onError],
2754
2795
  lifecycleHookLane: lanes.lifecycleHookLane,
2755
2796
  around: [...this.aroundHooks],
2756
2797
  execution,
@@ -3190,6 +3231,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3190
3231
  // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
3191
3232
  // its routes; dropping it would leak the resource past `stop()`.
3192
3233
  this.stopHooks.push(...source.stopHooks)
3234
+ this.stoppingHooks.push(...source.stoppingHooks)
3193
3235
  this.mcpResourceList.push(...source.mcpResourceList)
3194
3236
  this.mcpPromptList.push(...source.mcpPromptList)
3195
3237
  }
@@ -3353,7 +3395,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3353
3395
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
3354
3396
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
3355
3397
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
3356
- const target = requestTarget(req)
3398
+ const target = requestTarget(req, this.validateHost)
3357
3399
  if (target === true) return jsonError(400, "invalid_host")
3358
3400
  if (target !== false) {
3359
3401
  const rebuilt = withResolvedTarget(req, target)
@@ -3857,7 +3899,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3857
3899
  // The adapter answers a pass by handing this same object to `fetch`.
3858
3900
  const received = req
3859
3901
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
3860
- const target = requestTarget(req)
3902
+ const target = requestTarget(req, this.validateHost)
3861
3903
  if (target === true) {
3862
3904
  return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3863
3905
  }
@@ -3995,14 +4037,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3995
4037
  return refuse(jsonError(500, "internal_error"))
3996
4038
  }
3997
4039
  }
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
4040
  const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
4007
4041
  outcome.kind === "pass" ||
4008
4042
  (outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
@@ -4010,7 +4044,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4010
4044
  : outcome.kind === "reject"
4011
4045
  ? refuse(outcome.response)
4012
4046
  : outcome
4013
- return child instanceof Promise ? child.then(settle) : settle(child)
4047
+ const run = (): MaybePromise<WebSocketUpgradeOutcome> => {
4048
+ const child = upgrade()
4049
+ return child instanceof Promise ? child.then(settle) : settle(child)
4050
+ }
4051
+ // Only a child's miss may fall through, never an authentication refusal.
4052
+ return mount.authStages.length === 0
4053
+ ? run()
4054
+ : thenMaybe(
4055
+ authenticateMount(mount.authStages, request, platform, this.logger),
4056
+ (refusal) => (refusal === undefined ? run() : refuse(refusal)),
4057
+ )
4014
4058
  }
4015
4059
  return dispatch(0)
4016
4060
  }
@@ -4517,25 +4561,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4517
4561
  if (mount === undefined) return fallback ?? jsonError(404, "not_found")
4518
4562
  const selected = mount
4519
4563
  const run = (): MaybePromise<Response> => {
4564
+ const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined
4520
4565
  const childRequest = selected.stripPrefix
4521
- ? (selected.wsPassed?.get(request) ?? stripMountPrefix(request, selected.path))
4566
+ ? (reused ?? stripMountPrefix(request, selected.path))
4522
4567
  : 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
- )
4568
+ // HTTP authentication may have marked the original since the WebSocket pass.
4569
+ if (reused !== undefined && isAuthenticatedRequest(request)) {
4570
+ markAuthenticatedRequest(reused)
4571
+ }
4572
+ const outcome = selected.handler(childRequest, platform)
4573
+ if (!replayable || !selected.fallbackOn404) return outcome
4574
+ if (outcome instanceof Promise) {
4575
+ return outcome.then((response) =>
4576
+ response.status === 404 ? dispatch(index + 1, response) : response,
4577
+ )
4578
+ }
4579
+ return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
4537
4580
  }
4538
- return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
4581
+ // Keep gate refusals outside the child's 404 fallthrough path.
4582
+ return mount.authStages.length === 0
4583
+ ? run()
4584
+ : thenMaybe(
4585
+ authenticateMount(mount.authStages, request, platform, this.logger),
4586
+ (refusal) => refusal ?? run(),
4587
+ )
4539
4588
  }
4540
4589
  return dispatch(first)
4541
4590
  }
@@ -7003,9 +7052,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7003
7052
  peer,
7004
7053
  fallback,
7005
7054
  )
7006
- // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
7007
- tableAt(path, pattern.segments)[method as Method] = (request, server) =>
7008
- requestTarget(request) ? fallback(request, server) : handler(request, server)
7055
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it. Left
7056
+ // off, `validateHost` costs nothing here: the table holds no wildcard, a `..` parameter falls
7057
+ // back, and reading the Host on every request measured about 6% of a bare route.
7058
+ tableAt(path, pattern.segments)[method as Method] = this.validateHost
7059
+ ? (request, server) =>
7060
+ requestTarget(request, true) ? fallback(request, server) : handler(request, server)
7061
+ : handler
7009
7062
  count += 1
7010
7063
  if (paramNames.length > 0) {
7011
7064
  below.add(pattern.segments.length + method)
@@ -7205,11 +7258,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7205
7258
  * The Bun semantics: poll `pendingRequests` (awaiting
7206
7259
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
7207
7260
  * 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.
7261
+ * New connections may be accepted during the drain window; `delayMs` is the time a load balancer
7262
+ * gets to stop routing here first. The `onStopping` hooks run before that delay, the `onStop`
7263
+ * hooks after the drain. Open WebSockets get a 1001 close when the drain starts and the same
7264
+ * window to finish it; a socket opened after `stop()` began is closed at once.
7211
7265
  */
7212
- async stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
7266
+ async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 }: StopOptions = {}): Promise<void> {
7213
7267
  const onSignal = this.signalHandler
7214
7268
  if (onSignal !== undefined) {
7215
7269
  this.signalHandler = undefined
@@ -7218,6 +7272,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7218
7272
  const server = this.bunServer
7219
7273
  if (server === undefined) return
7220
7274
  this.bunServer = undefined
7275
+ await this[SERVER_LIFECYCLE]("stopping")
7276
+ if (delayMs > 0) await Bun.sleep(delayMs)
7221
7277
  const sockets = this.bunSockets
7222
7278
  this.bunSockets = undefined
7223
7279
  if (sockets !== undefined) for (const ws of sockets) ws.close(1001)
@@ -7227,16 +7283,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7227
7283
  await Bun.sleep(DRAIN_POLL_MS)
7228
7284
  }
7229
7285
  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)
7286
+ await this[SERVER_LIFECYCLE]("stopped")
7287
+ }
7288
+
7289
+ /**
7290
+ * @internal Run the `onStopping` or the `onStop` hooks, each bounded. A runtime adapter that drains
7291
+ * its own listener calls it on either side of the drain, as `stop()` does here.
7292
+ */
7293
+ async [SERVER_LIFECYCLE](phase: "stopping" | "stopped"): Promise<void> {
7294
+ const hooks = phase === "stopping" ? this.stoppingHooks : this.stopHooks
7295
+ if (hooks.length === 0) return
7296
+ let timer: ReturnType<typeof setTimeout> | undefined
7233
7297
  await Promise.race([
7234
- settled,
7235
- new Promise<void>((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
7298
+ Promise.allSettled(hooks.map((hook) => Promise.resolve().then(hook))),
7299
+ new Promise<void>((resolve) => {
7300
+ timer = setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)
7301
+ }),
7236
7302
  ])
7303
+ // A pending bound would hold the event loop open after every hook settled.
7304
+ clearTimeout(timer)
7237
7305
  }
7238
7306
 
7239
- private installSignalHandlers(drain: { readonly drainMs?: number }): void {
7307
+ private installSignalHandlers(drain: StopOptions): void {
7240
7308
  // Drain, then let the process exit naturally - the stopped server no longer
7241
7309
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
7242
7310
  // 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
  },