@nifrajs/core 3.5.0 → 4.0.2
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/README.md +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +26 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +24 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +168 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +40 -8
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- 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 +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +41 -37
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +53 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +191 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1206 -218
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +7 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +31 -6
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +237 -12
- package/src/idempotency.ts +72 -8
- package/src/index.ts +40 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +41 -36
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +59 -2
- package/src/server/server.ts +1613 -269
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +38 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
package/src/ledger.ts
CHANGED
|
@@ -145,7 +145,7 @@ const ERROR_CODE = /^[a-z][a-z0-9_.-]{0,63}$/
|
|
|
145
145
|
const DIGEST_HEX = /^[0-9a-f]{64}$/
|
|
146
146
|
|
|
147
147
|
function printableToken(value: string, maxLength: number): boolean {
|
|
148
|
-
if (value.length === 0 || value.length > maxLength) return false
|
|
148
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return false
|
|
149
149
|
for (let index = 0; index < value.length; index++) {
|
|
150
150
|
const code = value.charCodeAt(index)
|
|
151
151
|
if (code < 33 || code > 126) return false
|
|
@@ -153,6 +153,9 @@ function printableToken(value: string, maxLength: number): boolean {
|
|
|
153
153
|
return true
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// `RegExp.test` stringifies its argument, so a check on a token's shape must first check it is a string.
|
|
157
|
+
const validDigest = (value: string): boolean => typeof value === "string" && DIGEST_HEX.test(value)
|
|
158
|
+
|
|
156
159
|
function validateCost(cost: EffectCost): EffectCost {
|
|
157
160
|
if (Object(cost) !== cost || Array.isArray(cost)) {
|
|
158
161
|
throw new TypeError("effect ledger: invalid cost")
|
|
@@ -181,7 +184,7 @@ export function normalizeEffectMetadata(input: EffectMetadata): EffectMetadata {
|
|
|
181
184
|
if (input.target !== undefined && !printableToken(input.target, TARGET_MAX_LENGTH)) {
|
|
182
185
|
throw new TypeError("effect ledger: invalid target")
|
|
183
186
|
}
|
|
184
|
-
if (input.digest !== undefined && !
|
|
187
|
+
if (input.digest !== undefined && !validDigest(input.digest)) {
|
|
185
188
|
throw new TypeError("effect ledger: invalid digest")
|
|
186
189
|
}
|
|
187
190
|
return Object.freeze({
|
|
@@ -236,7 +239,7 @@ class BoundedRequestLedger implements RequestLedger {
|
|
|
236
239
|
append(input: EffectEntryInput): EffectEntry {
|
|
237
240
|
if (this.sealResult !== undefined) throw new EffectLedgerSealedError()
|
|
238
241
|
if (this.items.length >= this.maxEntries) throw new EffectLedgerOverflowError(this.maxEntries)
|
|
239
|
-
if (!validCapabilityId(input.capability)) {
|
|
242
|
+
if (typeof input.capability !== "string" || !validCapabilityId(input.capability)) {
|
|
240
243
|
throw new TypeError(`effect ledger: invalid capability ${JSON.stringify(input.capability)}`)
|
|
241
244
|
}
|
|
242
245
|
const phase = input.phase ?? "intent"
|
|
@@ -249,10 +252,13 @@ class BoundedRequestLedger implements RequestLedger {
|
|
|
249
252
|
if (input.target !== undefined && !printableToken(input.target, TARGET_MAX_LENGTH)) {
|
|
250
253
|
throw new TypeError("effect ledger: invalid target")
|
|
251
254
|
}
|
|
252
|
-
if (input.digest !== undefined && !
|
|
255
|
+
if (input.digest !== undefined && !validDigest(input.digest)) {
|
|
253
256
|
throw new TypeError("effect ledger: invalid digest")
|
|
254
257
|
}
|
|
255
|
-
if (
|
|
258
|
+
if (
|
|
259
|
+
input.error !== undefined &&
|
|
260
|
+
(typeof input.error?.code !== "string" || !ERROR_CODE.test(input.error.code))
|
|
261
|
+
) {
|
|
256
262
|
throw new TypeError("effect ledger: error code must be a bounded lowercase token")
|
|
257
263
|
}
|
|
258
264
|
// Only the known token fields are copied - a stray `payload`-like property never survives append.
|
package/src/manifest.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type RouteSnapshotSchema,
|
|
15
15
|
} from "./diff.ts"
|
|
16
16
|
import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "./evidence.ts"
|
|
17
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
17
18
|
|
|
18
19
|
export interface NifraManifestAssurance {
|
|
19
20
|
readonly rule?: string
|
|
@@ -226,7 +227,7 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
|
|
|
226
227
|
evidence: Object.freeze(
|
|
227
228
|
[...assurance.evidence]
|
|
228
229
|
.map((item) => ({ id: item.id, source: item.source }))
|
|
229
|
-
.sort((a, b) => a.id
|
|
230
|
+
.sort((a, b) => codeUnitOrder(a.id, b.id) || codeUnitOrder(a.source, b.source)),
|
|
230
231
|
),
|
|
231
232
|
},
|
|
232
233
|
}
|
|
@@ -246,7 +247,7 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
|
|
|
246
247
|
: {}),
|
|
247
248
|
}
|
|
248
249
|
})
|
|
249
|
-
.sort((a, b) => a.path
|
|
250
|
+
.sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
|
|
250
251
|
const body = Object.freeze({ manifestVersion: 1 as const, routes: Object.freeze(routes) })
|
|
251
252
|
return Object.freeze({ ...body, contentHash: await sha256Hex(canonicalManifest(body)) })
|
|
252
253
|
}
|
|
@@ -462,44 +463,48 @@ export function diffNifraManifests(before: NifraManifest, after: NifraManifest):
|
|
|
462
463
|
before.routes.map((route) => [routeKey(route.method, route.path), route]),
|
|
463
464
|
)
|
|
464
465
|
for (const route of after.routes) {
|
|
465
|
-
const
|
|
466
|
-
if (old === undefined) continue
|
|
466
|
+
const existing = previous.get(routeKey(route.method, route.path))
|
|
467
467
|
const base = { method: route.method, path: route.path } as const
|
|
468
468
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
.
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
469
|
+
if (existing !== undefined) {
|
|
470
|
+
const oldRule = existing.assurance?.rule
|
|
471
|
+
const newRule = route.assurance?.rule
|
|
472
|
+
if (oldRule !== newRule) {
|
|
473
|
+
changes.push({
|
|
474
|
+
...base,
|
|
475
|
+
section: "assurance",
|
|
476
|
+
severity: "breaking",
|
|
477
|
+
message: `assurance rule changed from ${oldRule ?? "unclassified"} to ${newRule ?? "unclassified"}`,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
const oldEvidence = (existing.assurance?.evidence ?? [])
|
|
481
|
+
.map((item) => `${item.id}@${item.source}`)
|
|
482
|
+
.sort()
|
|
483
|
+
const newEvidence = (route.assurance?.evidence ?? [])
|
|
484
|
+
.map((item) => `${item.id}@${item.source}`)
|
|
485
|
+
.sort()
|
|
486
|
+
const assuranceDelta = listChanges(oldEvidence, newEvidence)
|
|
487
|
+
for (const value of assuranceDelta.added) {
|
|
488
|
+
changes.push({
|
|
489
|
+
...base,
|
|
490
|
+
section: "assurance",
|
|
491
|
+
severity: "compatible",
|
|
492
|
+
message: `assurance evidence added ${value}`,
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
for (const value of assuranceDelta.removed) {
|
|
496
|
+
changes.push({
|
|
497
|
+
...base,
|
|
498
|
+
section: "assurance",
|
|
499
|
+
severity: "breaking",
|
|
500
|
+
message: `assurance evidence removed ${value}`,
|
|
501
|
+
})
|
|
502
|
+
}
|
|
501
503
|
}
|
|
502
504
|
|
|
505
|
+
// A new route is diffed against an empty one: the capabilities it declares and the data it returns
|
|
506
|
+
// expand risk as surely as the same change to a route that already existed.
|
|
507
|
+
const old: Partial<NifraManifestRoute> = existing ?? {}
|
|
503
508
|
for (const field of ["declared", "evidenced", "unproven"] as const) {
|
|
504
509
|
const delta = listChanges(old.capabilities?.[field] ?? [], route.capabilities?.[field] ?? [])
|
|
505
510
|
for (const value of delta.added) {
|
package/src/mount.ts
CHANGED
|
@@ -7,18 +7,80 @@
|
|
|
7
7
|
* in-process mount.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { ProjectEvidenceSnapshot } from "./evidence.ts"
|
|
10
11
|
import type { Platform } from "./server/context.ts"
|
|
12
|
+
import type { WebSocketUpgradeOutcome } from "./server/websocket.ts"
|
|
11
13
|
|
|
12
14
|
/** Global symbol so independently bundled copies of core/client/web still agree on the mount seam. */
|
|
13
15
|
export const NIFRA_BACKEND_MOUNT = Symbol.for("@nifrajs/backend-mount")
|
|
14
16
|
|
|
17
|
+
/** Optional symbol-keyed upgrade seam carried by an in-process client mount. */
|
|
18
|
+
export const NIFRA_BACKEND_WS_MOUNT = Symbol.for("@nifrajs/backend-ws-mount")
|
|
19
|
+
|
|
20
|
+
/** Internal runtime-provider seam used when an in-process WebSocket backend is mounted in Bun. */
|
|
21
|
+
export const NIFRA_BACKEND_WS_RUNTIME = Symbol.for("@nifrajs/backend-ws-runtime")
|
|
22
|
+
|
|
23
|
+
/** Optional offline assurance/evidence seam for composed applications. */
|
|
24
|
+
export const NIFRA_BACKEND_EVIDENCE = Symbol.for("@nifrajs/backend-evidence")
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Optional request-scoping seam carried by an in-process typed client. Called with one request's
|
|
28
|
+
* {@link Platform}, it returns a view of the same typed client whose calls dispatch with that platform,
|
|
29
|
+
* so a backend reached from an SSR loader sees the page visitor's `c.clientIp`, `c.env` and
|
|
30
|
+
* `c.waitUntil`. The view carries platform fields only - never the page request's headers.
|
|
31
|
+
*/
|
|
32
|
+
export const NIFRA_BACKEND_BIND_PLATFORM = Symbol.for("@nifrajs/backend-bind-platform")
|
|
33
|
+
|
|
34
|
+
export { NIFRA_PLATFORM_CLIENT_IP_DERIVED } from "./server/client-ip.ts"
|
|
35
|
+
|
|
15
36
|
/** Dispatch one already-materialized request into a backend with its outer runtime platform context. */
|
|
16
37
|
export type BackendMountHandler<Env = unknown> = (
|
|
17
38
|
request: Request,
|
|
18
39
|
platform?: Platform<Env>,
|
|
19
40
|
) => Response | Promise<Response>
|
|
20
41
|
|
|
42
|
+
/** Adapter-neutral WebSocket upgrade resolver for a mounted backend. */
|
|
43
|
+
export type BackendWebSocketMountHandler<Env = unknown> = (
|
|
44
|
+
request: Request,
|
|
45
|
+
platform?: Platform<Env>,
|
|
46
|
+
) => WebSocketUpgradeOutcome | Promise<WebSocketUpgradeOutcome>
|
|
47
|
+
|
|
48
|
+
/** Opaque provider kept structural so public mount types do not expose the WS runtime internals. */
|
|
49
|
+
export type BackendWebSocketRuntimeProvider = () => unknown
|
|
50
|
+
|
|
51
|
+
/** Returns a view of an in-process typed client bound to one request's platform. */
|
|
52
|
+
export type BackendPlatformBinder<Env = unknown> = (platform: Platform<Env>) => unknown
|
|
53
|
+
|
|
54
|
+
/** Produces token-only route/evidence facts; it is never called from request dispatch. */
|
|
55
|
+
export type BackendEvidenceProvider = () =>
|
|
56
|
+
| ProjectEvidenceSnapshot
|
|
57
|
+
| Promise<ProjectEvidenceSnapshot>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The path of every pre-route `mount()` on a nifra server, as the server matches it (`"/"` for a root
|
|
61
|
+
* mount). Such a mount answers every request under its path before the app's own routes run, and
|
|
62
|
+
* `fallbackOn: 404` only moves on to the next mount, so a route the app declares there is unreachable.
|
|
63
|
+
* Reads the server's own mount table, as `reflectMounts` does; a value that is not a nifra server
|
|
64
|
+
* yields `[]`. Never called from request dispatch.
|
|
65
|
+
*/
|
|
66
|
+
export function preRouteMountPaths(app: unknown): readonly string[] {
|
|
67
|
+
const mounts = (app as { readonly fetchMounts?: unknown } | null | undefined)?.fetchMounts
|
|
68
|
+
if (!Array.isArray(mounts)) return []
|
|
69
|
+
const paths: string[] = []
|
|
70
|
+
for (const mount of mounts as ReadonlyArray<{
|
|
71
|
+
readonly path?: unknown
|
|
72
|
+
readonly beforeRoutes?: unknown
|
|
73
|
+
} | null>) {
|
|
74
|
+
if (mount?.beforeRoutes === true && typeof mount.path === "string") paths.push(mount.path)
|
|
75
|
+
}
|
|
76
|
+
return paths
|
|
77
|
+
}
|
|
78
|
+
|
|
21
79
|
/** Structural mount capability exposed by an in-process typed client. */
|
|
22
80
|
export interface BackendMount<Env = unknown> {
|
|
23
81
|
readonly [NIFRA_BACKEND_MOUNT]: BackendMountHandler<Env>
|
|
82
|
+
readonly [NIFRA_BACKEND_WS_MOUNT]?: BackendWebSocketMountHandler<Env>
|
|
83
|
+
readonly [NIFRA_BACKEND_WS_RUNTIME]?: BackendWebSocketRuntimeProvider
|
|
84
|
+
readonly [NIFRA_BACKEND_EVIDENCE]?: BackendEvidenceProvider
|
|
85
|
+
readonly [NIFRA_BACKEND_BIND_PLATFORM]?: BackendPlatformBinder<Env>
|
|
24
86
|
}
|
package/src/reflection.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
evidenceProvenance,
|
|
19
19
|
validEvidence,
|
|
20
20
|
} from "./internal/route-assurance.ts"
|
|
21
|
+
import { NIFRA_BACKEND_EVIDENCE } from "./mount.ts"
|
|
21
22
|
import type { StandardSchemaV1 } from "./schema/standard.ts"
|
|
22
23
|
import type { ToolAnnotations } from "./server/server.ts"
|
|
23
24
|
|
|
@@ -39,14 +40,25 @@ export interface SchemaReflection {
|
|
|
39
40
|
readonly jsonSchema: JsonSchema | undefined
|
|
40
41
|
/** Top-level object fields, or `undefined` when the JSON Schema is absent/non-object. */
|
|
41
42
|
readonly fields: readonly ReflectedSchemaField[] | undefined
|
|
43
|
+
/**
|
|
44
|
+
* The media types a body schema reads through a parser of its own (`bodyParser`), beyond the
|
|
45
|
+
* JSON and urlencoded bodies every body schema reads. Absent when it declares none.
|
|
46
|
+
*/
|
|
47
|
+
readonly mediaTypes?: readonly string[]
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export interface ReflectedRouteSchema {
|
|
51
|
+
/** Explicit external-protocol mode; the handler owns raw request parsing. */
|
|
52
|
+
readonly wire?: "raw"
|
|
53
|
+
/** Validation/authentication ordering selected for this route. */
|
|
54
|
+
readonly validationOrder?: "validate-before-auth" | "auth-before-validation"
|
|
45
55
|
/** Effective transport body policy; surfaced so upload/streaming exemptions are auditable. */
|
|
46
56
|
readonly bodyLimit?: number | "unlimited"
|
|
47
57
|
readonly bodyLimitReason?: string
|
|
48
58
|
/** Request-header schema; names are normalized to lower-case at runtime. */
|
|
49
59
|
readonly headers?: SchemaReflection
|
|
60
|
+
/** Request-cookie schema, validated against the parsed `Cookie` header. */
|
|
61
|
+
readonly cookies?: SchemaReflection
|
|
50
62
|
readonly body?: SchemaReflection
|
|
51
63
|
readonly query?: SchemaReflection
|
|
52
64
|
/** Path-params schema - constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
|
|
@@ -61,6 +73,8 @@ export interface ReflectedRoute {
|
|
|
61
73
|
readonly method: string
|
|
62
74
|
readonly path: string
|
|
63
75
|
readonly schema?: ReflectedRouteSchema
|
|
76
|
+
/** Runtime response-contract mode, when a response-contract plugin covered the route. */
|
|
77
|
+
readonly responseContract?: "warn" | "enforce"
|
|
64
78
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
65
79
|
readonly capabilities?: readonly string[]
|
|
66
80
|
/** Set when the route is a declared dynamic route family (a runtime-resolved template like
|
|
@@ -146,6 +160,24 @@ const fieldsOf = (schema: JsonSchema | undefined): readonly ReflectedSchemaField
|
|
|
146
160
|
return fields
|
|
147
161
|
}
|
|
148
162
|
|
|
163
|
+
// The brand a body schema carries its own reader under. Spelled here rather than imported so that
|
|
164
|
+
// reflection does not pull the body lane into a bundle that only describes routes.
|
|
165
|
+
const BODY_READER = Symbol.for("nifra.body.schemaReader")
|
|
166
|
+
|
|
167
|
+
const mediaTypesOf = (value: unknown): readonly string[] | undefined => {
|
|
168
|
+
if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined
|
|
169
|
+
try {
|
|
170
|
+
const reader = (value as Record<symbol, unknown>)[BODY_READER]
|
|
171
|
+
if (typeof reader !== "function") return undefined
|
|
172
|
+
const declared = (reader as { readonly mediaTypes?: unknown }).mediaTypes
|
|
173
|
+
if (!Array.isArray(declared)) return undefined
|
|
174
|
+
const types = declared.filter((type): type is string => typeof type === "string")
|
|
175
|
+
return types.length > 0 ? Object.freeze(types) : undefined
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
/**
|
|
150
182
|
* Reflect a Standard Schema, Nifra/TypeBox schema carrier, or raw JSON Schema. Never throws.
|
|
151
183
|
* Validation-only schemas have `standard` but no `jsonSchema`; raw JSON Schema has the reverse.
|
|
@@ -153,7 +185,13 @@ const fieldsOf = (schema: JsonSchema | undefined): readonly ReflectedSchemaField
|
|
|
153
185
|
export function reflectSchema(value: unknown): SchemaReflection {
|
|
154
186
|
const standard = standardOf(value)
|
|
155
187
|
const jsonSchema = jsonSchemaOf(value, standard)
|
|
156
|
-
|
|
188
|
+
const mediaTypes = mediaTypesOf(value)
|
|
189
|
+
return {
|
|
190
|
+
standard,
|
|
191
|
+
jsonSchema,
|
|
192
|
+
fields: fieldsOf(jsonSchema),
|
|
193
|
+
...(mediaTypes !== undefined ? { mediaTypes } : {}),
|
|
194
|
+
}
|
|
157
195
|
}
|
|
158
196
|
|
|
159
197
|
const routeCandidates = (source: unknown): readonly unknown[] => {
|
|
@@ -179,6 +217,11 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
|
|
|
179
217
|
}
|
|
180
218
|
}
|
|
181
219
|
return {
|
|
220
|
+
...(schema.wire === "raw" ? { wire: "raw" as const } : {}),
|
|
221
|
+
...(schema.validationOrder === "auth-before-validation" ||
|
|
222
|
+
schema.validationOrder === "validate-before-auth"
|
|
223
|
+
? { validationOrder: schema.validationOrder }
|
|
224
|
+
: {}),
|
|
182
225
|
...(schema.bodyLimit === "unlimited" || typeof schema.bodyLimit === "number"
|
|
183
226
|
? { bodyLimit: schema.bodyLimit }
|
|
184
227
|
: {}),
|
|
@@ -186,6 +229,7 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
|
|
|
186
229
|
? { bodyLimitReason: schema.bodyLimitReason }
|
|
187
230
|
: {}),
|
|
188
231
|
...(schema.headers !== undefined ? { headers: reflectSchema(schema.headers) } : {}),
|
|
232
|
+
...(schema.cookies !== undefined ? { cookies: reflectSchema(schema.cookies) } : {}),
|
|
189
233
|
...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
|
|
190
234
|
...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
|
|
191
235
|
...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
|
|
@@ -242,6 +286,9 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
|
|
|
242
286
|
method: route.method.toUpperCase(),
|
|
243
287
|
path: route.path,
|
|
244
288
|
...(schema !== undefined ? { schema } : {}),
|
|
289
|
+
...(route.responseContract === "warn" || route.responseContract === "enforce"
|
|
290
|
+
? { responseContract: route.responseContract }
|
|
291
|
+
: {}),
|
|
245
292
|
...(assurance !== undefined ? { assurance } : {}),
|
|
246
293
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
247
294
|
...(route.family === true ? { family: true } : {}),
|
|
@@ -251,3 +298,39 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
|
|
|
251
298
|
}
|
|
252
299
|
return reflected
|
|
253
300
|
}
|
|
301
|
+
|
|
302
|
+
/** A mounted child (`mount()`, `mountFetch()`) whose routes route reflection cannot see. */
|
|
303
|
+
export interface ReflectedMount {
|
|
304
|
+
/** The mount prefix with `/*`: the child serves every path under it. */
|
|
305
|
+
readonly path: string
|
|
306
|
+
/** Why the child is not analyzed, as declared by `opaque` on the mount. */
|
|
307
|
+
readonly opaque?: string
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The mounts on an app that route reflection cannot see into, sorted by path. A mount whose app
|
|
312
|
+
* publishes composed evidence (the API `createWebApp` mounts) is left out: its routes reach
|
|
313
|
+
* reflection through that evidence. Anything that is not a nifra server yields an empty list.
|
|
314
|
+
*/
|
|
315
|
+
export function reflectMounts(source: unknown): readonly ReflectedMount[] {
|
|
316
|
+
// `fetchMounts` is the server's own mount table, read here rather than exposed through a method so
|
|
317
|
+
// apps that never reflect pay nothing for it.
|
|
318
|
+
const mounts = recordOf(source)?.fetchMounts
|
|
319
|
+
if (!Array.isArray(mounts)) return []
|
|
320
|
+
const reflected: ReflectedMount[] = []
|
|
321
|
+
for (const candidate of mounts) {
|
|
322
|
+
const mount = recordOf(candidate)
|
|
323
|
+
if (typeof mount?.path !== "string") continue
|
|
324
|
+
const app = mount.app as { readonly [NIFRA_BACKEND_EVIDENCE]?: unknown } | undefined
|
|
325
|
+
if (typeof app?.[NIFRA_BACKEND_EVIDENCE] === "function") continue
|
|
326
|
+
// Checked here, at analysis time, so serving pays nothing: a blank reason is no reason.
|
|
327
|
+
const opaque = typeof mount.opaque === "string" ? mount.opaque.trim() || undefined : undefined
|
|
328
|
+
reflected.push(
|
|
329
|
+
Object.freeze({
|
|
330
|
+
path: mount.path === "/" ? "/*" : `${mount.path}/*`,
|
|
331
|
+
...(opaque !== undefined ? { opaque } : {}),
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
return reflected.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
336
|
+
}
|
package/src/response-observer.ts
CHANGED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CompiledRoutePattern,
|
|
3
|
+
compileRoutePattern,
|
|
4
|
+
expandOptionalParams,
|
|
5
|
+
type MixedPart,
|
|
6
|
+
type ParamConstraint,
|
|
7
|
+
} from "./pattern.ts"
|
|
8
|
+
|
|
9
|
+
type Token =
|
|
10
|
+
| { readonly kind: "char"; readonly value: string }
|
|
11
|
+
| { readonly kind: "param"; readonly c?: ParamConstraint }
|
|
12
|
+
| { readonly kind: "wildcard" }
|
|
13
|
+
|
|
14
|
+
interface State {
|
|
15
|
+
readonly index: number
|
|
16
|
+
readonly active?: "param" | "wildcard"
|
|
17
|
+
/**
|
|
18
|
+
* Inside a parameter constrained to a character class: how many characters it has taken. Once the
|
|
19
|
+
* class has no upper bound the count stops at the lower one, where it no longer changes anything.
|
|
20
|
+
*/
|
|
21
|
+
readonly taken?: number
|
|
22
|
+
/** Inside a parameter constrained to a list of values: what is left of each value still possible. */
|
|
23
|
+
readonly live?: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProductNode {
|
|
27
|
+
readonly left: State
|
|
28
|
+
readonly right: State
|
|
29
|
+
readonly previous?: string
|
|
30
|
+
readonly character?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Hard budget for the offline route-language intersection. Never use this on the request path. */
|
|
34
|
+
export const ROUTE_PATTERN_OVERLAP_MAX_STATES = 100_000
|
|
35
|
+
const ROUTE_PATTERN_OVERLAP_MAX_LENGTH = 16_384
|
|
36
|
+
|
|
37
|
+
export class RoutePatternOverlapLimitError extends Error {
|
|
38
|
+
readonly code = "ROUTE_OVERLAP_LIMIT"
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
super("route overlap analysis exceeded its safety budget")
|
|
42
|
+
this.name = "RoutePatternOverlapLimitError"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stateKey = (state: State): string =>
|
|
47
|
+
`${state.index}:${state.active ?? "none"}:${state.taken ?? ""}:${state.live?.join("|") ?? ""}`
|
|
48
|
+
const productKey = (left: State, right: State): string => `${stateKey(left)}|${stateKey(right)}`
|
|
49
|
+
|
|
50
|
+
function pushLiteral(tokens: Token[], value: string): void {
|
|
51
|
+
for (const character of value) tokens.push({ kind: "char", value: character })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pushMixed(tokens: Token[], parts: readonly MixedPart[]): void {
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
if (part.t === "lit") pushLiteral(tokens, part.v)
|
|
57
|
+
else tokens.push(part.c === undefined ? { kind: "param" } : { kind: "param", c: part.c })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tokensOf(pattern: CompiledRoutePattern): readonly Token[] {
|
|
62
|
+
const tokens: Token[] = [{ kind: "char", value: "/" }]
|
|
63
|
+
pattern.segments.forEach((segment, index) => {
|
|
64
|
+
if (index > 0) tokens.push({ kind: "char", value: "/" })
|
|
65
|
+
if (segment.kind === "static") pushLiteral(tokens, segment.value)
|
|
66
|
+
else if (segment.kind === "param") tokens.push({ kind: "param" })
|
|
67
|
+
else if (segment.kind === "wildcard") tokens.push({ kind: "wildcard" })
|
|
68
|
+
else pushMixed(tokens, segment.parts)
|
|
69
|
+
})
|
|
70
|
+
return tokens
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function epsilon(state: State, tokens: readonly Token[]): State | undefined {
|
|
74
|
+
if (state.active === undefined) return undefined
|
|
75
|
+
// A constrained parameter ends only where its constraint is met: a whole value from the list, or
|
|
76
|
+
// at least the class's lower bound of characters.
|
|
77
|
+
if (state.live?.includes("") === false) return undefined
|
|
78
|
+
const token = tokens[state.index]
|
|
79
|
+
if (state.taken !== undefined && token?.kind === "param" && state.taken < token.c!.min) {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
return { index: state.index + 1 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function advance(state: State, character: string, tokens: readonly Token[]): State | undefined {
|
|
86
|
+
if (state.active === "wildcard") return state
|
|
87
|
+
const token = tokens[state.index]
|
|
88
|
+
if (token === undefined) return undefined
|
|
89
|
+
if (token.kind === "char") {
|
|
90
|
+
return token.value === character ? { index: state.index + 1 } : undefined
|
|
91
|
+
}
|
|
92
|
+
if (token.kind === "wildcard") return { index: state.index, active: "wildcard" }
|
|
93
|
+
if (character === "/") return undefined
|
|
94
|
+
const constraint = token.c
|
|
95
|
+
if (constraint === undefined) {
|
|
96
|
+
return state.active === "param" ? state : { index: state.index, active: "param" }
|
|
97
|
+
}
|
|
98
|
+
if (constraint.oneOf !== undefined) {
|
|
99
|
+
const live = (state.live ?? constraint.oneOf)
|
|
100
|
+
.filter((value) => value[0] === character)
|
|
101
|
+
.map((value) => value.slice(1))
|
|
102
|
+
return live.length === 0 ? undefined : { index: state.index, active: "param", live }
|
|
103
|
+
}
|
|
104
|
+
const taken = state.taken ?? 0
|
|
105
|
+
const code = character.charCodeAt(0)
|
|
106
|
+
if (taken >= constraint.max || constraint.mask.charCodeAt(code) !== 49 /* 1 */) return undefined
|
|
107
|
+
return {
|
|
108
|
+
index: state.index,
|
|
109
|
+
active: "param",
|
|
110
|
+
taken: constraint.max === Infinity ? Math.min(taken + 1, constraint.min) : taken + 1,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The exact characters that can advance `state`, or `undefined` when it accepts a character class. */
|
|
115
|
+
function exactCharacters(state: State, tokens: readonly Token[]): readonly string[] | undefined {
|
|
116
|
+
if (state.active === "wildcard") return undefined
|
|
117
|
+
const token = tokens[state.index]
|
|
118
|
+
if (token === undefined) return []
|
|
119
|
+
if (token.kind === "char") return [token.value]
|
|
120
|
+
if (token.kind === "wildcard") return undefined
|
|
121
|
+
const values = state.live ?? token.c?.oneOf
|
|
122
|
+
return values === undefined
|
|
123
|
+
? undefined
|
|
124
|
+
: [...new Set(values.flatMap((value) => (value === "" ? [] : [value[0]!])))]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// "a" first, so an unconstrained parameter is still witnessed by the letter it always was.
|
|
128
|
+
const CLASS_REPRESENTATIVES = ["a"]
|
|
129
|
+
for (let code = 33; code < 127; code++) {
|
|
130
|
+
if (code !== 47 /* / */ && code !== 97 /* a */) {
|
|
131
|
+
CLASS_REPRESENTATIVES.push(String.fromCharCode(code))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Return the finite representative alphabet for this product state. A global alphabet makes a
|
|
137
|
+
* route containing many literal characters quadratic in the number of states. At each state only
|
|
138
|
+
* the exact characters one side asks for can affect the next state, or - when both sides take a
|
|
139
|
+
* class of characters - one character that both classes hold: where a class leads does not depend on
|
|
140
|
+
* which of its characters was taken. Slash is additionally needed only when both sides are
|
|
141
|
+
* wildcards because it can move through a segment boundary that no parameter may consume.
|
|
142
|
+
*/
|
|
143
|
+
function transitionCharacters(
|
|
144
|
+
left: State,
|
|
145
|
+
right: State,
|
|
146
|
+
leftTokens: readonly Token[],
|
|
147
|
+
rightTokens: readonly Token[],
|
|
148
|
+
): readonly string[] {
|
|
149
|
+
const exact = exactCharacters(left, leftTokens) ?? exactCharacters(right, rightTokens)
|
|
150
|
+
if (exact !== undefined) return exact
|
|
151
|
+
const both = (character: string): boolean =>
|
|
152
|
+
advance(left, character, leftTokens) !== undefined &&
|
|
153
|
+
advance(right, character, rightTokens) !== undefined
|
|
154
|
+
const shared = CLASS_REPRESENTATIVES.find(both)
|
|
155
|
+
const characters = shared === undefined ? [] : [shared]
|
|
156
|
+
if (both("/")) characters.push("/")
|
|
157
|
+
return characters
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function accepted(state: State, tokens: readonly Token[]): boolean {
|
|
161
|
+
return state.active === undefined && state.index === tokens.length
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function witnessOf(nodes: ReadonlyMap<string, ProductNode>, key: string): string {
|
|
165
|
+
const characters: string[] = []
|
|
166
|
+
let current = key
|
|
167
|
+
while (true) {
|
|
168
|
+
const node = nodes.get(current)
|
|
169
|
+
if (node === undefined || node.previous === undefined) break
|
|
170
|
+
if (node.character !== undefined) characters.push(node.character)
|
|
171
|
+
current = node.previous
|
|
172
|
+
}
|
|
173
|
+
characters.reverse()
|
|
174
|
+
return characters.join("")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Return a deterministic path accepted by both patterns, or `undefined` when their path languages are
|
|
179
|
+
* disjoint. A pattern ending in optional params is every concrete path it serves, so `/users/:id?`
|
|
180
|
+
* overlaps `/users` as well as `/users/me`.
|
|
181
|
+
*
|
|
182
|
+
* This is a build/check-time NFA product, never a request-time operation. Route literals are the only
|
|
183
|
+
* input alphabet needed: a literal character is tried verbatim, while `a` is a representative for
|
|
184
|
+
* the unrestricted non-slash character class. No user route text is compiled as a regular expression.
|
|
185
|
+
*
|
|
186
|
+
* A constrained parameter is modelled as its constraint: a value from its list, or a run of its
|
|
187
|
+
* class within its bounds. In a segment that has several parameters the router is stricter than
|
|
188
|
+
* that - it places the literals first and tests the values afterwards - so for such a segment the
|
|
189
|
+
* answer errs towards reporting an overlap that no request can reach, never towards missing one.
|
|
190
|
+
* The state budget is one budget for the whole call, however many concrete paths the two sides have.
|
|
191
|
+
*/
|
|
192
|
+
export function routePatternOverlap(left: string, right: string): string | undefined {
|
|
193
|
+
if (
|
|
194
|
+
left.length > ROUTE_PATTERN_OVERLAP_MAX_LENGTH ||
|
|
195
|
+
right.length > ROUTE_PATTERN_OVERLAP_MAX_LENGTH
|
|
196
|
+
)
|
|
197
|
+
throw new RoutePatternOverlapLimitError()
|
|
198
|
+
const rights = expandOptionalParams(right).map((form) => tokensOf(compileRoutePattern(form)))
|
|
199
|
+
const budget = { states: ROUTE_PATTERN_OVERLAP_MAX_STATES }
|
|
200
|
+
for (const form of expandOptionalParams(left)) {
|
|
201
|
+
const leftTokens = tokensOf(compileRoutePattern(form))
|
|
202
|
+
for (const rightTokens of rights) {
|
|
203
|
+
const witness = tokenOverlap(leftTokens, rightTokens, budget)
|
|
204
|
+
if (witness !== undefined) return witness
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function tokenOverlap(
|
|
211
|
+
leftTokens: readonly Token[],
|
|
212
|
+
rightTokens: readonly Token[],
|
|
213
|
+
budget: { states: number },
|
|
214
|
+
): string | undefined {
|
|
215
|
+
const startLeft: State = { index: 0 }
|
|
216
|
+
const startRight: State = { index: 0 }
|
|
217
|
+
const start = productKey(startLeft, startRight)
|
|
218
|
+
if (budget.states-- <= 0) throw new RoutePatternOverlapLimitError()
|
|
219
|
+
const nodes = new Map<string, ProductNode>([[start, { left: startLeft, right: startRight }]])
|
|
220
|
+
const queue: string[] = [start]
|
|
221
|
+
let head = 0
|
|
222
|
+
|
|
223
|
+
const enqueue = (next: string, node: ProductNode): void => {
|
|
224
|
+
if (nodes.has(next)) return
|
|
225
|
+
if (budget.states-- <= 0) throw new RoutePatternOverlapLimitError()
|
|
226
|
+
nodes.set(next, node)
|
|
227
|
+
queue.push(next)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
while (head < queue.length) {
|
|
231
|
+
const key = queue[head++]!
|
|
232
|
+
const node = nodes.get(key)!
|
|
233
|
+
if (accepted(node.left, leftTokens) && accepted(node.right, rightTokens)) {
|
|
234
|
+
return witnessOf(nodes, key)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const transitions: Array<{ readonly left: State; readonly right: State }> = []
|
|
238
|
+
const leftEpsilon = epsilon(node.left, leftTokens)
|
|
239
|
+
const rightEpsilon = epsilon(node.right, rightTokens)
|
|
240
|
+
if (leftEpsilon !== undefined) transitions.push({ left: leftEpsilon, right: node.right })
|
|
241
|
+
if (rightEpsilon !== undefined) transitions.push({ left: node.left, right: rightEpsilon })
|
|
242
|
+
|
|
243
|
+
for (const transition of transitions) {
|
|
244
|
+
const next = productKey(transition.left, transition.right)
|
|
245
|
+
enqueue(next, { ...transition, previous: key })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const character of transitionCharacters(node.left, node.right, leftTokens, rightTokens)) {
|
|
249
|
+
const nextLeft = advance(node.left, character, leftTokens)
|
|
250
|
+
const nextRight = advance(node.right, character, rightTokens)
|
|
251
|
+
if (nextLeft === undefined || nextRight === undefined) continue
|
|
252
|
+
const next = productKey(nextLeft, nextRight)
|
|
253
|
+
enqueue(next, { left: nextLeft, right: nextRight, previous: key, character })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return undefined
|
|
258
|
+
}
|