@nifrajs/core 5.1.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2409 -0
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +2 -0
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/hook-origins.d.ts +17 -1
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +20 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/internal/route-program.d.ts +2 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +22 -17
- package/dist/internal/route-program.js.map +1 -1
- package/dist/mount.d.ts +12 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +20 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +23 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +57 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +7 -4
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/core-copies.d.ts +7 -1
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +37 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/headers.d.ts +9 -0
- package/dist/server/headers.d.ts.map +1 -1
- package/dist/server/headers.js +26 -0
- package/dist/server/headers.js.map +1 -1
- package/dist/server/registry.d.ts +8 -10
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-target.d.ts +9 -7
- package/dist/server/request-target.d.ts.map +1 -1
- package/dist/server/request-target.js +21 -12
- package/dist/server/request-target.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +10 -7
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +37 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +278 -32
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +7 -4
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +17 -4
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +184 -40
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +4 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +46 -5
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +8 -4
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +4 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +3 -2
- package/src/edge-kit.ts +1 -1
- package/src/idempotency.ts +2 -0
- package/src/index.ts +1 -1
- package/src/internal/hook-origins.ts +35 -0
- package/src/internal/route-program.ts +39 -20
- package/src/mount.ts +25 -0
- package/src/reflection.ts +61 -1
- package/src/server/context.ts +7 -4
- package/src/server/core-copies.ts +40 -0
- package/src/server/headers.ts +39 -0
- package/src/server/registry.ts +18 -19
- package/src/server/request-context.ts +6 -3
- package/src/server/request-target.ts +21 -12
- package/src/server/respond.ts +10 -7
- package/src/server/response-contract-lane.ts +357 -42
- package/src/server/server-types.ts +7 -4
- package/src/server/server.ts +235 -35
- package/src/server/websocket.ts +3 -0
- package/src/single-copy.ts +55 -5
- package/src/tool-contract.ts +7 -4
- package/src/transport-codec.ts +4 -1
package/src/server/server.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
leavePlugin,
|
|
24
24
|
tagAdoption,
|
|
25
25
|
tagHook,
|
|
26
|
+
tagMergeAuthGap,
|
|
27
|
+
tagRouteSite,
|
|
26
28
|
tagScoped,
|
|
27
29
|
tagStatic,
|
|
28
30
|
} from "../internal/hook-origins.ts"
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
type AssuranceDeclaration,
|
|
31
33
|
assuranceDeclarationsOf,
|
|
32
34
|
assuranceEvidenceFor,
|
|
35
|
+
NIFRA_ASSURANCE_IDS,
|
|
33
36
|
} from "../internal/route-assurance.ts"
|
|
34
37
|
import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
|
|
35
38
|
import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.ts"
|
|
@@ -67,20 +70,20 @@ import {
|
|
|
67
70
|
type RawBodyReaders,
|
|
68
71
|
UNLIMITED_BODY_BYTES,
|
|
69
72
|
} from "./body.ts"
|
|
73
|
+
import type { FormOrigins } from "./body-lane.ts"
|
|
70
74
|
import {
|
|
71
75
|
type ClientIpTrust,
|
|
72
76
|
NIFRA_PLATFORM_CLIENT_IP_DERIVED,
|
|
73
77
|
resolveClientIp,
|
|
74
78
|
} from "./client-ip.ts"
|
|
75
79
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
76
|
-
import "./core-copies.ts"
|
|
77
|
-
import type { FormOrigins } from "./body-lane.ts"
|
|
80
|
+
import { mergeRefusal, recordCopyVersion } from "./core-copies.ts"
|
|
78
81
|
import {
|
|
79
82
|
hasLowercaseHeaderKeysMark,
|
|
80
83
|
headerKeysAllLowercase,
|
|
81
84
|
markLowercaseHeaderKeys,
|
|
82
85
|
} from "./header-case.ts"
|
|
83
|
-
import { headerObjectOf } from "./headers.ts"
|
|
86
|
+
import { declaredHeaders, headerObjectOf } from "./headers.ts"
|
|
84
87
|
import {
|
|
85
88
|
isRoutableMethod,
|
|
86
89
|
jsonError,
|
|
@@ -110,7 +113,7 @@ import {
|
|
|
110
113
|
readBodyFramed,
|
|
111
114
|
} from "./request-context.ts"
|
|
112
115
|
import { recordRequestReplacement } from "./request-lineage.ts"
|
|
113
|
-
import {
|
|
116
|
+
import { needsResolving, resolveDotSegments } from "./request-target.ts"
|
|
114
117
|
import {
|
|
115
118
|
applyStaticResponseHeaders,
|
|
116
119
|
buildStaticResponseHeaders,
|
|
@@ -580,14 +583,21 @@ export { plainValidationError } from "./validation.ts"
|
|
|
580
583
|
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
581
584
|
export { type QueryValue, queryObjectOf, searchOf }
|
|
582
585
|
|
|
586
|
+
// Outside the printable ASCII a parsed path keeps as written: a control, space, `"`, `#`, `<`, `>`,
|
|
587
|
+
// `\`, `^`, `` ` ``, `{`, `}`, or past ASCII (U+FFFD, an invalid escape, included). Bun's table reads
|
|
588
|
+
// a raw `#` and what follows as path, where the parsed URL ends the path there.
|
|
589
|
+
const UNROUTED_CHAR = /[^!$%&'()*+,\-./0-9:;=?@A-Z[\]_a-z|~]/
|
|
590
|
+
|
|
583
591
|
/**
|
|
584
592
|
* A param value Bun's native route table matched but the portable router must decide: an invalid
|
|
585
593
|
* escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
|
|
586
|
-
* segment (Bun hands `%2e` over decoded),
|
|
594
|
+
* segment (Bun hands `%2e` over decoded), a backslash, which the parsed URL reads as `/`, a `#`,
|
|
595
|
+
* where the parsed URL's path ends (`/a/..#/b` is `/`), or a character the parsed URL
|
|
596
|
+
* percent-encodes (`/t/<b>` is `/t/%3Cb%3E`, which a static route for that text matches). The value
|
|
597
|
+
* is decoded, so a raw `<` and a sent `%3C` look alike; both are re-routed.
|
|
587
598
|
*/
|
|
588
599
|
const unroutedParam = (value: string | undefined): boolean =>
|
|
589
|
-
value !== undefined &&
|
|
590
|
-
(value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..")
|
|
600
|
+
value !== undefined && (value === "." || value === ".." || UNROUTED_CHAR.test(value))
|
|
591
601
|
|
|
592
602
|
function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
593
603
|
for (const key in params) {
|
|
@@ -602,9 +612,19 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
|
602
612
|
*/
|
|
603
613
|
const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
604
614
|
|
|
605
|
-
/** `req` as Bun or workerd would have delivered it: the same request, its URL
|
|
615
|
+
/** `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it. */
|
|
606
616
|
const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegments(req.url), req)
|
|
607
617
|
|
|
618
|
+
// A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
|
|
619
|
+
// `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
|
|
620
|
+
// request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
|
|
621
|
+
const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i
|
|
622
|
+
|
|
623
|
+
const invalidHost = (req: Request): boolean => {
|
|
624
|
+
const host = req.headers.get("host")
|
|
625
|
+
return host !== null && !HOST_AUTHORITY.test(host)
|
|
626
|
+
}
|
|
627
|
+
|
|
608
628
|
function normalizeMountPrefix(path: string): string {
|
|
609
629
|
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
610
630
|
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
|
|
@@ -787,6 +807,7 @@ const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
|
787
807
|
// node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
|
|
788
808
|
// throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
|
|
789
809
|
// off) while a production define still folds `<flag> && false` to `false` on every other runtime.
|
|
810
|
+
// It must live in this module: imported from another, Bun leaves `<flag>,` at every folded guard.
|
|
790
811
|
const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
|
|
791
812
|
|
|
792
813
|
// This module's own path, captured once from a sentinel stack. Every internal frame of an
|
|
@@ -797,9 +818,10 @@ const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
|
|
|
797
818
|
return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
|
|
798
819
|
})()
|
|
799
820
|
|
|
800
|
-
/** One `file:line:col` frame for the caller of an order-scoped hook method
|
|
801
|
-
* report. Only ever called behind
|
|
802
|
-
* non-trivial cost) is never constructed
|
|
821
|
+
/** One `file:line:col` frame for the caller of an order-scoped hook method or a route registration,
|
|
822
|
+
* for the unused-hook report and route provenance. Only ever called behind a development guard that
|
|
823
|
+
* also requires the feature to be on, so the `Error` (the sole non-trivial cost) is never constructed
|
|
824
|
+
* otherwise. */
|
|
803
825
|
const captureCallerSite = (): string | undefined => {
|
|
804
826
|
const stack = new Error().stack
|
|
805
827
|
if (stack === undefined) return undefined
|
|
@@ -823,9 +845,19 @@ interface HookAuditState {
|
|
|
823
845
|
readonly hasCustomLogger: boolean
|
|
824
846
|
readonly sites: {
|
|
825
847
|
readonly kind: string
|
|
848
|
+
/** Routes that captured the chain so far: merged routes excluded, they keep their own. */
|
|
826
849
|
readonly routesAtPush: number
|
|
850
|
+
readonly mergedAtPush: number
|
|
851
|
+
readonly site: string | undefined
|
|
852
|
+
}[]
|
|
853
|
+
/** Registration-time plugins installed after routes they would have covered. */
|
|
854
|
+
readonly lateInstalls: {
|
|
855
|
+
readonly plugin: string
|
|
827
856
|
readonly site: string | undefined
|
|
857
|
+
readonly routes: readonly string[]
|
|
828
858
|
}[]
|
|
859
|
+
/** Routes adopted by `merge()`, which keep the chain of the server that declared them. */
|
|
860
|
+
merged: number
|
|
829
861
|
checked: boolean
|
|
830
862
|
}
|
|
831
863
|
const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
|
|
@@ -860,7 +892,14 @@ const beginHookAudit = (
|
|
|
860
892
|
policy: "warn" | "error" | "off",
|
|
861
893
|
hasCustomLogger: boolean,
|
|
862
894
|
): void => {
|
|
863
|
-
hookAudit.set(key, {
|
|
895
|
+
hookAudit.set(key, {
|
|
896
|
+
policy,
|
|
897
|
+
hasCustomLogger,
|
|
898
|
+
sites: [],
|
|
899
|
+
lateInstalls: [],
|
|
900
|
+
merged: 0,
|
|
901
|
+
checked: false,
|
|
902
|
+
})
|
|
864
903
|
}
|
|
865
904
|
|
|
866
905
|
/** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
|
|
@@ -877,7 +916,62 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
|
|
|
877
916
|
}
|
|
878
917
|
const state = hookAudit.get(key)
|
|
879
918
|
if (state === undefined || state.policy === "off") return
|
|
880
|
-
state.sites.push({
|
|
919
|
+
state.sites.push({
|
|
920
|
+
kind,
|
|
921
|
+
routesAtPush: routesAtPush - state.merged,
|
|
922
|
+
mergedAtPush: state.merged,
|
|
923
|
+
site: captureCallerSite(),
|
|
924
|
+
})
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
|
|
928
|
+
* followed only by merges covers nothing. */
|
|
929
|
+
const noteMergedRoutes = (key: object, count: number): void => {
|
|
930
|
+
const state = hookAudit.get(key)
|
|
931
|
+
if (state !== undefined) state.merged += count
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Record a `merge()` whose server authenticates none of its routes while this server's order-scoped
|
|
935
|
+
* auth - `authenticate()` stages, or `authenticated` evidence scoped to subsequent routes - would
|
|
936
|
+
* cover some of them. A server that authenticates any of its own routes chose its own auth, so its
|
|
937
|
+
* unauthenticated routes are taken as public on purpose. `nifra check` reports what this records. */
|
|
938
|
+
const noteMergeAuthGap = (
|
|
939
|
+
target: object,
|
|
940
|
+
authStages: readonly RawAuthStage[],
|
|
941
|
+
active: readonly AssuranceDeclaration[],
|
|
942
|
+
routes: readonly CatalogRoute[],
|
|
943
|
+
sourceGlobal: readonly AssuranceDeclaration[],
|
|
944
|
+
): void => {
|
|
945
|
+
const parentAuth = active.filter((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
|
|
946
|
+
if (authStages.length === 0 && parentAuth.length === 0) return
|
|
947
|
+
const authenticates = (route: CatalogRoute): boolean =>
|
|
948
|
+
route.assurance.some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED) ||
|
|
949
|
+
assuranceEvidenceFor(sourceGlobal, route.method, route.path).some(
|
|
950
|
+
(item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED,
|
|
951
|
+
)
|
|
952
|
+
if (routes.some(authenticates)) return
|
|
953
|
+
const covered = routes.filter(
|
|
954
|
+
(route) =>
|
|
955
|
+
authStages.length > 0 ||
|
|
956
|
+
assuranceEvidenceFor(parentAuth, route.method, route.path).length > 0,
|
|
957
|
+
)
|
|
958
|
+
if (covered.length === 0) return
|
|
959
|
+
tagMergeAuthGap(target, {
|
|
960
|
+
site: captureCallerSite(),
|
|
961
|
+
auth: [
|
|
962
|
+
...authStages.map((stage) => `authenticate(${stage.id})`),
|
|
963
|
+
...new Set(parentAuth.map((item) => item.source)),
|
|
964
|
+
],
|
|
965
|
+
routes: covered.map((route) => `${route.method} ${route.path}`),
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Record a registration-time plugin (`responseContract()`, `effectLedger()`) installed after routes
|
|
970
|
+
* it would have covered, named as `METHOD /path`. Reported with the dead hooks at seal. */
|
|
971
|
+
const recordLateInstall = (key: object, plugin: string, routes: readonly string[]): void => {
|
|
972
|
+
const state = hookAudit.get(key)
|
|
973
|
+
if (state === undefined || state.policy === "off" || routes.length === 0) return
|
|
974
|
+
state.lateInstalls.push({ plugin, site: captureCallerSite(), routes })
|
|
881
975
|
}
|
|
882
976
|
|
|
883
977
|
/** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
|
|
@@ -914,20 +1008,46 @@ const sealHookAudit = (
|
|
|
914
1008
|
if (state === undefined || state.checked) return
|
|
915
1009
|
state.checked = true
|
|
916
1010
|
if (state.policy === "off" || total + sockets === 0) return
|
|
1011
|
+
const own = total - state.merged
|
|
917
1012
|
const dead = state.sites.filter(
|
|
918
1013
|
(hook) =>
|
|
919
1014
|
hook.routesAtPush ===
|
|
920
1015
|
(hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
|
|
921
|
-
?
|
|
922
|
-
:
|
|
1016
|
+
? own
|
|
1017
|
+
: own + sockets),
|
|
923
1018
|
)
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1019
|
+
const parts: string[] = []
|
|
1020
|
+
if (dead.length > 0) {
|
|
1021
|
+
const mergedAfter = (hook: (typeof dead)[number]): boolean => state.merged > hook.mergedAtPush
|
|
1022
|
+
const detail = dead
|
|
1023
|
+
.map(
|
|
1024
|
+
(hook) =>
|
|
1025
|
+
` ${hook.kind}() at ${hook.site ?? "<unknown>"}${mergedAfter(hook) ? " (only merge()d routes follow it)" : ""}`,
|
|
1026
|
+
)
|
|
1027
|
+
.join("\n")
|
|
1028
|
+
parts.push(
|
|
1029
|
+
`[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
|
|
1030
|
+
`last route was registered, and register() snapshots the chain into each route as it is ` +
|
|
1031
|
+
`declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
|
|
1032
|
+
`(onRequest/onResponse) are not order-scoped and can be added at any time.` +
|
|
1033
|
+
(dead.some(mergedAfter)
|
|
1034
|
+
? " A merge()d route keeps the hooks of the server that declared it and never runs this " +
|
|
1035
|
+
"server's: wire the hook on the merged server, or declare those routes in a group()."
|
|
1036
|
+
: ""),
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
for (const late of state.lateInstalls) {
|
|
1040
|
+
const shown = late.routes.slice(0, 5).join(", ")
|
|
1041
|
+
const more = late.routes.length > 5 ? ` and ${late.routes.length - 5} more` : ""
|
|
1042
|
+
parts.push(
|
|
1043
|
+
`[nifra] ${late.plugin}() at ${late.site ?? "<unknown>"} was installed after ` +
|
|
1044
|
+
`${late.routes.length} route(s) it would have covered: ${shown}${more}. It is decided per ` +
|
|
1045
|
+
`route at registration and merge(), so those routes run without it. Install it before the ` +
|
|
1046
|
+
`routes and merge() calls it should cover.`,
|
|
1047
|
+
)
|
|
1048
|
+
}
|
|
1049
|
+
if (parts.length === 0) return
|
|
1050
|
+
const message = parts.join("\n")
|
|
931
1051
|
if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
|
|
932
1052
|
if (state.hasCustomLogger) logger.warn(message)
|
|
933
1053
|
else console.warn(message)
|
|
@@ -1177,6 +1297,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1177
1297
|
// for the bundler to eliminate; a non-bundled production run skips it the same way.
|
|
1178
1298
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
1179
1299
|
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
|
|
1300
|
+
recordCopyVersion()
|
|
1180
1301
|
}
|
|
1181
1302
|
this.derives = []
|
|
1182
1303
|
this.authStages = []
|
|
@@ -1232,6 +1353,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1232
1353
|
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
1233
1354
|
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
1234
1355
|
* Mounted responses are outside Nifra's typed route and response-contract checks.
|
|
1356
|
+
*
|
|
1357
|
+
* It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
|
|
1358
|
+
* request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
|
|
1359
|
+
* mounted child app, or keep the legacy server running beside this one for them.
|
|
1235
1360
|
*/
|
|
1236
1361
|
mountFetch(
|
|
1237
1362
|
path: string,
|
|
@@ -1369,6 +1494,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1369
1494
|
* Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
|
|
1370
1495
|
* route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
|
|
1371
1496
|
* security gate and lets the route compiler retain a protected fast lane.
|
|
1497
|
+
*
|
|
1498
|
+
* A stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, fails
|
|
1499
|
+
* closed with 503 `{ ok: false, error: "auth_unavailable" }`. The cause goes where a handler's error
|
|
1500
|
+
* goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger. A
|
|
1501
|
+
* thrown `Response` or `status(...)` is sent as thrown, as it is from a handler.
|
|
1372
1502
|
*/
|
|
1373
1503
|
authenticate<Principal>(
|
|
1374
1504
|
stage: AuthenticationStage<Principal, EnvOf<Ctx>>,
|
|
@@ -1387,8 +1517,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1387
1517
|
throw new TypeError('authenticate() stage mode must be "sync" or "async"')
|
|
1388
1518
|
}
|
|
1389
1519
|
this.authStages.push(stage as unknown as RawAuthStage)
|
|
1390
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1520
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
1521
|
+
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "authenticate")
|
|
1391
1522
|
tagHook(stage, "authenticate", this)
|
|
1523
|
+
}
|
|
1392
1524
|
return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
|
|
1393
1525
|
}
|
|
1394
1526
|
|
|
@@ -1505,9 +1637,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1505
1637
|
return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
|
|
1506
1638
|
}
|
|
1507
1639
|
|
|
1508
|
-
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500
|
|
1509
|
-
* Order-scoped: captured by routes
|
|
1510
|
-
* route reaches nothing (see
|
|
1640
|
+
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
|
|
1641
|
+
* `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
|
|
1642
|
+
* declared *after* this call, none before; one added after the last route reaches nothing (see
|
|
1643
|
+
* {@link ServerOptions.unusedScopedHooks}). */
|
|
1511
1644
|
onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
|
|
1512
1645
|
fn: H,
|
|
1513
1646
|
): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
|
|
@@ -2261,6 +2394,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2261
2394
|
schema: RouteSchema | undefined,
|
|
2262
2395
|
handler: (context: never) => unknown,
|
|
2263
2396
|
): CatalogRoute {
|
|
2397
|
+
// Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
|
|
2398
|
+
// app: the module that registered the route, read where a static scan cannot see the path.
|
|
2399
|
+
if (
|
|
2400
|
+
hookAuditRuntime &&
|
|
2401
|
+
process.env.NODE_ENV !== "production" &&
|
|
2402
|
+
process.env.NIFRA_ROUTE_SITES === "1"
|
|
2403
|
+
) {
|
|
2404
|
+
const site = captureCallerSite()
|
|
2405
|
+
if (site !== undefined) tagRouteSite(this, `${method.toUpperCase()} ${path}`, site)
|
|
2406
|
+
}
|
|
2264
2407
|
const compiled = compileRouteOptions(
|
|
2265
2408
|
{
|
|
2266
2409
|
maxBodyBytes: this.maxBodyBytes,
|
|
@@ -2461,6 +2604,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2461
2604
|
[INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
|
|
2462
2605
|
this.assertConfigurable("responseContract()")
|
|
2463
2606
|
this.responseContractRuntime = runtime
|
|
2607
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2608
|
+
recordLateInstall(
|
|
2609
|
+
this,
|
|
2610
|
+
"responseContract",
|
|
2611
|
+
this.catalog
|
|
2612
|
+
.entries()
|
|
2613
|
+
.filter(
|
|
2614
|
+
({ entry }) =>
|
|
2615
|
+
entry.responseContract === undefined &&
|
|
2616
|
+
(entry.schema?.response !== undefined || entry.schema?.errors !== undefined),
|
|
2617
|
+
)
|
|
2618
|
+
.map((route) => `${route.method} ${route.path}`),
|
|
2619
|
+
)
|
|
2464
2620
|
}
|
|
2465
2621
|
|
|
2466
2622
|
/** @internal Symbol-keyed install seam for the response observer plugin. */
|
|
@@ -2610,6 +2766,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2610
2766
|
*
|
|
2611
2767
|
* The response contract and the ledger can only add a check or a record, so a merged route is
|
|
2612
2768
|
* recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
|
|
2769
|
+
*
|
|
2770
|
+
* In development, {@link ServerOptions.unusedScopedHooks} reports a hook followed only by merges and
|
|
2771
|
+
* a contract or ledger installed after the routes it would cover; `nifra check` reports a merge whose
|
|
2772
|
+
* server authenticates none of its routes under this server's `authenticate()` (NF-C043).
|
|
2613
2773
|
*/
|
|
2614
2774
|
merge<R2 extends Registry, Ctx2, HookOutput2>(
|
|
2615
2775
|
other: Server<R2, Ctx2, HookOutput2>,
|
|
@@ -2618,7 +2778,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2618
2778
|
if (!(other instanceof Server)) {
|
|
2619
2779
|
// A server from another copy of this package reaches this copy's private request state through
|
|
2620
2780
|
// symbols it does not share, and would fail per request. The boot warning names the copies.
|
|
2621
|
-
throw new TypeError(
|
|
2781
|
+
throw new TypeError(
|
|
2782
|
+
hookAuditRuntime && process.env.NODE_ENV !== "production"
|
|
2783
|
+
? mergeRefusal()
|
|
2784
|
+
: "merge() requires a server() from this copy of @nifrajs/core",
|
|
2785
|
+
)
|
|
2622
2786
|
}
|
|
2623
2787
|
if (this.routePrefix !== "") {
|
|
2624
2788
|
// The merged routes were compiled under their own paths; adopting them here would publish them
|
|
@@ -2635,7 +2799,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2635
2799
|
"merge() does not carry WebSocket routes or mounts - register them on the parent server",
|
|
2636
2800
|
)
|
|
2637
2801
|
}
|
|
2802
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2803
|
+
noteMergeAuthGap(
|
|
2804
|
+
this,
|
|
2805
|
+
this.authStages,
|
|
2806
|
+
this.activeAssurance,
|
|
2807
|
+
source.catalog.entries(),
|
|
2808
|
+
source.globalAssurance,
|
|
2809
|
+
)
|
|
2638
2810
|
this.adopt(source, undefined, 0)
|
|
2811
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2812
|
+
noteMergedRoutes(this, source.catalog.size)
|
|
2639
2813
|
return this as unknown as Server<R & R2, Ctx, HookOutput>
|
|
2640
2814
|
}
|
|
2641
2815
|
|
|
@@ -3002,7 +3176,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3002
3176
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
3003
3177
|
// Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
|
|
3004
3178
|
// Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
|
|
3005
|
-
if (rawRequestTargets
|
|
3179
|
+
if (rawRequestTargets) {
|
|
3180
|
+
if (invalidHost(req)) return jsonError(400, "invalid_host")
|
|
3181
|
+
if (needsResolving(req.url)) req = withResolvedTarget(req)
|
|
3182
|
+
}
|
|
3006
3183
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
3007
3184
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
3008
3185
|
if (this.trustBodyFraming) markTrustedBodyFraming(req)
|
|
@@ -3497,8 +3674,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3497
3674
|
if (req.method !== "GET") return WS_PASS
|
|
3498
3675
|
// The adapter answers a pass by handing this same object to `fetch`.
|
|
3499
3676
|
const received = req
|
|
3500
|
-
// The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
|
|
3501
|
-
if (rawRequestTargets
|
|
3677
|
+
// The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
|
|
3678
|
+
if (rawRequestTargets) {
|
|
3679
|
+
if (invalidHost(req)) {
|
|
3680
|
+
return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
|
|
3681
|
+
}
|
|
3682
|
+
if (needsResolving(req.url)) req = withResolvedTarget(req)
|
|
3683
|
+
}
|
|
3502
3684
|
// The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
|
|
3503
3685
|
platform = this.trustedPlatform(req, platform)
|
|
3504
3686
|
|
|
@@ -4318,6 +4500,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4318
4500
|
[INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void {
|
|
4319
4501
|
this.assertConfigurable("effectLedger()")
|
|
4320
4502
|
this.effectLedgerRuntime = runtime
|
|
4503
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
4504
|
+
recordLateInstall(
|
|
4505
|
+
this,
|
|
4506
|
+
"effectLedger",
|
|
4507
|
+
this.catalog
|
|
4508
|
+
.entries()
|
|
4509
|
+
.filter(
|
|
4510
|
+
({ entry, descriptor }) =>
|
|
4511
|
+
entry.ledgered === undefined && (descriptor.capabilities?.length ?? 0) > 0,
|
|
4512
|
+
)
|
|
4513
|
+
.map((route) => `${route.method} ${route.path}`),
|
|
4514
|
+
)
|
|
4321
4515
|
}
|
|
4322
4516
|
|
|
4323
4517
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
@@ -5640,7 +5834,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5640
5834
|
if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
|
|
5641
5835
|
const input =
|
|
5642
5836
|
stage.kind === "headers"
|
|
5643
|
-
?
|
|
5837
|
+
? stage.names === undefined
|
|
5838
|
+
? headerObjectOf(source.headers)
|
|
5839
|
+
: declaredHeaders(source.headers, stage.names)
|
|
5644
5840
|
: stage.kind === "query"
|
|
5645
5841
|
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
5646
5842
|
: ctx[stage.kind]
|
|
@@ -5669,8 +5865,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5669
5865
|
ctx: RawContext,
|
|
5670
5866
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
5671
5867
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5868
|
+
fallback?: () => T,
|
|
5672
5869
|
): MaybePromise<T> {
|
|
5673
|
-
return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
|
|
5870
|
+
return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse, fallback)
|
|
5674
5871
|
}
|
|
5675
5872
|
|
|
5676
5873
|
/** Finish a successful program result through response-contract enforcement and finalization. */
|
|
@@ -6140,6 +6337,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6140
6337
|
ctx: RawContext,
|
|
6141
6338
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
6142
6339
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
6340
|
+
// The answer when no hook does; the default 500 unless a stage owns a different fail-closed status.
|
|
6341
|
+
fallback?: () => T,
|
|
6143
6342
|
): MaybePromise<T> {
|
|
6144
6343
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
6145
6344
|
// an action throws an error page. Send it as the same Response returned would be sent (Remix/
|
|
@@ -6155,9 +6354,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6155
6354
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
6156
6355
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
6157
6356
|
this.logRequestError(err, ctx)
|
|
6158
|
-
return wrapResponse(plainError(500, "internal_error"))
|
|
6357
|
+
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
|
|
6159
6358
|
}
|
|
6160
|
-
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse)
|
|
6359
|
+
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback)
|
|
6161
6360
|
}
|
|
6162
6361
|
|
|
6163
6362
|
/** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
|
|
@@ -6168,6 +6367,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6168
6367
|
ctx: RawContext,
|
|
6169
6368
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
6170
6369
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
6370
|
+
fallback?: () => T,
|
|
6171
6371
|
): Promise<T> {
|
|
6172
6372
|
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
6173
6373
|
// the `await` and stay heap-allocated on both engines.
|
|
@@ -6188,7 +6388,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6188
6388
|
}
|
|
6189
6389
|
}
|
|
6190
6390
|
this.logRequestError(err, ctx)
|
|
6191
|
-
return wrapResponse(plainError(500, "internal_error"))
|
|
6391
|
+
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
|
|
6192
6392
|
}
|
|
6193
6393
|
|
|
6194
6394
|
/** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
|
package/src/server/websocket.ts
CHANGED
|
@@ -60,6 +60,9 @@ export class TopicRegistry {
|
|
|
60
60
|
private readonly memberships = new WeakMap<NifraWebSocket, Set<string>>()
|
|
61
61
|
|
|
62
62
|
subscribe(topic: string, ws: NifraWebSocket): void {
|
|
63
|
+
// CLOSING or CLOSED: close has run, or is about to run, `unsubscribeAll`, so an async handler's
|
|
64
|
+
// late subscribe would hold the dead socket for good.
|
|
65
|
+
if (ws.readyState >= 2) return
|
|
63
66
|
let subs = this.topics.get(topic)
|
|
64
67
|
if (subs === undefined) {
|
|
65
68
|
subs = new Set()
|
package/src/single-copy.ts
CHANGED
|
@@ -666,6 +666,36 @@ const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string =
|
|
|
666
666
|
return lines.join("\n")
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
/** The `node_modules` folder a copy was installed into: `/kit/node_modules/@scope/pkg` -> `/kit/node_modules`. */
|
|
670
|
+
const modulesDirOf = (copy: string): string => {
|
|
671
|
+
const marker = `${sep}node_modules${sep}`
|
|
672
|
+
const at = copy.lastIndexOf(marker)
|
|
673
|
+
return at === -1 ? dirname(copy) : copy.slice(0, at + marker.length - 1)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Version skews that share both trees and both versions, as one report: a linked repository on
|
|
678
|
+
* another release skews every declared package at once, and a block per package said the same thing
|
|
679
|
+
* eighteen times. One package keeps the per-package report.
|
|
680
|
+
*/
|
|
681
|
+
const describeSkewGroup = (skips: readonly SingleCopySkip[]): string => {
|
|
682
|
+
const [first] = skips
|
|
683
|
+
if (first === undefined || skips.length === 1) return describeSkips(first?.package ?? "", skips)
|
|
684
|
+
const names = skips.map((skip) => skip.package).sort(codeUnitOrder)
|
|
685
|
+
return [
|
|
686
|
+
`[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each loads, so their module state is not shared.`,
|
|
687
|
+
copyLine(
|
|
688
|
+
"app copies",
|
|
689
|
+
first.to === undefined ? undefined : modulesDirOf(first.to),
|
|
690
|
+
first.toVersion,
|
|
691
|
+
),
|
|
692
|
+
copyLine("linked copies", modulesDirOf(first.from), first.fromVersion),
|
|
693
|
+
` packages: ${names.join(", ")}`,
|
|
694
|
+
` ${first.fromVersion ?? "another version"} there, ${first.toVersion ?? "another version"} here - redirecting would serve a version those copies did not ask for`,
|
|
695
|
+
" Fix: align the dependency ranges so both trees install one version, then reinstall. nifra never redirects across versions.",
|
|
696
|
+
].join("\n")
|
|
697
|
+
}
|
|
698
|
+
|
|
669
699
|
const strictFromEnv = (): boolean => {
|
|
670
700
|
const value = typeof process === "undefined" ? undefined : process.env[SINGLE_COPY_STRICT_ENV]
|
|
671
701
|
return value === "1" || value === "true"
|
|
@@ -685,15 +715,15 @@ export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyP
|
|
|
685
715
|
const root = realpathOrSelf(resolve(options.cwd ?? process.cwd()))
|
|
686
716
|
const strict = options.strict ?? (readSingleCopyStrict(root) || strictFromEnv())
|
|
687
717
|
const warned = processSet(WARNED)
|
|
688
|
-
const
|
|
689
|
-
const message = describeSkips(name, skips)
|
|
718
|
+
const emit = (key: string, message: string): void => {
|
|
690
719
|
if (strict)
|
|
691
720
|
throw new Error(`${message}\n Strict mode is on, so this process refuses to start.`)
|
|
692
|
-
const key = `${skips[0]?.reason ?? ""}\0${name}`
|
|
693
721
|
if (warned.has(key)) return
|
|
694
722
|
warned.add(key)
|
|
695
723
|
console.warn(message)
|
|
696
724
|
}
|
|
725
|
+
const report = (name: string, skips: readonly SingleCopySkip[]): void =>
|
|
726
|
+
emit(`${skips[0]?.reason ?? ""}\0${name}`, describeSkips(name, skips))
|
|
697
727
|
const plugin = singleCopyPlugin({
|
|
698
728
|
...options,
|
|
699
729
|
cwd: root,
|
|
@@ -703,13 +733,33 @@ export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyP
|
|
|
703
733
|
},
|
|
704
734
|
})
|
|
705
735
|
const byPackage = new Map<string, SingleCopySkip[]>()
|
|
736
|
+
const bySkew = new Map<string, SingleCopySkip[]>()
|
|
706
737
|
for (const skip of plugin.plan.skipped) {
|
|
707
738
|
options.onSkip?.(skip)
|
|
708
|
-
const
|
|
739
|
+
const skew =
|
|
740
|
+
skip.reason === "version-skew"
|
|
741
|
+
? [
|
|
742
|
+
modulesDirOf(skip.from),
|
|
743
|
+
skip.to === undefined ? "" : modulesDirOf(skip.to),
|
|
744
|
+
skip.fromVersion ?? "",
|
|
745
|
+
skip.toVersion ?? "",
|
|
746
|
+
].join("\0")
|
|
747
|
+
: undefined
|
|
748
|
+
const groups = skew === undefined ? byPackage : bySkew
|
|
749
|
+
const key = skew ?? skip.package
|
|
750
|
+
const group = groups.get(key) ?? []
|
|
709
751
|
group.push(skip)
|
|
710
|
-
|
|
752
|
+
groups.set(key, group)
|
|
711
753
|
}
|
|
712
754
|
for (const [name, skips] of byPackage) report(name, skips)
|
|
755
|
+
for (const skips of bySkew.values()) {
|
|
756
|
+
if (skips.length === 1) report(skips[0]?.package ?? "", skips)
|
|
757
|
+
else
|
|
758
|
+
emit(
|
|
759
|
+
`version-skew\0${skips.map((skip) => skip.package).join("\0")}`,
|
|
760
|
+
describeSkewGroup(skips),
|
|
761
|
+
)
|
|
762
|
+
}
|
|
713
763
|
// Installing twice (a preload listed twice, or an explicit call on top of the preload) would register
|
|
714
764
|
// two `onLoad` hooks for the same foreign files, and two hooks both answering one file stall the
|
|
715
765
|
// runtime loader. One install per root and declaration is the whole effect anyway.
|
package/src/tool-contract.ts
CHANGED
|
@@ -469,8 +469,9 @@ export async function executeTool<Input, Output>(
|
|
|
469
469
|
await options.idempotency?.abandon(reservationInput(reservation))
|
|
470
470
|
} catch {}
|
|
471
471
|
}
|
|
472
|
-
// Once the executor has
|
|
473
|
-
// that may have committed. A store that throws on completion counts as
|
|
472
|
+
// Once the executor has started, the reservation is never released, even when it throws: a retry
|
|
473
|
+
// would repeat a side effect that may have committed. A store that throws on completion counts as
|
|
474
|
+
// one that refused it.
|
|
474
475
|
let executed = false
|
|
475
476
|
const complete = async (): Promise<boolean> => {
|
|
476
477
|
if (reservation === undefined) return true
|
|
@@ -660,6 +661,7 @@ export async function executeTool<Input, Output>(
|
|
|
660
661
|
record("execution", "dry-run")
|
|
661
662
|
return finish({ ok: true, dryRun })
|
|
662
663
|
}
|
|
664
|
+
executed = true
|
|
663
665
|
const output = await tool.execute(validatedInput.value, {
|
|
664
666
|
effectId,
|
|
665
667
|
signal,
|
|
@@ -667,7 +669,6 @@ export async function executeTool<Input, Output>(
|
|
|
667
669
|
dryRun,
|
|
668
670
|
...(tool.policy === undefined ? {} : { policy: tool.policy }),
|
|
669
671
|
})
|
|
670
|
-
executed = true
|
|
671
672
|
record("execution", "committed")
|
|
672
673
|
const validatedOutput = await validateStandard(tool.output, output)
|
|
673
674
|
if (!validatedOutput.ok) {
|
|
@@ -699,7 +700,9 @@ export async function executeTool<Input, Output>(
|
|
|
699
700
|
}
|
|
700
701
|
return finish({ ok: true, dryRun, output: validatedOutput.value })
|
|
701
702
|
} catch (error) {
|
|
702
|
-
|
|
703
|
+
// A completion the store refuses still leaves the key pending until its lease lapses.
|
|
704
|
+
if (executed) await complete()
|
|
705
|
+
else await abandon()
|
|
703
706
|
const code = signal.aborted || isAbortError(error) ? "cancelled" : "execution_failed"
|
|
704
707
|
record("execution", "failed", code)
|
|
705
708
|
return finish({ ok: false, dryRun, error: { code, stage: "execution" } })
|
package/src/transport-codec.ts
CHANGED
|
@@ -228,8 +228,11 @@ export async function readBoundedBytes(
|
|
|
228
228
|
): Promise<Uint8Array> {
|
|
229
229
|
const maxBytes = maxBytesOf(options)
|
|
230
230
|
const declared = response.headers.get("content-length")
|
|
231
|
-
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
|
|
231
|
+
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes) {
|
|
232
|
+
// Refused unread, the body would otherwise hold its connection open until the sender gives up.
|
|
233
|
+
response.body?.cancel().catch(() => {})
|
|
232
234
|
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
235
|
+
}
|
|
233
236
|
if (response.body === null) return new Uint8Array(0)
|
|
234
237
|
const reader = response.body.getReader()
|
|
235
238
|
const chunks: Uint8Array[] = []
|