@nifrajs/core 5.10.0 → 5.12.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 (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/edge-kit.d.ts +3 -2
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +3 -2
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/guard-deny.d.ts +38 -0
  7. package/dist/guard-deny.d.ts.map +1 -0
  8. package/dist/guard-deny.js +43 -0
  9. package/dist/guard-deny.js.map +1 -0
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/internal/capability-runtime.d.ts.map +1 -1
  15. package/dist/internal/capability-runtime.js +3 -2
  16. package/dist/internal/capability-runtime.js.map +1 -1
  17. package/dist/internal/route-execution.d.ts +6 -4
  18. package/dist/internal/route-execution.d.ts.map +1 -1
  19. package/dist/internal/route-execution.js.map +1 -1
  20. package/dist/internal/route-lanes.d.ts +4 -4
  21. package/dist/internal/route-lanes.d.ts.map +1 -1
  22. package/dist/internal/route-lanes.js +37 -37
  23. package/dist/internal/route-lanes.js.map +1 -1
  24. package/dist/manifest.d.ts.map +1 -1
  25. package/dist/manifest.js +2 -1
  26. package/dist/manifest.js.map +1 -1
  27. package/dist/server/body.d.ts +2 -0
  28. package/dist/server/body.d.ts.map +1 -1
  29. package/dist/server/body.js +2 -0
  30. package/dist/server/body.js.map +1 -1
  31. package/dist/server/core-copies.js +1 -1
  32. package/dist/server/idle-timeout.d.ts +1 -1
  33. package/dist/server/idle-timeout.d.ts.map +1 -1
  34. package/dist/server/idle-timeout.js.map +1 -1
  35. package/dist/server/json-response.d.ts +14 -0
  36. package/dist/server/json-response.d.ts.map +1 -1
  37. package/dist/server/json-response.js +205 -0
  38. package/dist/server/json-response.js.map +1 -1
  39. package/dist/server/server-types.d.ts +7 -1
  40. package/dist/server/server-types.d.ts.map +1 -1
  41. package/dist/server/server.d.ts +16 -24
  42. package/dist/server/server.d.ts.map +1 -1
  43. package/dist/server/server.js +285 -140
  44. package/dist/server/server.js.map +1 -1
  45. package/dist/server/static-response.d.ts +13 -0
  46. package/dist/server/static-response.d.ts.map +1 -0
  47. package/dist/server/static-response.js +28 -0
  48. package/dist/server/static-response.js.map +1 -0
  49. package/dist/server/websocket.d.ts +23 -7
  50. package/dist/server/websocket.d.ts.map +1 -1
  51. package/dist/server/websocket.js +8 -3
  52. package/dist/server/websocket.js.map +1 -1
  53. package/dist/server/ws-adapter-view.d.ts +3 -3
  54. package/dist/server/ws-adapter-view.d.ts.map +1 -1
  55. package/dist/server/ws-bun.d.ts +4 -4
  56. package/dist/server/ws-bun.d.ts.map +1 -1
  57. package/dist/server/ws-bun.js +12 -5
  58. package/dist/server/ws-bun.js.map +1 -1
  59. package/dist/server/ws-hook.d.ts +4 -4
  60. package/dist/server/ws-hook.d.ts.map +1 -1
  61. package/dist/server.d.ts +2 -1
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +1 -0
  64. package/dist/server.js.map +1 -1
  65. package/dist/single-copy-register.js +5 -3
  66. package/dist/single-copy-register.js.map +1 -1
  67. package/dist/single-copy.d.ts +1 -1
  68. package/dist/single-copy.d.ts.map +1 -1
  69. package/dist/single-copy.js +49 -2
  70. package/dist/single-copy.js.map +1 -1
  71. package/dist/tool-contract.d.ts +5 -0
  72. package/dist/tool-contract.d.ts.map +1 -1
  73. package/dist/tool-contract.js +6 -3
  74. package/dist/tool-contract.js.map +1 -1
  75. package/dist/ws.d.ts +1 -1
  76. package/dist/ws.d.ts.map +1 -1
  77. package/dist/ws.js.map +1 -1
  78. package/package.json +6 -1
  79. package/src/edge-kit.ts +3 -2
  80. package/src/guard-deny.ts +61 -0
  81. package/src/index.ts +3 -1
  82. package/src/internal/capability-runtime.ts +3 -2
  83. package/src/internal/route-execution.ts +6 -4
  84. package/src/internal/route-lanes.ts +44 -44
  85. package/src/manifest.ts +2 -1
  86. package/src/server/body.ts +3 -0
  87. package/src/server/core-copies.ts +1 -1
  88. package/src/server/idle-timeout.ts +2 -4
  89. package/src/server/json-response.ts +209 -0
  90. package/src/server/server-types.ts +7 -1
  91. package/src/server/server.ts +414 -189
  92. package/src/server/static-response.ts +36 -0
  93. package/src/server/websocket.ts +29 -8
  94. package/src/server/ws-adapter-view.ts +3 -3
  95. package/src/server/ws-bun.ts +15 -9
  96. package/src/server/ws-hook.ts +4 -3
  97. package/src/server.ts +2 -0
  98. package/src/single-copy-register.ts +5 -3
  99. package/src/single-copy.ts +51 -3
  100. package/src/tool-contract.ts +13 -3
  101. package/src/ws.ts +1 -0
@@ -2,9 +2,9 @@
2
2
  * Registration-time route-lane selection.
3
3
  *
4
4
  * A route's lane is a property of its registered shape, not of an individual request. Keeping this
5
- * decision in a small internal module gives the server kernel one vocabulary for bare/body/query/
6
- * lifecycle execution while preserving the important invariant: this module is never called from the
7
- * request path. The returned booleans are consumed by the existing compiled execution plan and fused
5
+ * decision in a small internal module gives the server kernel one vocabulary for
6
+ * bare/body/query/input/lifecycle execution. This module is never called from the request path. The
7
+ * returned booleans are consumed by the existing compiled execution plan and fused
8
8
  * builders; no adapter or extra dispatch hop is introduced at runtime.
9
9
  */
10
10
  import type { RouteSchema } from "../server/context.ts"
@@ -18,7 +18,7 @@ export interface RouteLaneSelection {
18
18
  readonly lane: RouteExecutionLane
19
19
  readonly lifecycleLane: LifecycleExecutionLane
20
20
  readonly lifecycleHookLane: "hooks" | undefined
21
- readonly fusedLane: "bare" | "body" | "query" | "hooks" | "body-hooks" | undefined
21
+ readonly fusedLane: "bare" | "body" | "query" | "inputs" | "hooks" | undefined
22
22
  }
23
23
 
24
24
  export function selectRouteLanes(options: {
@@ -52,13 +52,20 @@ export function selectRouteLanes(options: {
52
52
 
53
53
  const authBeforeValidation =
54
54
  options.hasAuth === true || schema?.validationOrder === "auth-before-validation"
55
- // No fused lane validates params, headers, or cookies: any of those schemas keeps the stage runner.
56
- const plain =
57
- schema?.params === undefined && schema?.headers === undefined && schema?.cookies === undefined
55
+ // Narrow body/query runners handle simple shapes; the shared input lane composes schemas.
56
+ const plain = schema?.headers === undefined && schema?.cookies === undefined
57
+ const noParams = schema?.params === undefined
58
+ const hasInputSchema =
59
+ schema?.headers !== undefined ||
60
+ schema?.cookies !== undefined ||
61
+ schema?.params !== undefined ||
62
+ schema?.body !== undefined ||
63
+ schema?.query !== undefined
58
64
 
59
65
  const bare =
60
66
  !authBeforeValidation &&
61
67
  plain &&
68
+ noParams &&
62
69
  schema?.body === undefined &&
63
70
  schema?.query === undefined &&
64
71
  !hasIdempotency &&
@@ -69,19 +76,18 @@ export function selectRouteLanes(options: {
69
76
  afterHandle === 0 &&
70
77
  onError === 0
71
78
 
72
- // A route whose ONLY lifecycle step is a query schema can fuse too: the parse + validate + handler +
73
- // respond collapse into one closure with no lifecycle promise on the sync path. The guards mirror the
74
- // query lane below PLUS everything the fused dispatch skips: around hooks, the idempotency/ledger
75
- // wrappers, and validation-error recovery (schema or server default) - the fused invalid path is
76
- // exactly `validationError(issues)`, so any recovery semantics keep the generic lane.
79
+ // A route whose only input stages are path params and/or a query schema can fuse too: validate +
80
+ // handler + respond collapse into one closure with no lifecycle promise on the sync path. The guards
81
+ // mirror the query lane below PLUS everything the fused dispatch skips: around hooks, the
82
+ // idempotency/ledger wrappers, and validation-error recovery (schema or server default).
77
83
  const fusedQuery =
78
84
  !bare &&
79
85
  !authBeforeValidation &&
80
86
  !hasResponseContract &&
81
- schema?.query !== undefined &&
87
+ (schema?.query !== undefined || schema?.params !== undefined) &&
82
88
  schema.body === undefined &&
83
89
  plain &&
84
- schema.onValidationError === undefined &&
90
+ schema?.onValidationError === undefined &&
85
91
  !defaultOnValidationError &&
86
92
  !hasIdempotency &&
87
93
  !hasLedger &&
@@ -112,17 +118,32 @@ export function selectRouteLanes(options: {
112
118
  !hasLedger &&
113
119
  around === 0
114
120
 
121
+ // The shared input runner handles every ordinary validation shape, including body+query and
122
+ // params+headers+cookies combinations. It also carries lifecycle and error hooks; only recovery
123
+ // callbacks or outer wrappers that observe the stage sequence need the general program.
124
+ const fusedInputs =
125
+ hasInputSchema &&
126
+ !authBeforeValidation &&
127
+ !hasResponseContract &&
128
+ schema.onValidationError === undefined &&
129
+ !defaultOnValidationError &&
130
+ !hasIdempotency &&
131
+ !hasLedger &&
132
+ decorationsAssignable &&
133
+ around === 0
134
+
115
135
  // The idempotency gate lives in the general program, so an idempotent route always runs it.
116
136
  const lane: RouteExecutionLane =
117
137
  authBeforeValidation || hasIdempotency
118
138
  ? "lifecycle"
119
139
  : bare
120
140
  ? "bare"
121
- : bodyOnly
141
+ : bodyOnly && (noParams || fusedBody)
122
142
  ? "body"
123
143
  : !hasResponseContract &&
124
144
  schema?.body === undefined &&
125
145
  schema?.query !== undefined &&
146
+ noParams &&
126
147
  plain &&
127
148
  derives === 0 &&
128
149
  beforeHandle === 0 &&
@@ -131,10 +152,8 @@ export function selectRouteLanes(options: {
131
152
  ? "query"
132
153
  : "lifecycle"
133
154
 
134
- // Lifecycle routes with no params schema are the common middleware shape: derive/before plus an
135
- // optional query or body schema. Select their complete validation stage at registration so the
136
- // request path never re-checks params/body presence. Parameter-schema routes retain the generic
137
- // lifecycle runner until their more involved recovery matrix is selected explicitly.
155
+ // Select the lifecycle stage shape at registration so the request path never re-checks which input
156
+ // schemas or hook phases are present.
138
157
  const lifecycleLane: LifecycleExecutionLane =
139
158
  lane !== "lifecycle" || !plain
140
159
  ? undefined
@@ -146,12 +165,8 @@ export function selectRouteLanes(options: {
146
165
  ? "query"
147
166
  : "hooks"
148
167
 
149
- // A route whose only lifecycle steps are derive, before and after hooks (any number of each, at least
150
- // one) runs them in one fused closure chain, with the route's decorations and error hooks. Anything
151
- // that can observe around hooks, a response contract or a validation-error recovery keeps the
152
- // generic runner. A params, headers or cookies schema
153
- // routes back to the generic lane because the fused builder has no validation step for them, and a
154
- // `body` schema is the `body-hooks` lane below because this one does not parse the body.
168
+ // A route with lifecycle hooks and no validated inputs uses the same compiled hook chain. Input
169
+ // routes use `fusedInputs` above, including when their hooks run after validation.
155
170
  const hookCount = derives + beforeHandle + afterHandle
156
171
  const lifecycleHookLane =
157
172
  lane === "lifecycle" &&
@@ -164,28 +179,11 @@ export function selectRouteLanes(options: {
164
179
  !defaultOnValidationError &&
165
180
  plain &&
166
181
  schema?.body === undefined &&
167
- hookCount > 0 &&
182
+ (hookCount > 0 || onError > 0) &&
168
183
  around === 0
169
184
  ? "hooks"
170
185
  : undefined
171
186
 
172
- // The same hooks around a body schema: the fused closure runs parse, validate, derive, before,
173
- // handler and after in one function frame, the win `fusedBody` already gives the no-hooks shape.
174
- const fusedBodyLifecycle =
175
- lane === "lifecycle" &&
176
- !authBeforeValidation &&
177
- schema?.body !== undefined &&
178
- schema?.query === undefined &&
179
- plain &&
180
- schema?.onValidationError === undefined &&
181
- !defaultOnValidationError &&
182
- !hasResponseContract &&
183
- decorationsAssignable &&
184
- !hasIdempotency &&
185
- !hasLedger &&
186
- hookCount > 0 &&
187
- around === 0
188
-
189
187
  return {
190
188
  bare,
191
189
  fusedQuery,
@@ -202,7 +200,9 @@ export function selectRouteLanes(options: {
202
200
  ? "body"
203
201
  : bare
204
202
  ? "bare"
205
- : (lifecycleHookLane ?? (fusedBodyLifecycle ? "body-hooks" : undefined))
203
+ : fusedInputs
204
+ ? "inputs"
205
+ : lifecycleHookLane
206
206
  : undefined,
207
207
  }
208
208
  }
package/src/manifest.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type RouteSnapshotSchema,
16
16
  } from "./diff.ts"
17
17
  import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "./evidence.ts"
18
+ import { validCapabilityId } from "./internal/capability-runtime.ts"
18
19
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
19
20
  import { hasControlCharacter } from "./internal/control-character.ts"
20
21
  import { sha256Hex } from "./internal/hex.ts"
@@ -368,7 +369,7 @@ export async function parseNifraManifest(
368
369
  !Array.isArray(capabilities.unproven) ||
369
370
  typeof capabilities.covered !== "boolean" ||
370
371
  [...capabilities.declared, ...capabilities.evidenced, ...capabilities.unproven].some(
371
- (value) => typeof value !== "string" || !/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(value),
372
+ (value) => typeof value !== "string" || !validCapabilityId(value),
372
373
  )
373
374
  ) {
374
375
  throw new Error(`nifra manifest: ${source} contains invalid capability material`)
@@ -10,6 +10,9 @@
10
10
  import { jsonError } from "./http.ts"
11
11
  import { carryToCopy } from "./request-local.ts"
12
12
 
13
+ /** Default request-body cap shared by the full Server and the compact edge server. */
14
+ export const DEFAULT_MAX_BODY_BYTES = 1_000_000
15
+
13
16
  interface BodySource {
14
17
  readonly headers: Pick<Headers, "get">
15
18
  readonly body: ReadableStream<Uint8Array> | null
@@ -26,7 +26,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
26
26
 
27
27
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
28
28
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
29
- const COPY_VERSION = "5.10.0"
29
+ const COPY_VERSION = "5.12.0"
30
30
 
31
31
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
32
32
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */
@@ -47,7 +47,7 @@ const longest = (scopes: readonly number[]): number =>
47
47
  * ```
48
48
  */
49
49
  export function withIdleTimeout<T>(
50
- c: Context,
50
+ c: Pick<Context, "signal">,
51
51
  seconds: number,
52
52
  work: () => MaybePromise<T>,
53
53
  ): MaybePromise<T> {
@@ -55,9 +55,7 @@ export function withIdleTimeout<T>(
55
55
  throw new RangeError("withIdleTimeout: seconds must be an integer from 0 to 255")
56
56
  }
57
57
  // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only RequestContext defines this key, with this signature; any other context leaves it undefined.
58
- const control = (c as Context & { [CONTEXT_IDLE_TIMEOUT]?: IdleTimeoutControl })[
59
- CONTEXT_IDLE_TIMEOUT
60
- ]
58
+ const control = (c as { [CONTEXT_IDLE_TIMEOUT]?: IdleTimeoutControl })[CONTEXT_IDLE_TIMEOUT]
61
59
  const signal = c.signal
62
60
  if (control === undefined || signal.aborted) return work()
63
61
  const set = (value: number | undefined): void => {
@@ -1,5 +1,214 @@
1
+ import { isRawJson } from "./json-shared.ts"
1
2
  import { retried } from "./json-text.ts"
2
3
 
4
+ /** A JSON value would exceed the caller's UTF-8 byte budget. */
5
+ export class JsonByteLimitError extends RangeError {
6
+ constructor() {
7
+ super("JSON output exceeds the byte limit")
8
+ this.name = "JsonByteLimitError"
9
+ }
10
+ }
11
+
12
+ const OMIT_JSON_VALUE = Symbol("nifra.omitJsonValue")
13
+
14
+ interface JsonBudget {
15
+ readonly maxBytes: number
16
+ usedBytes: number
17
+ }
18
+
19
+ function addJsonBytes(budget: JsonBudget, bytes: number): void {
20
+ if (bytes > budget.maxBytes - budget.usedBytes) throw new JsonByteLimitError()
21
+ budget.usedBytes += bytes
22
+ }
23
+
24
+ /** Count a quoted JSON string without allocating its escaped representation. */
25
+ function countQuotedJsonString(value: string, budget: JsonBudget): void {
26
+ addJsonBytes(budget, 2) // opening and closing quotes
27
+ for (let index = 0; index < value.length; index++) {
28
+ const code = value.charCodeAt(index)
29
+ if (
30
+ code === 0x22 ||
31
+ code === 0x5c ||
32
+ code === 0x08 ||
33
+ code === 0x09 ||
34
+ code === 0x0a ||
35
+ code === 0x0c ||
36
+ code === 0x0d
37
+ ) {
38
+ addJsonBytes(budget, 2)
39
+ } else if (code < 0x20) {
40
+ addJsonBytes(budget, 6) // \\u00xx
41
+ } else if (code >= 0xd800 && code <= 0xdbff) {
42
+ const next = value.charCodeAt(index + 1)
43
+ if (next >= 0xdc00 && next <= 0xdfff) {
44
+ addJsonBytes(budget, 4)
45
+ index++
46
+ } else {
47
+ addJsonBytes(budget, 6) // well-formed JSON escapes a lone surrogate
48
+ }
49
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
50
+ addJsonBytes(budget, 6)
51
+ } else {
52
+ addJsonBytes(budget, code < 0x80 ? 1 : code < 0x800 ? 2 : 3)
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Count the UTF-8 bytes of an already-encoded JSON fragment. */
58
+ function countRawJsonBytes(value: string, budget: JsonBudget): void {
59
+ for (let index = 0; index < value.length; index++) {
60
+ const code = value.charCodeAt(index)
61
+ const next = value.charCodeAt(index + 1)
62
+ if (code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
63
+ addJsonBytes(budget, 4)
64
+ index++
65
+ } else addJsonBytes(budget, code < 0x80 ? 1 : code < 0x800 ? 2 : 3)
66
+ }
67
+ }
68
+
69
+ function ownJsonValue(value: object, key: string): unknown {
70
+ const toJSON = Reflect.get(value, "toJSON")
71
+ return typeof toJSON === "function" ? Reflect.apply(toJSON, value, [key]) : value
72
+ }
73
+
74
+ function normalizedJsonValue(
75
+ value: unknown,
76
+ key: string,
77
+ budget: JsonBudget,
78
+ ancestors: Set<object>,
79
+ ): unknown | typeof OMIT_JSON_VALUE {
80
+ if (typeof value === "object" && value !== null && isRawJson?.(value) === true) {
81
+ const raw = Reflect.get(value, "rawJSON")
82
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The runtime guard below verifies this optional JSON.rawJSON extension is callable before use.
83
+ const rawJSON = (JSON as JSON & { readonly rawJSON?: (text: string) => unknown }).rawJSON
84
+ if (typeof raw !== "string" || typeof rawJSON !== "function")
85
+ throw new TypeError("Could not serialize JSON.rawJSON")
86
+ countRawJsonBytes(raw, budget)
87
+ return rawJSON(raw)
88
+ }
89
+ if ((typeof value === "object" && value !== null) || typeof value === "function") {
90
+ value = ownJsonValue(value, key)
91
+ } else if (typeof value === "bigint") {
92
+ const boxed = Object(value)
93
+ const toJSON = Reflect.get(boxed, "toJSON")
94
+ if (typeof toJSON === "function") value = Reflect.apply(toJSON, boxed, [key])
95
+ }
96
+
97
+ if (value === null) {
98
+ addJsonBytes(budget, 4)
99
+ return null
100
+ }
101
+ if (typeof value === "string") {
102
+ countQuotedJsonString(value, budget)
103
+ return value
104
+ }
105
+ if (typeof value === "boolean") {
106
+ addJsonBytes(budget, value ? 4 : 5)
107
+ return value
108
+ }
109
+ if (typeof value === "number") {
110
+ const text = JSON.stringify(value)!
111
+ addJsonBytes(budget, text.length) // JSON numbers are ASCII.
112
+ return value
113
+ }
114
+ if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol")
115
+ return OMIT_JSON_VALUE
116
+ if (typeof value === "bigint") throw new TypeError("Do not know how to serialize a BigInt")
117
+
118
+ // JSON.stringify unwraps primitive wrapper objects before writing them.
119
+ try {
120
+ return normalizedJsonValue(Number.prototype.valueOf.call(value), key, budget, ancestors)
121
+ } catch (error) {
122
+ if (error instanceof JsonByteLimitError) throw error
123
+ }
124
+ try {
125
+ return normalizedJsonValue(String.prototype.valueOf.call(value), key, budget, ancestors)
126
+ } catch (error) {
127
+ if (error instanceof JsonByteLimitError) throw error
128
+ }
129
+ try {
130
+ return normalizedJsonValue(Boolean.prototype.valueOf.call(value), key, budget, ancestors)
131
+ } catch (error) {
132
+ if (error instanceof JsonByteLimitError) throw error
133
+ }
134
+ try {
135
+ BigInt.prototype.valueOf.call(value)
136
+ } catch {
137
+ // Not a boxed BigInt; continue with ordinary object serialization.
138
+ return normalizeJsonObject(value, budget, ancestors)
139
+ }
140
+ throw new TypeError("Do not know how to serialize a BigInt")
141
+ }
142
+
143
+ function normalizeJsonObject(
144
+ object: object,
145
+ budget: JsonBudget,
146
+ ancestors: Set<object>,
147
+ ): unknown | typeof OMIT_JSON_VALUE {
148
+ if (ancestors.has(object)) throw new TypeError("Converting circular structure to JSON")
149
+ ancestors.add(object)
150
+ try {
151
+ if (Array.isArray(object)) {
152
+ addJsonBytes(budget, 1) // [
153
+ const result: unknown[] = Object.setPrototypeOf([], null)
154
+ const length = object.length
155
+ for (let index = 0; index < length; index++) {
156
+ if (index > 0) addJsonBytes(budget, 1)
157
+ const key = String(index)
158
+ const item = normalizedJsonValue(Reflect.get(object, key), key, budget, ancestors)
159
+ if (item === OMIT_JSON_VALUE) {
160
+ addJsonBytes(budget, 4)
161
+ result[index] = null
162
+ } else result[index] = item
163
+ }
164
+ addJsonBytes(budget, 1) // ]
165
+ return result
166
+ }
167
+
168
+ addJsonBytes(budget, 1) // {
169
+ const result: Record<string, unknown> = Object.create(null)
170
+ let first = true
171
+ for (const property of Object.keys(object)) {
172
+ const item = normalizedJsonValue(Reflect.get(object, property), property, budget, ancestors)
173
+ if (item === OMIT_JSON_VALUE) continue
174
+ if (!first) addJsonBytes(budget, 1)
175
+ countQuotedJsonString(property, budget)
176
+ addJsonBytes(budget, 1) // :
177
+ first = false
178
+ Object.defineProperty(result, property, {
179
+ configurable: true,
180
+ enumerable: true,
181
+ value: item,
182
+ writable: true,
183
+ })
184
+ }
185
+ addJsonBytes(budget, 1) // }
186
+ return result
187
+ } finally {
188
+ ancestors.delete(object)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Serialize a JSON value only when its exact UTF-8 size fits `maxBytes`. The preflight snapshots
194
+ * enumerable values and `toJSON` results while counting, so an oversized output is rejected before
195
+ * constructing the escaped JSON string or its encoded byte buffer. Returns `undefined` for a root
196
+ * value with no JSON representation, matching `JSON.stringify`.
197
+ */
198
+ export function boundedJsonText(
199
+ value: unknown,
200
+ maxBytes: number,
201
+ ): { readonly text: string; readonly byteLength: number } | undefined {
202
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
203
+ throw new RangeError("maxBytes must be a non-negative safe integer")
204
+ const budget: JsonBudget = { maxBytes, usedBytes: 0 }
205
+ const normalized = normalizedJsonValue(value, "", budget, new Set())
206
+ if (normalized === OMIT_JSON_VALUE) return undefined
207
+ const text = JSON.stringify(normalized)
208
+ if (text === undefined) return undefined
209
+ return { text, byteLength: budget.usedBytes }
210
+ }
211
+
3
212
  /** `Response.json`, with each `bigint` written as its decimal string, as `jsonText` writes it. */
4
213
  export function jsonResponse(value: unknown, init?: ResponseInit): Response {
5
214
  try {
@@ -17,7 +17,7 @@ import type {
17
17
  ResponseHeadersHook,
18
18
  } from "./node-outcome-hook.ts"
19
19
  import type { MaybePromise, OnRequestResult } from "./server.ts"
20
- import type { WebSocketUpgradeOutcome } from "./websocket.ts"
20
+ import type { TopicAdapter, WebSocketUpgradeOutcome } from "./websocket.ts"
21
21
 
22
22
  /**
23
23
  * The outcome of a capacity-admission decision. `admitted` requests carry a `release` the server calls
@@ -126,6 +126,12 @@ export interface ServerOptions {
126
126
  * the whole message.
127
127
  */
128
128
  readonly wsMaxPayloadBytes?: number
129
+ /**
130
+ * Topic membership and broadcast implementation for WebSocket routes. By default, Nifra uses an
131
+ * in-process `TopicRegistry`; pass an adapter to bridge subscriptions and publishes to a shared
132
+ * fan-out service. `app.publish()` returns a promise when the adapter publishes asynchronously.
133
+ */
134
+ readonly topicAdapter?: TopicAdapter
129
135
  /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
130
136
  readonly requestTimeoutMs?: number
131
137
  /** WebSocket upgrade admission/guard timeout (ms). Default 10_000; set to 0 only for a deliberate unbounded guard. */