@nifrajs/core 5.1.0 → 5.3.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 (92) hide show
  1. package/CHANGELOG.md +2409 -0
  2. package/dist/edge-kit.d.ts +1 -1
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +1 -1
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/idempotency.d.ts.map +1 -1
  7. package/dist/idempotency.js +2 -0
  8. package/dist/idempotency.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/internal/hook-origins.d.ts +17 -1
  12. package/dist/internal/hook-origins.d.ts.map +1 -1
  13. package/dist/internal/hook-origins.js +20 -0
  14. package/dist/internal/hook-origins.js.map +1 -1
  15. package/dist/internal/route-program.d.ts +2 -0
  16. package/dist/internal/route-program.d.ts.map +1 -1
  17. package/dist/internal/route-program.js +22 -17
  18. package/dist/internal/route-program.js.map +1 -1
  19. package/dist/mount.d.ts +12 -0
  20. package/dist/mount.d.ts.map +1 -1
  21. package/dist/mount.js +20 -0
  22. package/dist/mount.js.map +1 -1
  23. package/dist/reflection.d.ts +23 -2
  24. package/dist/reflection.d.ts.map +1 -1
  25. package/dist/reflection.js +57 -1
  26. package/dist/reflection.js.map +1 -1
  27. package/dist/server/context.d.ts +7 -4
  28. package/dist/server/context.d.ts.map +1 -1
  29. package/dist/server/core-copies.d.ts +7 -1
  30. package/dist/server/core-copies.d.ts.map +1 -1
  31. package/dist/server/core-copies.js +37 -1
  32. package/dist/server/core-copies.js.map +1 -1
  33. package/dist/server/headers.d.ts +9 -0
  34. package/dist/server/headers.d.ts.map +1 -1
  35. package/dist/server/headers.js +26 -0
  36. package/dist/server/headers.js.map +1 -1
  37. package/dist/server/registry.d.ts +8 -10
  38. package/dist/server/registry.d.ts.map +1 -1
  39. package/dist/server/request-context.d.ts.map +1 -1
  40. package/dist/server/request-context.js +4 -3
  41. package/dist/server/request-context.js.map +1 -1
  42. package/dist/server/request-target.d.ts +9 -7
  43. package/dist/server/request-target.d.ts.map +1 -1
  44. package/dist/server/request-target.js +21 -12
  45. package/dist/server/request-target.js.map +1 -1
  46. package/dist/server/respond.d.ts.map +1 -1
  47. package/dist/server/respond.js +10 -7
  48. package/dist/server/respond.js.map +1 -1
  49. package/dist/server/response-contract-lane.d.ts +37 -2
  50. package/dist/server/response-contract-lane.d.ts.map +1 -1
  51. package/dist/server/response-contract-lane.js +278 -32
  52. package/dist/server/response-contract-lane.js.map +1 -1
  53. package/dist/server/server-types.d.ts +7 -4
  54. package/dist/server/server-types.d.ts.map +1 -1
  55. package/dist/server/server.d.ts +17 -4
  56. package/dist/server/server.d.ts.map +1 -1
  57. package/dist/server/server.js +184 -40
  58. package/dist/server/server.js.map +1 -1
  59. package/dist/server/websocket.d.ts.map +1 -1
  60. package/dist/server/websocket.js +4 -0
  61. package/dist/server/websocket.js.map +1 -1
  62. package/dist/single-copy.d.ts.map +1 -1
  63. package/dist/single-copy.js +46 -5
  64. package/dist/single-copy.js.map +1 -1
  65. package/dist/tool-contract.d.ts.map +1 -1
  66. package/dist/tool-contract.js +8 -4
  67. package/dist/tool-contract.js.map +1 -1
  68. package/dist/transport-codec.d.ts.map +1 -1
  69. package/dist/transport-codec.js +4 -1
  70. package/dist/transport-codec.js.map +1 -1
  71. package/package.json +3 -2
  72. package/src/edge-kit.ts +1 -1
  73. package/src/idempotency.ts +2 -0
  74. package/src/index.ts +1 -1
  75. package/src/internal/hook-origins.ts +35 -0
  76. package/src/internal/route-program.ts +39 -20
  77. package/src/mount.ts +25 -0
  78. package/src/reflection.ts +61 -1
  79. package/src/server/context.ts +7 -4
  80. package/src/server/core-copies.ts +40 -0
  81. package/src/server/headers.ts +39 -0
  82. package/src/server/registry.ts +18 -19
  83. package/src/server/request-context.ts +6 -3
  84. package/src/server/request-target.ts +21 -12
  85. package/src/server/respond.ts +10 -7
  86. package/src/server/response-contract-lane.ts +357 -42
  87. package/src/server/server-types.ts +7 -4
  88. package/src/server/server.ts +235 -35
  89. package/src/server/websocket.ts +3 -0
  90. package/src/single-copy.ts +55 -5
  91. package/src/tool-contract.ts +7 -4
  92. package/src/transport-codec.ts +4 -1
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "5.1.0" as const
19
+ export const VERSION = "5.3.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
@@ -16,6 +16,10 @@ export type HookKind =
16
16
  | "onResponse"
17
17
  | "responseHeaders"
18
18
  | "onResponseFinalized"
19
+ // Runtimes a route resolved at registration: reflected beside its hooks, never tagged as one.
20
+ | "effectLedger"
21
+ | "idempotency"
22
+ | "responseContract"
19
23
 
20
24
  export interface HookOrigin {
21
25
  readonly kind: HookKind
@@ -48,11 +52,24 @@ export interface AdoptionTag {
48
52
  readonly prefix: string | undefined
49
53
  }
50
54
 
55
+ /** A `merge()` whose routes carry no authentication of their own while the parent authenticates the
56
+ * routes it declares: the parent's order-scoped auth never runs for merged routes. */
57
+ export interface MergeAuthGapTag {
58
+ /** The `merge()` call's `file:line:col`, when a frame outside core was found. */
59
+ readonly site: string | undefined
60
+ /** The parent's auth in effect at the merge: `authenticate(<id>)` stages and evidence sources. */
61
+ readonly auth: readonly string[]
62
+ /** `METHOD /path` of each merged route the parent's auth would have covered. */
63
+ readonly routes: readonly string[]
64
+ }
65
+
51
66
  const origins = new WeakMap<object, HookOrigin>()
52
67
  const scopes = new WeakMap<object, HookScopeTag>()
53
68
  const sources = new WeakMap<object, unknown>()
54
69
  const statics = new WeakMap<object, StaticHeadersTag[]>()
55
70
  const adoptions = new WeakMap<object, AdoptionTag[]>()
71
+ const mergeAuthGaps = new WeakMap<object, MergeAuthGapTag[]>()
72
+ const routeSites = new WeakMap<object, Map<string, string>>()
56
73
  let applying: string | undefined
57
74
  // A merged server whose static headers are about to become one of the target's response hooks.
58
75
  let staticsOwner: object | undefined
@@ -130,6 +147,20 @@ export function tagAdoption(target: object, source: object, prefix: string | und
130
147
  else list.push(tag)
131
148
  }
132
149
 
150
+ export function tagMergeAuthGap(target: object, gap: MergeAuthGapTag): void {
151
+ const list = mergeAuthGaps.get(target)
152
+ if (list === undefined) mergeAuthGaps.set(target, [gap])
153
+ else list.push(gap)
154
+ }
155
+
156
+ /** Where `server` registered the route `key` (`METHOD /path`): a `file:line:col` frame. The first
157
+ * registration of a key wins, as it does for the router. */
158
+ export function tagRouteSite(server: object, key: string, site: string): void {
159
+ const sites = routeSites.get(server)
160
+ if (sites === undefined) routeSites.set(server, new Map([[key, site]]))
161
+ else if (!sites.has(key)) sites.set(key, site)
162
+ }
163
+
133
164
  const outer: (string | undefined)[] = []
134
165
 
135
166
  /** Mark the plugin a `use()` is applying, until the matching {@link leavePlugin}. */
@@ -151,3 +182,7 @@ export const hookSourceOf = (hook: unknown): unknown =>
151
182
  export const staticHeadersOf = (server: object): readonly StaticHeadersTag[] =>
152
183
  statics.get(server) ?? []
153
184
  export const adoptionsOf = (server: object): readonly AdoptionTag[] => adoptions.get(server) ?? []
185
+ export const mergeAuthGapsOf = (server: object): readonly MergeAuthGapTag[] =>
186
+ mergeAuthGaps.get(server) ?? []
187
+ export const routeSitesOf = (server: object): ReadonlyMap<string, string> =>
188
+ routeSites.get(server) ?? new Map()
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { StandardSchemaV1 } from "../schema/standard.ts"
11
11
  import { markAuthenticatedRequest } from "../server/auth.ts"
12
+ import { declaredHeaderNames } from "../server/headers.ts"
12
13
  import { pathnameOf } from "../server/http.ts"
13
14
  import { queryObjectOf } from "../server/query.ts"
14
15
  import type { Registry } from "../server/registry.ts"
@@ -35,7 +36,12 @@ import type {
35
36
  export type ProgramValidationKind = "headers" | "cookies" | "params" | "body" | "query"
36
37
 
37
38
  export type RouteProgramStage =
38
- | { readonly kind: ProgramValidationKind; readonly schema: StandardSchemaV1 }
39
+ | {
40
+ readonly kind: ProgramValidationKind
41
+ readonly schema: StandardSchemaV1
42
+ /** Headers only: the names a closed schema validates, read case-insensitively. */
43
+ readonly names?: readonly string[] | undefined
44
+ }
39
45
  | { readonly kind: "derive"; readonly run: RawDerive }
40
46
  | { readonly kind: "before"; readonly run: RawBeforeHandle }
41
47
  | { readonly kind: "handler"; readonly run: InternalHandler }
@@ -90,7 +96,8 @@ export function compileRouteProgram(input: RouteProgramInput): RouteProgram {
90
96
  for (const kind of ["headers", "cookies", "params", "body", "query"] as const) {
91
97
  const validator = schema?.[kind]
92
98
  if (validator !== undefined) {
93
- const stage = { kind, schema: validator }
99
+ const names = kind === "headers" ? declaredHeaderNames(validator) : undefined
100
+ const stage = { kind, schema: validator, names }
94
101
  stages.push(stage)
95
102
  validationStages.push(stage)
96
103
  }
@@ -142,6 +149,7 @@ interface RouteProgramRuntime {
142
149
  ctx: RawContext,
143
150
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
144
151
  wrapResponse: (response: Response | ResponseResult) => T,
152
+ fallback?: () => T,
145
153
  ): MaybePromise<T>
146
154
  finishProgramResult<T>(
147
155
  entry: RouteEntry,
@@ -243,6 +251,21 @@ function validAuthenticationFailureReason(
243
251
  )
244
252
  }
245
253
 
254
+ /** A failed stage still answers 503, but its cause takes the handler-error path (onError, else the
255
+ * logger) instead of vanishing. */
256
+ function failAuthentication<T>(
257
+ host: RouteProgramRuntime,
258
+ entry: RouteEntry,
259
+ ctx: RawContext,
260
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
261
+ wrapResponse: (response: Response | ResponseResult) => T,
262
+ error: unknown,
263
+ ): ProgramResult<T> {
264
+ return host.handleProgramError(entry, error, ctx, finalize, wrapResponse, () =>
265
+ finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx),
266
+ )
267
+ }
268
+
246
269
  function runAuthenticationStages<T>(
247
270
  host: RouteProgramRuntime,
248
271
  entry: RouteEntry,
@@ -287,8 +310,8 @@ function runAuthenticationStages<T>(
287
310
  let outcome: MaybePromise<import("../server/auth.ts").AuthenticationResult<unknown>>
288
311
  try {
289
312
  outcome = stage.run(input)
290
- } catch {
291
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
313
+ } catch (error) {
314
+ return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
292
315
  }
293
316
  if (outcome instanceof Promise) {
294
317
  return outcome.then(
@@ -312,7 +335,7 @@ function runAuthenticationStages<T>(
312
335
  return host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
313
336
  }
314
337
  },
315
- () => finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx),
338
+ (error) => failAuthentication(host, entry, ctx, finalize, wrapResponse, error),
316
339
  )
317
340
  }
318
341
  return finishAuthentication(
@@ -344,27 +367,23 @@ function finishAuthentication<T>(
344
367
  if (
345
368
  result === null ||
346
369
  typeof result !== "object" ||
347
- (result.kind !== "authenticated" && result.kind !== "rejected")
370
+ (result.kind === "rejected"
371
+ ? !validAuthenticationFailureReason(result.reason) ||
372
+ (result.response !== undefined &&
373
+ !(result.response instanceof Response) &&
374
+ !isResponseResult(result.response))
375
+ : result.kind !== "authenticated" ||
376
+ result.principal === null ||
377
+ result.principal === undefined)
348
378
  ) {
349
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
379
+ const id = program.authStages[next - 1]!.id
380
+ const error = new TypeError(`authenticate() stage "${id}" returned an invalid decision`)
381
+ return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
350
382
  }
351
383
  if (result.kind === "rejected") {
352
- if (!validAuthenticationFailureReason(result.reason)) {
353
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
354
- }
355
- if (
356
- result.response !== undefined &&
357
- !(result.response instanceof Response) &&
358
- !isResponseResult(result.response)
359
- ) {
360
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
361
- }
362
384
  const response = result.response ?? defaultAuthenticationResponse(result.reason)
363
385
  return finalize(response, responseSet(ctx), ctx)
364
386
  }
365
- if (result.principal === null || result.principal === undefined) {
366
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
367
- }
368
387
  Object.defineProperty(ctx, "principal", {
369
388
  value: result.principal,
370
389
  enumerable: true,
package/src/mount.ts CHANGED
@@ -76,6 +76,31 @@ export function preRouteMountPaths(app: unknown): readonly string[] {
76
76
  return paths
77
77
  }
78
78
 
79
+ const RESIDENT_BODIES = new WeakSet<Response>()
80
+
81
+ /** Set once an in-process client exists, so the leanest response lane records its bodies too. */
82
+ export let residentBodiesRecorded = false
83
+
84
+ /** Called by an in-process client: nothing else reads the record, so until then it can be skipped. */
85
+ export function recordResidentBodies(): void {
86
+ residentBodiesRecorded = true
87
+ }
88
+
89
+ /**
90
+ * Record a Response whose body the framework serialized into memory before building it. An
91
+ * in-process client reads only such a body whole; any other, a handler's own stream included, is
92
+ * read under its byte cap.
93
+ */
94
+ export function markResidentBody(response: Response): Response {
95
+ RESIDENT_BODIES.add(response)
96
+ return response
97
+ }
98
+
99
+ /** Whether {@link markResidentBody} recorded `response`. Another copy of this module did not. */
100
+ export function hasResidentBody(response: Response): boolean {
101
+ return RESIDENT_BODIES.has(response)
102
+ }
103
+
79
104
  /** Structural mount capability exposed by an in-process typed client. */
80
105
  export interface BackendMount<Env = unknown> {
81
106
  readonly [NIFRA_BACKEND_MOUNT]: BackendMountHandler<Env>
package/src/reflection.ts CHANGED
@@ -20,6 +20,9 @@ import {
20
20
  hookOriginOf,
21
21
  hookScopeOf,
22
22
  hookSourceOf,
23
+ type MergeAuthGapTag,
24
+ mergeAuthGapsOf,
25
+ routeSitesOf,
23
26
  staticHeadersOf,
24
27
  } from "./internal/hook-origins.ts"
25
28
  import {
@@ -33,6 +36,8 @@ import { NIFRA_BACKEND_EVIDENCE } from "./mount.ts"
33
36
  import type { StandardSchemaV1 } from "./schema/standard.ts"
34
37
  import type { ToolAnnotations } from "./server/server.ts"
35
38
 
39
+ export { declaredHeaderNames } from "./server/headers.ts"
40
+
36
41
  /** JSON Schema permits either a schema object or the boolean schemas `true` and `false`. */
37
42
  export type JsonSchema = boolean | Readonly<Record<string, unknown>>
38
43
 
@@ -460,7 +465,9 @@ export interface ReflectedHook {
460
465
  readonly kind: HookKind
461
466
  /** The plugin that registered it, else the function's own name, else `(anonymous)`. */
462
467
  readonly name: string
463
- /** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown`. */
468
+ /** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown`; `route`
469
+ * for a runtime the route resolved at registration (`responseContract`, `effectLedger`,
470
+ * `idempotency`), whose `name` is the contract mode or the runtime. */
464
471
  readonly site: string
465
472
  /** The header names a static `responseHeaders()` declaration sets. */
466
473
  readonly headers?: readonly string[]
@@ -559,6 +566,51 @@ const catalogOf = (
559
566
  const joinPath = (base: string, path: string): string =>
560
567
  base === "" ? path : path === "/" ? base : `${base}${path}`
561
568
 
569
+ /** A `merge()` that authenticates none of its routes under a parent that authenticates its own. */
570
+ export type ReflectedMergeAuthGap = MergeAuthGapTag
571
+
572
+ /**
573
+ * Every `merge()` in the app, its merged servers included, whose server authenticates none of its
574
+ * routes while the parent's order-scoped auth - an `authenticate()` stage, or `authenticated` evidence
575
+ * scoped to subsequent routes - covers the parent's own: that auth never runs for a merged route.
576
+ * Recorded by a development load only (the same provenance {@link reflectHooks} reads), so reflect the
577
+ * app outside production. `nifra check` reports each one (NF-C043).
578
+ */
579
+ export function reflectMergeAuthGaps(source: unknown): readonly ReflectedMergeAuthGap[] {
580
+ if (!isServerRecord(source)) return []
581
+ const gaps: ReflectedMergeAuthGap[] = []
582
+ const seen = new Set<object>()
583
+ const visit = (server: object): void => {
584
+ if (seen.has(server)) return
585
+ seen.add(server)
586
+ gaps.push(...mergeAuthGapsOf(server))
587
+ for (const adoption of adoptionsOf(server)) visit(adoption.source)
588
+ }
589
+ visit(source)
590
+ return gaps
591
+ }
592
+
593
+ /**
594
+ * Where each route was registered: `METHOD /path` (`WS` for a socket) mapped to the `file:line:col` of
595
+ * the call outside nifra's core that registered it, through every `group()` and `merge()`. Recorded only
596
+ * by a development load with `NIFRA_ROUTE_SITES=1` (the CLI sets it while it loads `nifra.assurance.ts`),
597
+ * so the map is empty otherwise. The paths are absolute, which is why they stay out of
598
+ * {@link reflectRoutes}, whose output is written into artifacts.
599
+ */
600
+ export function reflectRouteSites(source: unknown): ReadonlyMap<string, string> {
601
+ const sites = new Map<string, string>()
602
+ if (!isServerRecord(source)) return sites
603
+ const seen = new Set<object>()
604
+ const visit = (server: object): void => {
605
+ if (seen.has(server)) return
606
+ seen.add(server)
607
+ for (const [key, site] of routeSitesOf(server)) if (!sites.has(key)) sites.set(key, site)
608
+ for (const adoption of adoptionsOf(server)) visit(adoption.source)
609
+ }
610
+ visit(source)
611
+ return sites
612
+ }
613
+
562
614
  /**
563
615
  * Every hook that actually runs for each route, and for each prefix's unmatched paths, with the plugin
564
616
  * that registered it and where: the app, a `group()`, a `merge()`d server, or a `mount()`ed one. It
@@ -698,16 +750,24 @@ export function reflectHooks(source: unknown): ReflectedHookReport {
698
750
  const entry = recordOf(route.entry)
699
751
  const chain = (field: string, kind: HookKind) =>
700
752
  entry === undefined ? [] : listOf(entry, field).flatMap((hook) => entriesOf(hook, kind))
753
+ // A runtime is pinned into the route when it registers (or is merged), so it is listed with the
754
+ // route's site rather than a hook's; the mode is the name so a warn -> enforce change shows.
755
+ const mode = recordOf(recordOf(entry?.responseContract)?.runtime)?.mode
756
+ const pinned = (present: boolean, kind: HookKind, name: string): ReflectedHook[] =>
757
+ present ? [{ kind, name, site: "route" }] : []
701
758
  routes.push({
702
759
  method: route.method,
703
760
  path: reportPath(route.path),
704
761
  hooks: [
705
762
  ...requestPhase(probes),
706
763
  ...chain("around", "around"),
764
+ ...pinned(entry?.ledgered !== undefined, "effectLedger", "effectLedger"),
707
765
  ...chain("authStages", "authenticate"),
708
766
  ...chain("derives", "derive"),
709
767
  ...chain("beforeHandle", "beforeHandle"),
768
+ ...pinned(entry?.idempotent !== undefined, "idempotency", "idempotency"),
710
769
  ...chain("afterHandle", "afterHandle"),
770
+ ...pinned(typeof mode === "string", "responseContract", String(mode)),
711
771
  ...chain("onError", "onError"),
712
772
  ...responsePhase(probes),
713
773
  ],
@@ -312,9 +312,12 @@ export interface RouteSchema {
312
312
  * The schema must cover every path param; with the strict default an undeclared path param is rejected.
313
313
  * When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
314
314
  readonly params?: StandardSchemaV1
315
- /** Optional request-header schema. Header names are normalized to lower-case and the validated
316
- * output is exposed as `c.headers`; use lower-case keys because HTTP header names are
317
- * case-insensitive. */
315
+ /** Optional request-header schema; the validated output is `c.headers` and a failure is a `422`.
316
+ * Every client sends headers a route never declares (`host`, `user-agent`, `accept`), so a closed
317
+ * object schema (`t.object`) validates only the names it declares, each read case-insensitively and
318
+ * keyed as declared. `t.headers` is the open form: it receives every header, names lower-cased,
319
+ * and coerces values like `t.query`. A schema that does not expose its JSON Schema (a zod
320
+ * `.strict()` or valibot `strictObject`) receives every header, so declare it open. */
318
321
  readonly headers?: StandardSchemaV1
319
322
  /** Optional request-cookie schema. It validates the cookies parsed from the `Cookie` header -
320
323
  * names as sent, values URL-decoded strings, the first of a repeated name - before the handler
@@ -446,7 +449,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
446
449
  /** Read one request header without materializing a Web `Request` on the Node adapter hot path. */
447
450
  readonly header: (name: string) => string | null
448
451
  readonly params: ParamsOf<S, Path>
449
- /** Lower-case request headers, validated/coerced when `schema.headers` is declared. */
452
+ /** Request headers, names lower-cased; when `schema.headers` is declared, its validated output. */
450
453
  readonly headers: HeadersOf<S>
451
454
  readonly query: QueryOf<S>
452
455
  readonly body: BodyOf<S>
@@ -10,6 +10,7 @@
10
10
  */
11
11
  const registry = globalThis as unknown as Record<symbol, string[] | undefined>
12
12
  const COPIES = Symbol.for("nifra.core.copies")
13
+ const VERSIONS = Symbol.for("nifra.core.copy-versions")
13
14
  registry[COPIES] ??= []
14
15
  const copies = registry[COPIES]
15
16
  const here = String(import.meta.url).split("?")[0] as string
@@ -19,3 +20,42 @@ if (!copies.includes(here) && copies.push(here) > 1) {
19
20
  `[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}`,
20
21
  )
21
22
  }
23
+
24
+ /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
25
+ * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
26
+ const COPY_VERSION = "5.3.0"
27
+
28
+ /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
29
+ * so a production bundle drops it with the version; a copy that built a server has recorded one. */
30
+ export function recordCopyVersion(): void {
31
+ versionsByUrl().set(here, COPY_VERSION)
32
+ }
33
+
34
+ /** The process-wide URL -> version map every copy shares. */
35
+ const versionsByUrl = (): Map<unknown, unknown> => {
36
+ const found: unknown = Reflect.get(globalThis, VERSIONS)
37
+ if (found instanceof Map) return found
38
+ const created = new Map<unknown, unknown>()
39
+ Reflect.set(globalThis, VERSIONS, created)
40
+ return created
41
+ }
42
+
43
+ const labelled = (url: string): string => {
44
+ const version = versionsByUrl().get(url)
45
+ return typeof version === "string" ? `${url} (${version})` : url
46
+ }
47
+
48
+ /** Why `merge()` refused a value that is not a server of this copy: when another copy is loaded, the
49
+ * value is most likely its server, so both copies are named, with their versions where recorded
50
+ * (copies before 5.2 record none), and the way to collapse them. Called only outside production. */
51
+ export function mergeRefusal(): string {
52
+ const base = "merge() requires a server() from this copy of @nifrajs/core"
53
+ const others = copies.filter((url) => url !== here)
54
+ if (others.length === 0) return base
55
+ return [
56
+ `${base}, and ${copies.length} copies are loaded:`,
57
+ ` this copy: ${labelled(here)}`,
58
+ ...others.map((url) => ` other copy: ${labelled(url)}`),
59
+ "A server() from another copy cannot be merged. Make every package that builds a server() resolve one copy: in a linked repository, install the same nifra version (`nifra upgrade <version> --write`, then `bun install`); `nifra doctor` names each importer.",
60
+ ].join("\n")
61
+ }
@@ -9,3 +9,42 @@ export function headerObjectOf(headers: Headers): Record<string, string> {
9
9
  for (const [name, value] of headers) out[name.toLowerCase()] = value
10
10
  return out
11
11
  }
12
+
13
+ const HEADER_NAME = /^[\w!#$%&'*+.^`|~-]+$/
14
+
15
+ /** The parts of a header schema's JSON Schema that decide how the slot reads the request. */
16
+ interface HeaderJsonSchema {
17
+ readonly type?: unknown
18
+ readonly additionalProperties?: unknown
19
+ readonly patternProperties?: unknown
20
+ readonly properties?: object
21
+ }
22
+
23
+ /**
24
+ * The names a closed header schema declares, or `undefined` when it is open or lists no names.
25
+ * Every client sends headers no route declares (`host`, `user-agent`, `accept`), so a closed schema
26
+ * fed the whole record would refuse all real traffic; it is fed the names it declares instead,
27
+ * which is also how OpenAPI publishes it. A name that is not a valid header token is never sent.
28
+ */
29
+ export function declaredHeaderNames(schema: object): readonly string[] | undefined {
30
+ const json: HeaderJsonSchema | null | undefined = Reflect.get(schema, "jsonSchema")
31
+ return json?.type === "object" &&
32
+ json.additionalProperties === false &&
33
+ json.patternProperties === undefined &&
34
+ json.properties
35
+ ? Object.keys(json.properties).filter((name) => HEADER_NAME.test(name))
36
+ : undefined
37
+ }
38
+
39
+ /** The declared headers present on the request, each under the name as declared. */
40
+ export function declaredHeaders(
41
+ headers: Headers,
42
+ names: readonly string[],
43
+ ): Record<string, string> {
44
+ const out: Record<string, string> = Object.create(null)
45
+ for (const name of names) {
46
+ const value = headers.get(name)
47
+ if (value !== null) out[name] = value
48
+ }
49
+ return out
50
+ }
@@ -65,27 +65,26 @@ type IsSuccessCode<Code extends number> = `${Code}` extends `2${string}` ? true
65
65
 
66
66
  type EmptyResponseMap = NonNullable<unknown>
67
67
 
68
- type StatusBodies<Output, Codes extends number = StatusCodeOf<Output>> = [Codes] extends [never]
69
- ? EmptyResponseMap
70
- : { [Code in Codes]: StatusBodyAt<Output, Code> }
71
-
72
- /** A dynamic status code cannot be classified as success or failure; expose an opaque success arm
73
- * rather than guessing a status or body schema. Explicit route schemas still override this fallback. */
74
- type DynamicStatusResponses<Output> =
75
- Output extends StatusResponse<infer Code, unknown>
76
- ? number extends Code
77
- ? { 200: unknown }
78
- : EmptyResponseMap
79
- : EmptyResponseMap
80
-
81
- type InferredResponses<Output> = (Exclude<
68
+ type PlainOutput<Output> = Exclude<Output, StatusResponse<number, unknown> | Response>
69
+
70
+ type HasDynamicStatus<Output> =
71
+ Output extends StatusResponse<infer Code, unknown> ? (number extends Code ? true : never) : never
72
+
73
+ /** A dynamic code may be an error at runtime, but whenever it is a 2xx its body is the one passed to
74
+ * `status()`, so it joins the 200 arm (`StatusBodyAt` matches it there) instead of reading `unknown`. */
75
+ type UnnamedSuccessCode<Output> = [PlainOutput<Output> | HasDynamicStatus<Output>] extends [never]
76
+ ? never
77
+ : 200
78
+
79
+ /** One map, so a plain return and a `status(200, …)` union on the 200 arm rather than intersect. */
80
+ type InferredResponses<
82
81
  Output,
83
- StatusResponse<number, unknown> | Response
84
- > extends never
82
+ Codes extends number = StatusCodeOf<Output> | UnnamedSuccessCode<Output>,
83
+ > = [Codes] extends [never]
85
84
  ? EmptyResponseMap
86
- : { 200: Exclude<Output, StatusResponse<number, unknown> | Response> }) &
87
- StatusBodies<Output> &
88
- DynamicStatusResponses<Output>
85
+ : {
86
+ [Code in Codes]: StatusBodyAt<Output, Code> | (Code extends 200 ? PlainOutput<Output> : never)
87
+ }
89
88
 
90
89
  type ExplicitResponses<S extends RouteSchema> = (S extends {
91
90
  response: infer R extends StandardSchemaV1
@@ -5,6 +5,7 @@
5
5
  * sits below the server module in the graph.
6
6
  */
7
7
  import type { RequestBudget } from "../budget.ts"
8
+ import { markResidentBody } from "../mount.ts"
8
9
  import { applyTransportCap, readBoundedBytes } from "./body.ts"
9
10
  import { readBoundedJsonSource } from "./body-lane.ts"
10
11
  import type { Platform } from "./context.ts"
@@ -348,7 +349,7 @@ export class RequestContext implements RawContext {
348
349
  reply = defer(text, i?.status ?? 200, headers)
349
350
  }
350
351
  }
351
- reply ??= Response.json(body, i)
352
+ reply ??= markResidentBody(Response.json(body, i))
352
353
  // Remembered so a response contract holds the value to the route's schema: this is the
353
354
  // framework's JSON helper, not a raw-Response escape hatch.
354
355
  this.jsonBody = body
@@ -377,8 +378,10 @@ export class RequestContext implements RawContext {
377
378
  }
378
379
  }
379
380
  // Default to text/plain; if the caller passes their own headers, they own the content-type.
380
- if (i?.headers !== undefined) return new Response(body, i)
381
- return new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } })
381
+ if (i?.headers !== undefined) return markResidentBody(new Response(body, i))
382
+ return markResidentBody(
383
+ new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } }),
384
+ )
382
385
  }
383
386
 
384
387
  get query(): unknown {
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Dot segments in a request target, resolved the way a WHATWG URL parser resolves them.
2
+ * Dot segments in a request target, resolved the way a WHATWG URL parser resolves them, and the
3
+ * characters it percent-encodes in a path, encoded.
3
4
  *
4
5
  * Bun and workerd hand an app a request whose URL is already parsed: a client that sends
5
6
  * `/users/../posts`, `/users/%2e%2e/posts` or `/users/..\posts` is asking for `/posts`, and that
@@ -7,24 +8,28 @@
7
8
  * exactly as it was sent. Routing the raw text on those runtimes and the parsed text on the others
8
9
  * sends one request to two different handlers - with two different sets of guards - depending on
9
10
  * where the app runs. So a raw target is resolved before anything reads it, and every runtime routes
10
- * the same path.
11
+ * the same path. The same goes for a raw `/vault<tag>`: the parsed URL is `/vault%3Ctag%3E`, the
12
+ * path a route registered for that text matches.
11
13
  *
12
14
  * No imports: `@nifrajs/node` carries a generated copy of this module, since it never depends on
13
15
  * core at runtime.
14
16
  */
15
17
 
16
- // A `.` or `..` segment (`%2e` counts as a dot, in either case), or a backslash, which a WHATWG
17
- // parser reads as a `/` in an http(s) path.
18
- const DOT_SEGMENT = /\\|\/(?:\.|%2e){1,2}(?:[/?#]|$)/i
18
+ // A `.` or `..` segment (`%2e` counts as a dot, in either case), a backslash, which a WHATWG
19
+ // parser reads as a `/` in an http(s) path, or, before any `?` or `#`, a character outside the
20
+ // class the parser keeps as written in a path: a control, space, `"`, `<`, `>`, `^`, `` ` ``, `{`,
21
+ // `}`, or anything past ASCII, all of which it percent-encodes.
22
+ const DOT_SEGMENT =
23
+ /\\|\/(?:\.|%2e){1,2}(?:[/?#]|$)|^[^?#]*[^!#$%&'()*+,\-./0-9:;=?@A-Z[\\\]_a-z|~]/i
19
24
 
20
25
  /**
21
26
  * Whether `target` - an absolute URL or an origin-form `/path?query` - may hold a `.` or `..`
22
- * segment, raw or percent-encoded, or a backslash: what a WHATWG parser would rewrite. Never false
23
- * when the path has one. It can be true when only the query looks like one (`?next=/../x`):
24
- * resolving that target leaves its path and query as they are, apart from the characters a parser
25
- * always percent-encodes.
27
+ * segment, raw or percent-encoded, a backslash, or a character a WHATWG parser percent-encodes in a
28
+ * path: what that parser would rewrite. Never false when the path has one. It can be true when only
29
+ * the query looks like one (`?next=/../x`): resolving that target leaves its path and query as they
30
+ * are, apart from the characters a parser always percent-encodes.
26
31
  */
27
- export function hasDotSegment(target: string): boolean {
32
+ export function needsResolving(target: string): boolean {
28
33
  // One regex pass: measured faster on Node and Deno than substring scans that gate it.
29
34
  return DOT_SEGMENT.test(target)
30
35
  }
@@ -37,10 +42,14 @@ export function hasDotSegment(target: string): boolean {
37
42
  * `*`, or text no URL parser accepts - comes back as it arrived: there is no path to resolve.
38
43
  */
39
44
  export function resolveDotSegments(target: string): string {
45
+ // Bun, Node and workerd's parsers percent-encode a `^` in a path; Deno's keeps it as written.
40
46
  if (target.charCodeAt(0) === 47 /* / */) {
41
47
  // Appended to an origin, not resolved against one: `//host/..` must stay a path on this origin.
42
48
  const url = new URL(`http://localhost${target}`)
43
- return url.pathname + url.search
49
+ return url.pathname.replaceAll("^", "%5E") + url.search
44
50
  }
45
- return URL.canParse(target) ? new URL(target).href : target
51
+ if (!URL.canParse(target)) return target
52
+ const url = new URL(target)
53
+ url.pathname = url.pathname.replaceAll("^", "%5E")
54
+ return url.href
46
55
  }
@@ -4,6 +4,7 @@
4
4
  * spine types, so the wire-response format lives in one module distinct from the request engine.
5
5
  */
6
6
 
7
+ import { hasResidentBody, markResidentBody, residentBodiesRecorded } from "../mount.ts"
7
8
  import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
8
9
  import { plainError } from "./http.ts"
9
10
  import {
@@ -227,7 +228,8 @@ const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
227
228
 
228
229
  /**
229
230
  * Responses known to carry a body and take header writes: framework renders built with one, and
230
- * foreign ones `writableResponse` has already checked. A body is sent once, so such a response belongs
231
+ * foreign ones `writableResponse` has already checked. A body the framework serialized itself is
232
+ * recorded by `markResidentBody` instead, and counts the same. A body is sent once, so such a response belongs
231
233
  * to whichever request holds it and is written in place without reading `.body` again - on Bun that
232
234
  * read turns a string body into a stream, measured at about 6% of a request.
233
235
  */
@@ -252,10 +254,9 @@ export function rememberRenderedResponse(response: Response, hasBody: boolean):
252
254
  return response
253
255
  }
254
256
 
255
- /** `rememberRenderedResponse` for a Response the framework constructed with a body. */
257
+ /** `rememberRenderedResponse` for a Response the framework built around a body it serialized. */
256
258
  function withBody(response: Response): Response {
257
- WRITABLE_BODY_RESPONSES.add(response)
258
- return response
259
+ return markResidentBody(response)
259
260
  }
260
261
 
261
262
  const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
@@ -289,7 +290,7 @@ function headersTakeWrites(headers: Headers): boolean {
289
290
  * handed back on any other request, that copy is copied again.
290
291
  */
291
292
  export function writableResponse(response: Response, request?: Request): Response {
292
- if (WRITABLE_BODY_RESPONSES.has(response)) return response
293
+ if (hasResidentBody(response) || WRITABLE_BODY_RESPONSES.has(response)) return response
293
294
  if (response.body === null) {
294
295
  // A 101 upgrade and the status-0 `Response.error()` cannot be constructed, and a runtime makes
295
296
  // them per request.
@@ -426,7 +427,8 @@ export function fusedRespondNoSet(
426
427
  // hook walk is empty; an app that registers ANY response hook keeps the record, because the
427
428
  // alternative is `writableResponse` reading `.body` and probing the headers of every response (each
428
429
  // one is fresh, so nothing amortizes) - the probe alone measured 19% slower than the WeakSet write.
429
- // Default false: an unknown caller pays one WeakSet write rather than a probe per response.
430
+ // Default false: an unknown caller pays one WeakSet write rather than a probe per response. Once an
431
+ // in-process client exists the write is no longer dead: it reads a recorded body whole.
430
432
  if (
431
433
  unstamped &&
432
434
  (tagResponseBody === false || tagResponseBody === undefined) &&
@@ -437,7 +439,8 @@ export function fusedRespondNoSet(
437
439
  !(result instanceof Response) &&
438
440
  !isResponseResult(result)
439
441
  ) {
440
- return Response.json(result)
442
+ const response = Response.json(result)
443
+ return residentBodiesRecorded ? markResidentBody(response) : response
441
444
  }
442
445
  if (
443
446
  result !== undefined &&