@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.
- package/CHANGELOG.md +24 -0
- package/dist/edge-kit.d.ts +3 -2
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +3 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/guard-deny.d.ts +38 -0
- package/dist/guard-deny.d.ts.map +1 -0
- package/dist/guard-deny.js +43 -0
- package/dist/guard-deny.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/capability-runtime.d.ts.map +1 -1
- package/dist/internal/capability-runtime.js +3 -2
- package/dist/internal/capability-runtime.js.map +1 -1
- package/dist/internal/route-execution.d.ts +6 -4
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +4 -4
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +37 -37
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +2 -1
- package/dist/manifest.js.map +1 -1
- package/dist/server/body.d.ts +2 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +2 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/idle-timeout.d.ts +1 -1
- package/dist/server/idle-timeout.d.ts.map +1 -1
- package/dist/server/idle-timeout.js.map +1 -1
- package/dist/server/json-response.d.ts +14 -0
- package/dist/server/json-response.d.ts.map +1 -1
- package/dist/server/json-response.js +205 -0
- package/dist/server/json-response.js.map +1 -1
- package/dist/server/server-types.d.ts +7 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +16 -24
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +285 -140
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-response.d.ts +13 -0
- package/dist/server/static-response.d.ts.map +1 -0
- package/dist/server/static-response.js +28 -0
- package/dist/server/static-response.js.map +1 -0
- package/dist/server/websocket.d.ts +23 -7
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +8 -3
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-adapter-view.d.ts +3 -3
- package/dist/server/ws-adapter-view.d.ts.map +1 -1
- package/dist/server/ws-bun.d.ts +4 -4
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +12 -5
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +4 -4
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -3
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +49 -2
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +5 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +6 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/ws.d.ts +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js.map +1 -1
- package/package.json +6 -1
- package/src/edge-kit.ts +3 -2
- package/src/guard-deny.ts +61 -0
- package/src/index.ts +3 -1
- package/src/internal/capability-runtime.ts +3 -2
- package/src/internal/route-execution.ts +6 -4
- package/src/internal/route-lanes.ts +44 -44
- package/src/manifest.ts +2 -1
- package/src/server/body.ts +3 -0
- package/src/server/core-copies.ts +1 -1
- package/src/server/idle-timeout.ts +2 -4
- package/src/server/json-response.ts +209 -0
- package/src/server/server-types.ts +7 -1
- package/src/server/server.ts +414 -189
- package/src/server/static-response.ts +36 -0
- package/src/server/websocket.ts +29 -8
- package/src/server/ws-adapter-view.ts +3 -3
- package/src/server/ws-bun.ts +15 -9
- package/src/server/ws-hook.ts +4 -3
- package/src/server.ts +2 -0
- package/src/single-copy-register.ts +5 -3
- package/src/single-copy.ts +51 -3
- package/src/tool-contract.ts +13 -3
- 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
|
|
6
|
-
* lifecycle execution
|
|
7
|
-
*
|
|
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" | "
|
|
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
|
-
//
|
|
56
|
-
const plain =
|
|
57
|
-
|
|
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
|
|
73
|
-
// respond collapse into one closure with no lifecycle promise on the sync path. The guards
|
|
74
|
-
// query lane below PLUS everything the fused dispatch skips: around hooks, the
|
|
75
|
-
// wrappers, and validation-error recovery (schema or server default)
|
|
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
|
|
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
|
-
//
|
|
135
|
-
//
|
|
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
|
|
150
|
-
//
|
|
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
|
-
:
|
|
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" ||
|
|
372
|
+
(value) => typeof value !== "string" || !validCapabilityId(value),
|
|
372
373
|
)
|
|
373
374
|
) {
|
|
374
375
|
throw new Error(`nifra manifest: ${source} contains invalid capability material`)
|
package/src/server/body.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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. */
|