@nifrajs/core 5.0.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/assurance.d.ts +15 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +97 -71
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +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-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.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/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.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/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +7 -4
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.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/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.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/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +57 -7
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +394 -23
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +23 -9
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +40 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +188 -43
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +11 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +26 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +9 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +81 -10
- package/dist/single-copy.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +2 -1
- package/dist/ws.js.map +1 -1
- package/package.json +3 -2
- package/src/assurance.ts +120 -81
- package/src/capabilities.ts +3 -1
- package/src/edge-kit.ts +1 -1
- package/src/index.ts +2 -2
- package/src/internal/hook-origins.ts +35 -0
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/route-program.ts +39 -20
- package/src/internal/same-origin.ts +14 -1
- package/src/reflection.ts +61 -1
- package/src/server/body-lane.ts +36 -1
- package/src/server/client-ip.ts +5 -5
- package/src/server/context.ts +7 -4
- package/src/server/cookies.ts +12 -1
- package/src/server/core-copies.ts +40 -0
- package/src/server/edge.ts +2 -1
- package/src/server/headers.ts +39 -0
- package/src/server/http.ts +7 -1
- package/src/server/response-contract-lane.ts +491 -24
- package/src/server/server-types.ts +23 -9
- package/src/server/server.ts +249 -35
- package/src/server/websocket.ts +39 -13
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +9 -2
- package/src/server.ts +1 -1
- package/src/single-copy.ts +90 -9
- package/src/wire.ts +14 -0
- package/src/ws.ts +2 -0
package/dist/server/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
|
|
2
2
|
import { FrameworkError, RouteConfigError } from "../errors.js";
|
|
3
3
|
import { DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, } from "../internal/capability-runtime.js";
|
|
4
|
-
import { adoptStaticTags, enterPlugin, leavePlugin, tagAdoption, tagHook, tagScoped, tagStatic, } from "../internal/hook-origins.js";
|
|
5
|
-
import { assuranceDeclarationsOf, assuranceEvidenceFor, } from "../internal/route-assurance.js";
|
|
4
|
+
import { adoptStaticTags, enterPlugin, leavePlugin, tagAdoption, tagHook, tagMergeAuthGap, tagRouteSite, tagScoped, tagStatic, } from "../internal/hook-origins.js";
|
|
5
|
+
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
|
|
6
6
|
import { RouteCatalog } from "../internal/route-catalog.js";
|
|
7
|
-
import { compileRouteOptions } from "../internal/route-compiler.js";
|
|
7
|
+
import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.js";
|
|
8
8
|
import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
|
|
9
9
|
import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
|
|
10
10
|
import { isSameOriginRequest } from "../internal/same-origin.js";
|
|
@@ -13,9 +13,9 @@ import { EMPTY_PARAMS, METHODS, Router } from "../router/router.js";
|
|
|
13
13
|
import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
|
|
14
14
|
import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
|
|
15
15
|
import { NIFRA_PLATFORM_CLIENT_IP_DERIVED, resolveClientIp, } from "./client-ip.js";
|
|
16
|
-
import "./core-copies.js";
|
|
16
|
+
import { mergeRefusal, recordCopyVersion } from "./core-copies.js";
|
|
17
17
|
import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
|
|
18
|
-
import { headerObjectOf } from "./headers.js";
|
|
18
|
+
import { declaredHeaders, headerObjectOf } from "./headers.js";
|
|
19
19
|
import { isRoutableMethod, jsonError, pathnameOf, plainError, urlPartsOf, } from "./http.js";
|
|
20
20
|
import { withStaticNodeHeaders } from "./node-outcome.js";
|
|
21
21
|
import { queryObjectOf, searchOf } from "./query.js";
|
|
@@ -285,6 +285,7 @@ const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
|
|
|
285
285
|
// node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
|
|
286
286
|
// throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
|
|
287
287
|
// off) while a production define still folds `<flag> && false` to `false` on every other runtime.
|
|
288
|
+
// It must live in this module: imported from another, Bun leaves `<flag>,` at every folded guard.
|
|
288
289
|
const hookAuditRuntime = typeof globalThis.Deno === "undefined";
|
|
289
290
|
// This module's own path, captured once from a sentinel stack. Every internal frame of an
|
|
290
291
|
// order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
|
|
@@ -293,9 +294,10 @@ const SERVER_MODULE_PATH = /* @__PURE__ */ (() => {
|
|
|
293
294
|
const frame = new Error().stack?.split("\n")[1];
|
|
294
295
|
return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1];
|
|
295
296
|
})();
|
|
296
|
-
/** One `file:line:col` frame for the caller of an order-scoped hook method
|
|
297
|
-
* report. Only ever called behind
|
|
298
|
-
* non-trivial cost) is never constructed
|
|
297
|
+
/** One `file:line:col` frame for the caller of an order-scoped hook method or a route registration,
|
|
298
|
+
* for the unused-hook report and route provenance. Only ever called behind a development guard that
|
|
299
|
+
* also requires the feature to be on, so the `Error` (the sole non-trivial cost) is never constructed
|
|
300
|
+
* otherwise. */
|
|
299
301
|
const captureCallerSite = () => {
|
|
300
302
|
const stack = new Error().stack;
|
|
301
303
|
if (stack === undefined)
|
|
@@ -328,7 +330,14 @@ function mountLeadsBack(from, target, path, mountsOf, seen) {
|
|
|
328
330
|
const PAGES_DECLARED = /* @__PURE__ */ Symbol.for("nifra.web.pagesDeclared");
|
|
329
331
|
/** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
|
|
330
332
|
const beginHookAudit = (key, policy, hasCustomLogger) => {
|
|
331
|
-
hookAudit.set(key, {
|
|
333
|
+
hookAudit.set(key, {
|
|
334
|
+
policy,
|
|
335
|
+
hasCustomLogger,
|
|
336
|
+
sites: [],
|
|
337
|
+
lateInstalls: [],
|
|
338
|
+
merged: 0,
|
|
339
|
+
checked: false,
|
|
340
|
+
});
|
|
332
341
|
};
|
|
333
342
|
/** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
|
|
334
343
|
* seal means no route was declared after it) and the caller's frame. Hooks a WebSocket handshake runs
|
|
@@ -342,7 +351,52 @@ const recordScopedHook = (key, routesAtPush, kind) => {
|
|
|
342
351
|
const state = hookAudit.get(key);
|
|
343
352
|
if (state === undefined || state.policy === "off")
|
|
344
353
|
return;
|
|
345
|
-
state.sites.push({
|
|
354
|
+
state.sites.push({
|
|
355
|
+
kind,
|
|
356
|
+
routesAtPush: routesAtPush - state.merged,
|
|
357
|
+
mergedAtPush: state.merged,
|
|
358
|
+
site: captureCallerSite(),
|
|
359
|
+
});
|
|
360
|
+
};
|
|
361
|
+
/** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
|
|
362
|
+
* followed only by merges covers nothing. */
|
|
363
|
+
const noteMergedRoutes = (key, count) => {
|
|
364
|
+
const state = hookAudit.get(key);
|
|
365
|
+
if (state !== undefined)
|
|
366
|
+
state.merged += count;
|
|
367
|
+
};
|
|
368
|
+
/** Record a `merge()` whose server authenticates none of its routes while this server's order-scoped
|
|
369
|
+
* auth - `authenticate()` stages, or `authenticated` evidence scoped to subsequent routes - would
|
|
370
|
+
* cover some of them. A server that authenticates any of its own routes chose its own auth, so its
|
|
371
|
+
* unauthenticated routes are taken as public on purpose. `nifra check` reports what this records. */
|
|
372
|
+
const noteMergeAuthGap = (target, authStages, active, routes, sourceGlobal) => {
|
|
373
|
+
const parentAuth = active.filter((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
|
|
374
|
+
if (authStages.length === 0 && parentAuth.length === 0)
|
|
375
|
+
return;
|
|
376
|
+
const authenticates = (route) => route.assurance.some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED) ||
|
|
377
|
+
assuranceEvidenceFor(sourceGlobal, route.method, route.path).some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
|
|
378
|
+
if (routes.some(authenticates))
|
|
379
|
+
return;
|
|
380
|
+
const covered = routes.filter((route) => authStages.length > 0 ||
|
|
381
|
+
assuranceEvidenceFor(parentAuth, route.method, route.path).length > 0);
|
|
382
|
+
if (covered.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
tagMergeAuthGap(target, {
|
|
385
|
+
site: captureCallerSite(),
|
|
386
|
+
auth: [
|
|
387
|
+
...authStages.map((stage) => `authenticate(${stage.id})`),
|
|
388
|
+
...new Set(parentAuth.map((item) => item.source)),
|
|
389
|
+
],
|
|
390
|
+
routes: covered.map((route) => `${route.method} ${route.path}`),
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
/** Record a registration-time plugin (`responseContract()`, `effectLedger()`) installed after routes
|
|
394
|
+
* it would have covered, named as `METHOD /path`. Reported with the dead hooks at seal. */
|
|
395
|
+
const recordLateInstall = (key, plugin, routes) => {
|
|
396
|
+
const state = hookAudit.get(key);
|
|
397
|
+
if (state === undefined || state.policy === "off" || routes.length === 0)
|
|
398
|
+
return;
|
|
399
|
+
state.lateInstalls.push({ plugin, site: captureCallerSite(), routes });
|
|
346
400
|
};
|
|
347
401
|
/** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
|
|
348
402
|
* wrapper just appended to a hook list gates its hook by. `pairs` holds each source list with the target list it
|
|
@@ -368,17 +422,37 @@ const sealHookAudit = (key, total, sockets, logger) => {
|
|
|
368
422
|
state.checked = true;
|
|
369
423
|
if (state.policy === "off" || total + sockets === 0)
|
|
370
424
|
return;
|
|
425
|
+
const own = total - state.merged;
|
|
371
426
|
const dead = state.sites.filter((hook) => hook.routesAtPush ===
|
|
372
427
|
(hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
|
|
373
|
-
?
|
|
374
|
-
:
|
|
375
|
-
|
|
428
|
+
? own
|
|
429
|
+
: own + sockets));
|
|
430
|
+
const parts = [];
|
|
431
|
+
if (dead.length > 0) {
|
|
432
|
+
const mergedAfter = (hook) => state.merged > hook.mergedAtPush;
|
|
433
|
+
const detail = dead
|
|
434
|
+
.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}${mergedAfter(hook) ? " (only merge()d routes follow it)" : ""}`)
|
|
435
|
+
.join("\n");
|
|
436
|
+
parts.push(`[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
|
|
437
|
+
`last route was registered, and register() snapshots the chain into each route as it is ` +
|
|
438
|
+
`declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
|
|
439
|
+
`(onRequest/onResponse) are not order-scoped and can be added at any time.` +
|
|
440
|
+
(dead.some(mergedAfter)
|
|
441
|
+
? " A merge()d route keeps the hooks of the server that declared it and never runs this " +
|
|
442
|
+
"server's: wire the hook on the merged server, or declare those routes in a group()."
|
|
443
|
+
: ""));
|
|
444
|
+
}
|
|
445
|
+
for (const late of state.lateInstalls) {
|
|
446
|
+
const shown = late.routes.slice(0, 5).join(", ");
|
|
447
|
+
const more = late.routes.length > 5 ? ` and ${late.routes.length - 5} more` : "";
|
|
448
|
+
parts.push(`[nifra] ${late.plugin}() at ${late.site ?? "<unknown>"} was installed after ` +
|
|
449
|
+
`${late.routes.length} route(s) it would have covered: ${shown}${more}. It is decided per ` +
|
|
450
|
+
`route at registration and merge(), so those routes run without it. Install it before the ` +
|
|
451
|
+
`routes and merge() calls it should cover.`);
|
|
452
|
+
}
|
|
453
|
+
if (parts.length === 0)
|
|
376
454
|
return;
|
|
377
|
-
const
|
|
378
|
-
const message = `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
|
|
379
|
-
`last route was registered, and register() snapshots the chain into each route as it is ` +
|
|
380
|
-
`declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
|
|
381
|
-
`(onRequest/onResponse) are not order-scoped and can be added at any time.`;
|
|
455
|
+
const message = parts.join("\n");
|
|
382
456
|
if (state.policy === "error")
|
|
383
457
|
throw new FrameworkError("UNUSED_SCOPED_HOOKS", message);
|
|
384
458
|
if (state.hasCustomLogger)
|
|
@@ -419,6 +493,7 @@ export class Server {
|
|
|
419
493
|
nativePublish;
|
|
420
494
|
maxBodyBytes;
|
|
421
495
|
protoPoisoning;
|
|
496
|
+
formOrigins;
|
|
422
497
|
/** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
|
|
423
498
|
trustBodyFraming;
|
|
424
499
|
wsMaxPayloadBytes;
|
|
@@ -543,6 +618,7 @@ export class Server {
|
|
|
543
618
|
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
544
619
|
assertByteLimit(maxBodyBytes, "maxBodyBytes");
|
|
545
620
|
this.protoPoisoning = options.protoPoisoning ?? "reject";
|
|
621
|
+
this.formOrigins = options.formOrigins;
|
|
546
622
|
this.trustBodyFraming = options.trustBodyFraming ?? false;
|
|
547
623
|
const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
|
|
548
624
|
assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
|
|
@@ -598,6 +674,7 @@ export class Server {
|
|
|
598
674
|
// for the bundler to eliminate; a non-bundled production run skips it the same way.
|
|
599
675
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
600
676
|
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
|
|
677
|
+
recordCopyVersion();
|
|
601
678
|
}
|
|
602
679
|
this.derives = [];
|
|
603
680
|
this.authStages = [];
|
|
@@ -648,6 +725,10 @@ export class Server {
|
|
|
648
725
|
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
649
726
|
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
650
727
|
* Mounted responses are outside Nifra's typed route and response-contract checks.
|
|
728
|
+
*
|
|
729
|
+
* It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
|
|
730
|
+
* request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
|
|
731
|
+
* mounted child app, or keep the legacy server running beside this one for them.
|
|
651
732
|
*/
|
|
652
733
|
mountFetch(path, handler, options = {}) {
|
|
653
734
|
this.assertConfigurable("mountFetch()");
|
|
@@ -763,6 +844,11 @@ export class Server {
|
|
|
763
844
|
* Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
|
|
764
845
|
* route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
|
|
765
846
|
* security gate and lets the route compiler retain a protected fast lane.
|
|
847
|
+
*
|
|
848
|
+
* A stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, fails
|
|
849
|
+
* closed with 503 `{ ok: false, error: "auth_unavailable" }`. The cause goes where a handler's error
|
|
850
|
+
* goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger. A
|
|
851
|
+
* thrown `Response` or `status(...)` is sent as thrown, as it is from a handler.
|
|
766
852
|
*/
|
|
767
853
|
authenticate(stage) {
|
|
768
854
|
this.assertConfigurable("authenticate()");
|
|
@@ -779,8 +865,10 @@ export class Server {
|
|
|
779
865
|
throw new TypeError('authenticate() stage mode must be "sync" or "async"');
|
|
780
866
|
}
|
|
781
867
|
this.authStages.push(stage);
|
|
782
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
868
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
869
|
+
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "authenticate");
|
|
783
870
|
tagHook(stage, "authenticate", this);
|
|
871
|
+
}
|
|
784
872
|
return this;
|
|
785
873
|
}
|
|
786
874
|
decorate(key, value) {
|
|
@@ -877,9 +965,10 @@ export class Server {
|
|
|
877
965
|
}
|
|
878
966
|
return this;
|
|
879
967
|
}
|
|
880
|
-
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500
|
|
881
|
-
* Order-scoped: captured by routes
|
|
882
|
-
* route reaches nothing (see
|
|
968
|
+
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
|
|
969
|
+
* `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
|
|
970
|
+
* declared *after* this call, none before; one added after the last route reaches nothing (see
|
|
971
|
+
* {@link ServerOptions.unusedScopedHooks}). */
|
|
883
972
|
onError(fn) {
|
|
884
973
|
this.assertConfigurable("onError()");
|
|
885
974
|
this.onErrorHooks.push(fn);
|
|
@@ -1311,6 +1400,15 @@ export class Server {
|
|
|
1311
1400
|
throw new RouteConfigError("INVALID_PATH", `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`);
|
|
1312
1401
|
}
|
|
1313
1402
|
prepareRoute(method, path, schema, handler) {
|
|
1403
|
+
// Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
|
|
1404
|
+
// app: the module that registered the route, read where a static scan cannot see the path.
|
|
1405
|
+
if (hookAuditRuntime &&
|
|
1406
|
+
process.env.NODE_ENV !== "production" &&
|
|
1407
|
+
process.env.NIFRA_ROUTE_SITES === "1") {
|
|
1408
|
+
const site = captureCallerSite();
|
|
1409
|
+
if (site !== undefined)
|
|
1410
|
+
tagRouteSite(this, `${method.toUpperCase()} ${path}`, site);
|
|
1411
|
+
}
|
|
1314
1412
|
const compiled = compileRouteOptions({
|
|
1315
1413
|
maxBodyBytes: this.maxBodyBytes,
|
|
1316
1414
|
activeAssurance: this.activeAssurance,
|
|
@@ -1434,6 +1532,12 @@ export class Server {
|
|
|
1434
1532
|
[INSTALL_RESPONSE_CONTRACT](runtime) {
|
|
1435
1533
|
this.assertConfigurable("responseContract()");
|
|
1436
1534
|
this.responseContractRuntime = runtime;
|
|
1535
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1536
|
+
recordLateInstall(this, "responseContract", this.catalog
|
|
1537
|
+
.entries()
|
|
1538
|
+
.filter(({ entry }) => entry.responseContract === undefined &&
|
|
1539
|
+
(entry.schema?.response !== undefined || entry.schema?.errors !== undefined))
|
|
1540
|
+
.map((route) => `${route.method} ${route.path}`));
|
|
1437
1541
|
}
|
|
1438
1542
|
/** @internal Symbol-keyed install seam for the response observer plugin. */
|
|
1439
1543
|
[INSTALL_RESPONSE_OBSERVER](runtime) {
|
|
@@ -1555,13 +1659,40 @@ export class Server {
|
|
|
1555
1659
|
* registered before a `derive`). Fail closed: a path+method collision throws
|
|
1556
1660
|
* `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
|
|
1557
1661
|
* (register those on the parent).
|
|
1662
|
+
*
|
|
1663
|
+
* What this server's `.use()`, made BEFORE the merge, covers on a merged route versus a
|
|
1664
|
+
* `group()` route:
|
|
1665
|
+
*
|
|
1666
|
+
* | installed on this server | group() route | merge()d route |
|
|
1667
|
+
* | ------------------------------------------ | ------------- | ------------------------------ |
|
|
1668
|
+
* | derive/decorate/beforeHandle/afterHandle, | yes | no - wire it on the merged |
|
|
1669
|
+
* | onError/around, authenticate, route-scoped | | server |
|
|
1670
|
+
* | assurance | | |
|
|
1671
|
+
* | onRequest/onResponse/responseHeaders hooks | yes | yes (app-wide) |
|
|
1672
|
+
* | responseContract() | yes | yes, when the route declares a |
|
|
1673
|
+
* | | | response and its own server |
|
|
1674
|
+
* | | | installed no contract |
|
|
1675
|
+
* | effectLedger() | yes | yes, when the route declares |
|
|
1676
|
+
* | | | capabilities and has no ledger |
|
|
1677
|
+
* | idempotency() | yes | the merged server's own; a |
|
|
1678
|
+
* | | | route declaring it without one |
|
|
1679
|
+
* | | | is refused at registration |
|
|
1680
|
+
*
|
|
1681
|
+
* The response contract and the ledger can only add a check or a record, so a merged route is
|
|
1682
|
+
* recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
|
|
1683
|
+
*
|
|
1684
|
+
* In development, {@link ServerOptions.unusedScopedHooks} reports a hook followed only by merges and
|
|
1685
|
+
* a contract or ledger installed after the routes it would cover; `nifra check` reports a merge whose
|
|
1686
|
+
* server authenticates none of its routes under this server's `authenticate()` (NF-C043).
|
|
1558
1687
|
*/
|
|
1559
1688
|
merge(other) {
|
|
1560
1689
|
this.assertConfigurable("merge()");
|
|
1561
1690
|
if (!(other instanceof Server)) {
|
|
1562
1691
|
// A server from another copy of this package reaches this copy's private request state through
|
|
1563
1692
|
// symbols it does not share, and would fail per request. The boot warning names the copies.
|
|
1564
|
-
throw new TypeError(
|
|
1693
|
+
throw new TypeError(hookAuditRuntime && process.env.NODE_ENV !== "production"
|
|
1694
|
+
? mergeRefusal()
|
|
1695
|
+
: "merge() requires a server() from this copy of @nifrajs/core");
|
|
1565
1696
|
}
|
|
1566
1697
|
if (this.routePrefix !== "") {
|
|
1567
1698
|
// The merged routes were compiled under their own paths; adopting them here would publish them
|
|
@@ -1572,7 +1703,11 @@ export class Server {
|
|
|
1572
1703
|
if (source.wsRoutes.length > 0 || source.fetchMounts.length > 0) {
|
|
1573
1704
|
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes or mounts - register them on the parent server");
|
|
1574
1705
|
}
|
|
1706
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1707
|
+
noteMergeAuthGap(this, this.authStages, this.activeAssurance, source.catalog.entries(), source.globalAssurance);
|
|
1575
1708
|
this.adopt(source, undefined, 0);
|
|
1709
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1710
|
+
noteMergedRoutes(this, source.catalog.size);
|
|
1576
1711
|
return this;
|
|
1577
1712
|
}
|
|
1578
1713
|
/**
|
|
@@ -1590,7 +1725,7 @@ export class Server {
|
|
|
1590
1725
|
* The builder receives a scope that INHERITS this server's route chain as it stands at the call
|
|
1591
1726
|
* (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
|
|
1592
1727
|
* capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
|
|
1593
|
-
* would. A plugin this server already applied is in effect for the group: the group's `use()` of
|
|
1728
|
+
* would. {@link merge} documents how that differs for a merged server. A plugin this server already applied is in effect for the group: the group's `use()` of
|
|
1594
1729
|
* that same instance does nothing, and its `use()` of another instance under the same name throws a
|
|
1595
1730
|
* `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
|
|
1596
1731
|
* A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
|
|
@@ -1684,7 +1819,7 @@ export class Server {
|
|
|
1684
1819
|
assurance: [...route.assurance, ...ownGlobal],
|
|
1685
1820
|
})
|
|
1686
1821
|
: (route) => route;
|
|
1687
|
-
this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
|
|
1822
|
+
this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(adoptRouteRuntimes(route, this.responseContractRuntime, this.effectLedgerRuntime)))));
|
|
1688
1823
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1689
1824
|
// runtime object supplies the generic execution machinery. If the parent already has a runtime,
|
|
1690
1825
|
// either implementation can execute every resolved entry because route-specific options were
|
|
@@ -2310,6 +2445,7 @@ export class Server {
|
|
|
2310
2445
|
pubsub,
|
|
2311
2446
|
attach: runtime.attach,
|
|
2312
2447
|
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2448
|
+
sender: runtime.sender,
|
|
2313
2449
|
};
|
|
2314
2450
|
};
|
|
2315
2451
|
const wrap = (response) => finalize(response, EMPTY_RESPONSE_CONTROLS);
|
|
@@ -2410,9 +2546,8 @@ export class Server {
|
|
|
2410
2546
|
return this.fetch(req, bunPeerPlatform(server, req));
|
|
2411
2547
|
if (o.kind === "reject")
|
|
2412
2548
|
return o.response;
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
})
|
|
2549
|
+
// A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
|
|
2550
|
+
return server.upgrade(req, { data: { ...o } })
|
|
2416
2551
|
? undefined
|
|
2417
2552
|
: jsonError(426, "upgrade_required");
|
|
2418
2553
|
};
|
|
@@ -2845,6 +2980,11 @@ export class Server {
|
|
|
2845
2980
|
[INSTALL_EFFECT_LEDGER](runtime) {
|
|
2846
2981
|
this.assertConfigurable("effectLedger()");
|
|
2847
2982
|
this.effectLedgerRuntime = runtime;
|
|
2983
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2984
|
+
recordLateInstall(this, "effectLedger", this.catalog
|
|
2985
|
+
.entries()
|
|
2986
|
+
.filter(({ entry, descriptor }) => entry.ledgered === undefined && (descriptor.capabilities?.length ?? 0) > 0)
|
|
2987
|
+
.map((route) => `${route.method} ${route.path}`));
|
|
2848
2988
|
}
|
|
2849
2989
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
2850
2990
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
@@ -3053,7 +3193,7 @@ export class Server {
|
|
|
3053
3193
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3054
3194
|
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
3055
3195
|
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
3056
|
-
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
|
|
3196
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema, this.formOrigins);
|
|
3057
3197
|
};
|
|
3058
3198
|
}
|
|
3059
3199
|
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
@@ -3340,7 +3480,7 @@ export class Server {
|
|
|
3340
3480
|
ctx.body = validation.value;
|
|
3341
3481
|
return runHandlerValidated();
|
|
3342
3482
|
};
|
|
3343
|
-
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema);
|
|
3483
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema, this.formOrigins);
|
|
3344
3484
|
};
|
|
3345
3485
|
}
|
|
3346
3486
|
/**
|
|
@@ -3620,7 +3760,7 @@ export class Server {
|
|
|
3620
3760
|
const ctx = nativeContext
|
|
3621
3761
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3622
3762
|
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
3623
|
-
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
|
|
3763
|
+
return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema, this.formOrigins);
|
|
3624
3764
|
};
|
|
3625
3765
|
}
|
|
3626
3766
|
bareError(err, ctx, finalize, wrapResponse) {
|
|
@@ -3660,7 +3800,9 @@ export class Server {
|
|
|
3660
3800
|
if (stage.kind === "body")
|
|
3661
3801
|
return this.readProgramBody(entry, source, ctx);
|
|
3662
3802
|
const input = stage.kind === "headers"
|
|
3663
|
-
?
|
|
3803
|
+
? stage.names === undefined
|
|
3804
|
+
? headerObjectOf(source.headers)
|
|
3805
|
+
: declaredHeaders(source.headers, stage.names)
|
|
3664
3806
|
: stage.kind === "query"
|
|
3665
3807
|
? queryObjectOf(ctx[CONTEXT_SEARCH])
|
|
3666
3808
|
: ctx[stage.kind];
|
|
@@ -3678,8 +3820,8 @@ export class Server {
|
|
|
3678
3820
|
}
|
|
3679
3821
|
/** Keep thrown Response/status and redacted 500 semantics in one existing error boundary. */
|
|
3680
3822
|
// @ts-expect-error TS6133 -- invoked structurally by the general route program
|
|
3681
|
-
handleProgramError(entry, error, ctx, finalize, wrapResponse) {
|
|
3682
|
-
return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse);
|
|
3823
|
+
handleProgramError(entry, error, ctx, finalize, wrapResponse, fallback) {
|
|
3824
|
+
return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse, fallback);
|
|
3683
3825
|
}
|
|
3684
3826
|
/** Finish a successful program result through response-contract enforcement and finalization. */
|
|
3685
3827
|
// @ts-expect-error TS6133 -- invoked structurally by the general route program
|
|
@@ -3988,7 +4130,9 @@ export class Server {
|
|
|
3988
4130
|
* result a microtask even when every branch it takes returns immediately, which is the common case
|
|
3989
4131
|
* here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
|
|
3990
4132
|
* loop, which may await, drops into {@link runErrorHooks}. */
|
|
3991
|
-
handleLifecycleError(entry, err, ctx, finalize, wrapResponse
|
|
4133
|
+
handleLifecycleError(entry, err, ctx, finalize, wrapResponse,
|
|
4134
|
+
// The answer when no hook does; the default 500 unless a stage owns a different fail-closed status.
|
|
4135
|
+
fallback) {
|
|
3992
4136
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
3993
4137
|
// an action throws an error page. Send it as the same Response returned would be sent (Remix/
|
|
3994
4138
|
// SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
|
|
@@ -4003,13 +4147,13 @@ export class Server {
|
|
|
4003
4147
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
4004
4148
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
4005
4149
|
this.logRequestError(err, ctx);
|
|
4006
|
-
return wrapResponse(plainError(500, "internal_error"));
|
|
4150
|
+
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
|
|
4007
4151
|
}
|
|
4008
|
-
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse);
|
|
4152
|
+
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback);
|
|
4009
4153
|
}
|
|
4010
4154
|
/** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
|
|
4011
4155
|
* that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
|
|
4012
|
-
async runErrorHooks(entry, err, ctx, finalize, wrapResponse) {
|
|
4156
|
+
async runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback) {
|
|
4013
4157
|
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
4014
4158
|
// the `await` and stay heap-allocated on both engines.
|
|
4015
4159
|
for (let i = 0; i < entry.onError.length; i++) {
|
|
@@ -4031,7 +4175,7 @@ export class Server {
|
|
|
4031
4175
|
}
|
|
4032
4176
|
}
|
|
4033
4177
|
this.logRequestError(err, ctx);
|
|
4034
|
-
return wrapResponse(plainError(500, "internal_error"));
|
|
4178
|
+
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
|
|
4035
4179
|
}
|
|
4036
4180
|
/** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
|
|
4037
4181
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
@@ -4049,7 +4193,7 @@ export class Server {
|
|
|
4049
4193
|
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
|
|
4050
4194
|
: this.applyLifecycleValidation(entry, validation, ctx, "body");
|
|
4051
4195
|
};
|
|
4052
|
-
return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema);
|
|
4196
|
+
return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema, this.formOrigins);
|
|
4053
4197
|
}
|
|
4054
4198
|
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
4055
4199
|
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
@@ -4356,7 +4500,7 @@ export class Server {
|
|
|
4356
4500
|
// that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
|
|
4357
4501
|
// the parent.
|
|
4358
4502
|
const nativePubsub = this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
|
|
4359
|
-
const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub);
|
|
4503
|
+
const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub, this);
|
|
4360
4504
|
// Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
|
|
4361
4505
|
// an explicit undefined as a value on some option paths, and omitting is what selects its own
|
|
4362
4506
|
// defaults, 0.0.0.0 among them.
|
|
@@ -4402,8 +4546,9 @@ export class Server {
|
|
|
4402
4546
|
// `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
|
|
4403
4547
|
websocket: {
|
|
4404
4548
|
// Cap inbound frames so a huge message can't be buffered/JSON-parsed into memory; the runtime
|
|
4405
|
-
// closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes).
|
|
4406
|
-
|
|
4549
|
+
// closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
|
|
4550
|
+
// limit per listener: the largest cap among this app and the apps mounted on it.
|
|
4551
|
+
maxPayloadLength: wsHandlers.maxPayloadLength,
|
|
4407
4552
|
open: (ws) => wsHandlers.open(ws),
|
|
4408
4553
|
message: (ws, message) => wsHandlers.message(ws, message),
|
|
4409
4554
|
close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
|