@nifrajs/core 5.1.0 → 5.2.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 +2384 -0
- 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/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/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 +151 -30
- package/dist/server/server.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/package.json +3 -2
- package/src/index.ts +1 -1
- package/src/internal/hook-origins.ts +35 -0
- package/src/internal/route-program.ts +39 -20
- 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/response-contract-lane.ts +357 -42
- package/src/server/server-types.ts +7 -4
- package/src/server/server.ts +202 -27
- package/src/single-copy.ts +55 -5
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,
|
|
@@ -787,6 +790,7 @@ const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
|
787
790
|
// node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
|
|
788
791
|
// throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
|
|
789
792
|
// off) while a production define still folds `<flag> && false` to `false` on every other runtime.
|
|
793
|
+
// It must live in this module: imported from another, Bun leaves `<flag>,` at every folded guard.
|
|
790
794
|
const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
|
|
791
795
|
|
|
792
796
|
// This module's own path, captured once from a sentinel stack. Every internal frame of an
|
|
@@ -797,9 +801,10 @@ const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
|
|
|
797
801
|
return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
|
|
798
802
|
})()
|
|
799
803
|
|
|
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
|
|
804
|
+
/** One `file:line:col` frame for the caller of an order-scoped hook method or a route registration,
|
|
805
|
+
* for the unused-hook report and route provenance. Only ever called behind a development guard that
|
|
806
|
+
* also requires the feature to be on, so the `Error` (the sole non-trivial cost) is never constructed
|
|
807
|
+
* otherwise. */
|
|
803
808
|
const captureCallerSite = (): string | undefined => {
|
|
804
809
|
const stack = new Error().stack
|
|
805
810
|
if (stack === undefined) return undefined
|
|
@@ -823,9 +828,19 @@ interface HookAuditState {
|
|
|
823
828
|
readonly hasCustomLogger: boolean
|
|
824
829
|
readonly sites: {
|
|
825
830
|
readonly kind: string
|
|
831
|
+
/** Routes that captured the chain so far: merged routes excluded, they keep their own. */
|
|
826
832
|
readonly routesAtPush: number
|
|
833
|
+
readonly mergedAtPush: number
|
|
834
|
+
readonly site: string | undefined
|
|
835
|
+
}[]
|
|
836
|
+
/** Registration-time plugins installed after routes they would have covered. */
|
|
837
|
+
readonly lateInstalls: {
|
|
838
|
+
readonly plugin: string
|
|
827
839
|
readonly site: string | undefined
|
|
840
|
+
readonly routes: readonly string[]
|
|
828
841
|
}[]
|
|
842
|
+
/** Routes adopted by `merge()`, which keep the chain of the server that declared them. */
|
|
843
|
+
merged: number
|
|
829
844
|
checked: boolean
|
|
830
845
|
}
|
|
831
846
|
const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
|
|
@@ -860,7 +875,14 @@ const beginHookAudit = (
|
|
|
860
875
|
policy: "warn" | "error" | "off",
|
|
861
876
|
hasCustomLogger: boolean,
|
|
862
877
|
): void => {
|
|
863
|
-
hookAudit.set(key, {
|
|
878
|
+
hookAudit.set(key, {
|
|
879
|
+
policy,
|
|
880
|
+
hasCustomLogger,
|
|
881
|
+
sites: [],
|
|
882
|
+
lateInstalls: [],
|
|
883
|
+
merged: 0,
|
|
884
|
+
checked: false,
|
|
885
|
+
})
|
|
864
886
|
}
|
|
865
887
|
|
|
866
888
|
/** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
|
|
@@ -877,7 +899,62 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
|
|
|
877
899
|
}
|
|
878
900
|
const state = hookAudit.get(key)
|
|
879
901
|
if (state === undefined || state.policy === "off") return
|
|
880
|
-
state.sites.push({
|
|
902
|
+
state.sites.push({
|
|
903
|
+
kind,
|
|
904
|
+
routesAtPush: routesAtPush - state.merged,
|
|
905
|
+
mergedAtPush: state.merged,
|
|
906
|
+
site: captureCallerSite(),
|
|
907
|
+
})
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
|
|
911
|
+
* followed only by merges covers nothing. */
|
|
912
|
+
const noteMergedRoutes = (key: object, count: number): void => {
|
|
913
|
+
const state = hookAudit.get(key)
|
|
914
|
+
if (state !== undefined) state.merged += count
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Record a `merge()` whose server authenticates none of its routes while this server's order-scoped
|
|
918
|
+
* auth - `authenticate()` stages, or `authenticated` evidence scoped to subsequent routes - would
|
|
919
|
+
* cover some of them. A server that authenticates any of its own routes chose its own auth, so its
|
|
920
|
+
* unauthenticated routes are taken as public on purpose. `nifra check` reports what this records. */
|
|
921
|
+
const noteMergeAuthGap = (
|
|
922
|
+
target: object,
|
|
923
|
+
authStages: readonly RawAuthStage[],
|
|
924
|
+
active: readonly AssuranceDeclaration[],
|
|
925
|
+
routes: readonly CatalogRoute[],
|
|
926
|
+
sourceGlobal: readonly AssuranceDeclaration[],
|
|
927
|
+
): void => {
|
|
928
|
+
const parentAuth = active.filter((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
|
|
929
|
+
if (authStages.length === 0 && parentAuth.length === 0) return
|
|
930
|
+
const authenticates = (route: CatalogRoute): boolean =>
|
|
931
|
+
route.assurance.some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED) ||
|
|
932
|
+
assuranceEvidenceFor(sourceGlobal, route.method, route.path).some(
|
|
933
|
+
(item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED,
|
|
934
|
+
)
|
|
935
|
+
if (routes.some(authenticates)) return
|
|
936
|
+
const covered = routes.filter(
|
|
937
|
+
(route) =>
|
|
938
|
+
authStages.length > 0 ||
|
|
939
|
+
assuranceEvidenceFor(parentAuth, route.method, route.path).length > 0,
|
|
940
|
+
)
|
|
941
|
+
if (covered.length === 0) return
|
|
942
|
+
tagMergeAuthGap(target, {
|
|
943
|
+
site: captureCallerSite(),
|
|
944
|
+
auth: [
|
|
945
|
+
...authStages.map((stage) => `authenticate(${stage.id})`),
|
|
946
|
+
...new Set(parentAuth.map((item) => item.source)),
|
|
947
|
+
],
|
|
948
|
+
routes: covered.map((route) => `${route.method} ${route.path}`),
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Record a registration-time plugin (`responseContract()`, `effectLedger()`) installed after routes
|
|
953
|
+
* it would have covered, named as `METHOD /path`. Reported with the dead hooks at seal. */
|
|
954
|
+
const recordLateInstall = (key: object, plugin: string, routes: readonly string[]): void => {
|
|
955
|
+
const state = hookAudit.get(key)
|
|
956
|
+
if (state === undefined || state.policy === "off" || routes.length === 0) return
|
|
957
|
+
state.lateInstalls.push({ plugin, site: captureCallerSite(), routes })
|
|
881
958
|
}
|
|
882
959
|
|
|
883
960
|
/** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
|
|
@@ -914,20 +991,46 @@ const sealHookAudit = (
|
|
|
914
991
|
if (state === undefined || state.checked) return
|
|
915
992
|
state.checked = true
|
|
916
993
|
if (state.policy === "off" || total + sockets === 0) return
|
|
994
|
+
const own = total - state.merged
|
|
917
995
|
const dead = state.sites.filter(
|
|
918
996
|
(hook) =>
|
|
919
997
|
hook.routesAtPush ===
|
|
920
998
|
(hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
|
|
921
|
-
?
|
|
922
|
-
:
|
|
999
|
+
? own
|
|
1000
|
+
: own + sockets),
|
|
923
1001
|
)
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1002
|
+
const parts: string[] = []
|
|
1003
|
+
if (dead.length > 0) {
|
|
1004
|
+
const mergedAfter = (hook: (typeof dead)[number]): boolean => state.merged > hook.mergedAtPush
|
|
1005
|
+
const detail = dead
|
|
1006
|
+
.map(
|
|
1007
|
+
(hook) =>
|
|
1008
|
+
` ${hook.kind}() at ${hook.site ?? "<unknown>"}${mergedAfter(hook) ? " (only merge()d routes follow it)" : ""}`,
|
|
1009
|
+
)
|
|
1010
|
+
.join("\n")
|
|
1011
|
+
parts.push(
|
|
1012
|
+
`[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
|
|
1013
|
+
`last route was registered, and register() snapshots the chain into each route as it is ` +
|
|
1014
|
+
`declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
|
|
1015
|
+
`(onRequest/onResponse) are not order-scoped and can be added at any time.` +
|
|
1016
|
+
(dead.some(mergedAfter)
|
|
1017
|
+
? " A merge()d route keeps the hooks of the server that declared it and never runs this " +
|
|
1018
|
+
"server's: wire the hook on the merged server, or declare those routes in a group()."
|
|
1019
|
+
: ""),
|
|
1020
|
+
)
|
|
1021
|
+
}
|
|
1022
|
+
for (const late of state.lateInstalls) {
|
|
1023
|
+
const shown = late.routes.slice(0, 5).join(", ")
|
|
1024
|
+
const more = late.routes.length > 5 ? ` and ${late.routes.length - 5} more` : ""
|
|
1025
|
+
parts.push(
|
|
1026
|
+
`[nifra] ${late.plugin}() at ${late.site ?? "<unknown>"} was installed after ` +
|
|
1027
|
+
`${late.routes.length} route(s) it would have covered: ${shown}${more}. It is decided per ` +
|
|
1028
|
+
`route at registration and merge(), so those routes run without it. Install it before the ` +
|
|
1029
|
+
`routes and merge() calls it should cover.`,
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
if (parts.length === 0) return
|
|
1033
|
+
const message = parts.join("\n")
|
|
931
1034
|
if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
|
|
932
1035
|
if (state.hasCustomLogger) logger.warn(message)
|
|
933
1036
|
else console.warn(message)
|
|
@@ -1177,6 +1280,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1177
1280
|
// for the bundler to eliminate; a non-bundled production run skips it the same way.
|
|
1178
1281
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
1179
1282
|
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
|
|
1283
|
+
recordCopyVersion()
|
|
1180
1284
|
}
|
|
1181
1285
|
this.derives = []
|
|
1182
1286
|
this.authStages = []
|
|
@@ -1232,6 +1336,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1232
1336
|
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
1233
1337
|
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
1234
1338
|
* Mounted responses are outside Nifra's typed route and response-contract checks.
|
|
1339
|
+
*
|
|
1340
|
+
* It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
|
|
1341
|
+
* request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
|
|
1342
|
+
* mounted child app, or keep the legacy server running beside this one for them.
|
|
1235
1343
|
*/
|
|
1236
1344
|
mountFetch(
|
|
1237
1345
|
path: string,
|
|
@@ -1369,6 +1477,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1369
1477
|
* Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
|
|
1370
1478
|
* route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
|
|
1371
1479
|
* security gate and lets the route compiler retain a protected fast lane.
|
|
1480
|
+
*
|
|
1481
|
+
* A stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, fails
|
|
1482
|
+
* closed with 503 `{ ok: false, error: "auth_unavailable" }`. The cause goes where a handler's error
|
|
1483
|
+
* goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger. A
|
|
1484
|
+
* thrown `Response` or `status(...)` is sent as thrown, as it is from a handler.
|
|
1372
1485
|
*/
|
|
1373
1486
|
authenticate<Principal>(
|
|
1374
1487
|
stage: AuthenticationStage<Principal, EnvOf<Ctx>>,
|
|
@@ -1387,8 +1500,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1387
1500
|
throw new TypeError('authenticate() stage mode must be "sync" or "async"')
|
|
1388
1501
|
}
|
|
1389
1502
|
this.authStages.push(stage as unknown as RawAuthStage)
|
|
1390
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1503
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
1504
|
+
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "authenticate")
|
|
1391
1505
|
tagHook(stage, "authenticate", this)
|
|
1506
|
+
}
|
|
1392
1507
|
return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
|
|
1393
1508
|
}
|
|
1394
1509
|
|
|
@@ -1505,9 +1620,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1505
1620
|
return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
|
|
1506
1621
|
}
|
|
1507
1622
|
|
|
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
|
|
1623
|
+
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
|
|
1624
|
+
* `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
|
|
1625
|
+
* declared *after* this call, none before; one added after the last route reaches nothing (see
|
|
1626
|
+
* {@link ServerOptions.unusedScopedHooks}). */
|
|
1511
1627
|
onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
|
|
1512
1628
|
fn: H,
|
|
1513
1629
|
): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
|
|
@@ -2261,6 +2377,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2261
2377
|
schema: RouteSchema | undefined,
|
|
2262
2378
|
handler: (context: never) => unknown,
|
|
2263
2379
|
): CatalogRoute {
|
|
2380
|
+
// Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
|
|
2381
|
+
// app: the module that registered the route, read where a static scan cannot see the path.
|
|
2382
|
+
if (
|
|
2383
|
+
hookAuditRuntime &&
|
|
2384
|
+
process.env.NODE_ENV !== "production" &&
|
|
2385
|
+
process.env.NIFRA_ROUTE_SITES === "1"
|
|
2386
|
+
) {
|
|
2387
|
+
const site = captureCallerSite()
|
|
2388
|
+
if (site !== undefined) tagRouteSite(this, `${method.toUpperCase()} ${path}`, site)
|
|
2389
|
+
}
|
|
2264
2390
|
const compiled = compileRouteOptions(
|
|
2265
2391
|
{
|
|
2266
2392
|
maxBodyBytes: this.maxBodyBytes,
|
|
@@ -2461,6 +2587,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2461
2587
|
[INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
|
|
2462
2588
|
this.assertConfigurable("responseContract()")
|
|
2463
2589
|
this.responseContractRuntime = runtime
|
|
2590
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2591
|
+
recordLateInstall(
|
|
2592
|
+
this,
|
|
2593
|
+
"responseContract",
|
|
2594
|
+
this.catalog
|
|
2595
|
+
.entries()
|
|
2596
|
+
.filter(
|
|
2597
|
+
({ entry }) =>
|
|
2598
|
+
entry.responseContract === undefined &&
|
|
2599
|
+
(entry.schema?.response !== undefined || entry.schema?.errors !== undefined),
|
|
2600
|
+
)
|
|
2601
|
+
.map((route) => `${route.method} ${route.path}`),
|
|
2602
|
+
)
|
|
2464
2603
|
}
|
|
2465
2604
|
|
|
2466
2605
|
/** @internal Symbol-keyed install seam for the response observer plugin. */
|
|
@@ -2610,6 +2749,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2610
2749
|
*
|
|
2611
2750
|
* The response contract and the ledger can only add a check or a record, so a merged route is
|
|
2612
2751
|
* recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
|
|
2752
|
+
*
|
|
2753
|
+
* In development, {@link ServerOptions.unusedScopedHooks} reports a hook followed only by merges and
|
|
2754
|
+
* a contract or ledger installed after the routes it would cover; `nifra check` reports a merge whose
|
|
2755
|
+
* server authenticates none of its routes under this server's `authenticate()` (NF-C043).
|
|
2613
2756
|
*/
|
|
2614
2757
|
merge<R2 extends Registry, Ctx2, HookOutput2>(
|
|
2615
2758
|
other: Server<R2, Ctx2, HookOutput2>,
|
|
@@ -2618,7 +2761,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2618
2761
|
if (!(other instanceof Server)) {
|
|
2619
2762
|
// A server from another copy of this package reaches this copy's private request state through
|
|
2620
2763
|
// symbols it does not share, and would fail per request. The boot warning names the copies.
|
|
2621
|
-
throw new TypeError(
|
|
2764
|
+
throw new TypeError(
|
|
2765
|
+
hookAuditRuntime && process.env.NODE_ENV !== "production"
|
|
2766
|
+
? mergeRefusal()
|
|
2767
|
+
: "merge() requires a server() from this copy of @nifrajs/core",
|
|
2768
|
+
)
|
|
2622
2769
|
}
|
|
2623
2770
|
if (this.routePrefix !== "") {
|
|
2624
2771
|
// The merged routes were compiled under their own paths; adopting them here would publish them
|
|
@@ -2635,7 +2782,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2635
2782
|
"merge() does not carry WebSocket routes or mounts - register them on the parent server",
|
|
2636
2783
|
)
|
|
2637
2784
|
}
|
|
2785
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2786
|
+
noteMergeAuthGap(
|
|
2787
|
+
this,
|
|
2788
|
+
this.authStages,
|
|
2789
|
+
this.activeAssurance,
|
|
2790
|
+
source.catalog.entries(),
|
|
2791
|
+
source.globalAssurance,
|
|
2792
|
+
)
|
|
2638
2793
|
this.adopt(source, undefined, 0)
|
|
2794
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2795
|
+
noteMergedRoutes(this, source.catalog.size)
|
|
2639
2796
|
return this as unknown as Server<R & R2, Ctx, HookOutput>
|
|
2640
2797
|
}
|
|
2641
2798
|
|
|
@@ -4318,6 +4475,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4318
4475
|
[INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void {
|
|
4319
4476
|
this.assertConfigurable("effectLedger()")
|
|
4320
4477
|
this.effectLedgerRuntime = runtime
|
|
4478
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
4479
|
+
recordLateInstall(
|
|
4480
|
+
this,
|
|
4481
|
+
"effectLedger",
|
|
4482
|
+
this.catalog
|
|
4483
|
+
.entries()
|
|
4484
|
+
.filter(
|
|
4485
|
+
({ entry, descriptor }) =>
|
|
4486
|
+
entry.ledgered === undefined && (descriptor.capabilities?.length ?? 0) > 0,
|
|
4487
|
+
)
|
|
4488
|
+
.map((route) => `${route.method} ${route.path}`),
|
|
4489
|
+
)
|
|
4321
4490
|
}
|
|
4322
4491
|
|
|
4323
4492
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
@@ -5640,7 +5809,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5640
5809
|
if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
|
|
5641
5810
|
const input =
|
|
5642
5811
|
stage.kind === "headers"
|
|
5643
|
-
?
|
|
5812
|
+
? stage.names === undefined
|
|
5813
|
+
? headerObjectOf(source.headers)
|
|
5814
|
+
: declaredHeaders(source.headers, stage.names)
|
|
5644
5815
|
: stage.kind === "query"
|
|
5645
5816
|
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
5646
5817
|
: ctx[stage.kind]
|
|
@@ -5669,8 +5840,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5669
5840
|
ctx: RawContext,
|
|
5670
5841
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
5671
5842
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
5843
|
+
fallback?: () => T,
|
|
5672
5844
|
): MaybePromise<T> {
|
|
5673
|
-
return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
|
|
5845
|
+
return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse, fallback)
|
|
5674
5846
|
}
|
|
5675
5847
|
|
|
5676
5848
|
/** Finish a successful program result through response-contract enforcement and finalization. */
|
|
@@ -6140,6 +6312,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6140
6312
|
ctx: RawContext,
|
|
6141
6313
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
6142
6314
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
6315
|
+
// The answer when no hook does; the default 500 unless a stage owns a different fail-closed status.
|
|
6316
|
+
fallback?: () => T,
|
|
6143
6317
|
): MaybePromise<T> {
|
|
6144
6318
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
6145
6319
|
// an action throws an error page. Send it as the same Response returned would be sent (Remix/
|
|
@@ -6155,9 +6329,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6155
6329
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
6156
6330
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
6157
6331
|
this.logRequestError(err, ctx)
|
|
6158
|
-
return wrapResponse(plainError(500, "internal_error"))
|
|
6332
|
+
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
|
|
6159
6333
|
}
|
|
6160
|
-
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse)
|
|
6334
|
+
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback)
|
|
6161
6335
|
}
|
|
6162
6336
|
|
|
6163
6337
|
/** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
|
|
@@ -6168,6 +6342,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6168
6342
|
ctx: RawContext,
|
|
6169
6343
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
6170
6344
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
6345
|
+
fallback?: () => T,
|
|
6171
6346
|
): Promise<T> {
|
|
6172
6347
|
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
6173
6348
|
// the `await` and stay heap-allocated on both engines.
|
|
@@ -6188,7 +6363,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6188
6363
|
}
|
|
6189
6364
|
}
|
|
6190
6365
|
this.logRequestError(err, ctx)
|
|
6191
|
-
return wrapResponse(plainError(500, "internal_error"))
|
|
6366
|
+
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
|
|
6192
6367
|
}
|
|
6193
6368
|
|
|
6194
6369
|
/** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
|
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.
|