@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/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
|
|
17
17
|
* `check:publish` asserts it equals `@nifrajs/core`'s package version.
|
|
18
18
|
*/
|
|
19
|
-
export const VERSION = "5.
|
|
19
|
+
export const VERSION = "5.3.0" as const
|
|
20
20
|
|
|
21
21
|
export type Version = typeof VERSION
|
|
22
22
|
|
|
@@ -16,6 +16,10 @@ export type HookKind =
|
|
|
16
16
|
| "onResponse"
|
|
17
17
|
| "responseHeaders"
|
|
18
18
|
| "onResponseFinalized"
|
|
19
|
+
// Runtimes a route resolved at registration: reflected beside its hooks, never tagged as one.
|
|
20
|
+
| "effectLedger"
|
|
21
|
+
| "idempotency"
|
|
22
|
+
| "responseContract"
|
|
19
23
|
|
|
20
24
|
export interface HookOrigin {
|
|
21
25
|
readonly kind: HookKind
|
|
@@ -48,11 +52,24 @@ export interface AdoptionTag {
|
|
|
48
52
|
readonly prefix: string | undefined
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
/** A `merge()` whose routes carry no authentication of their own while the parent authenticates the
|
|
56
|
+
* routes it declares: the parent's order-scoped auth never runs for merged routes. */
|
|
57
|
+
export interface MergeAuthGapTag {
|
|
58
|
+
/** The `merge()` call's `file:line:col`, when a frame outside core was found. */
|
|
59
|
+
readonly site: string | undefined
|
|
60
|
+
/** The parent's auth in effect at the merge: `authenticate(<id>)` stages and evidence sources. */
|
|
61
|
+
readonly auth: readonly string[]
|
|
62
|
+
/** `METHOD /path` of each merged route the parent's auth would have covered. */
|
|
63
|
+
readonly routes: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
const origins = new WeakMap<object, HookOrigin>()
|
|
52
67
|
const scopes = new WeakMap<object, HookScopeTag>()
|
|
53
68
|
const sources = new WeakMap<object, unknown>()
|
|
54
69
|
const statics = new WeakMap<object, StaticHeadersTag[]>()
|
|
55
70
|
const adoptions = new WeakMap<object, AdoptionTag[]>()
|
|
71
|
+
const mergeAuthGaps = new WeakMap<object, MergeAuthGapTag[]>()
|
|
72
|
+
const routeSites = new WeakMap<object, Map<string, string>>()
|
|
56
73
|
let applying: string | undefined
|
|
57
74
|
// A merged server whose static headers are about to become one of the target's response hooks.
|
|
58
75
|
let staticsOwner: object | undefined
|
|
@@ -130,6 +147,20 @@ export function tagAdoption(target: object, source: object, prefix: string | und
|
|
|
130
147
|
else list.push(tag)
|
|
131
148
|
}
|
|
132
149
|
|
|
150
|
+
export function tagMergeAuthGap(target: object, gap: MergeAuthGapTag): void {
|
|
151
|
+
const list = mergeAuthGaps.get(target)
|
|
152
|
+
if (list === undefined) mergeAuthGaps.set(target, [gap])
|
|
153
|
+
else list.push(gap)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Where `server` registered the route `key` (`METHOD /path`): a `file:line:col` frame. The first
|
|
157
|
+
* registration of a key wins, as it does for the router. */
|
|
158
|
+
export function tagRouteSite(server: object, key: string, site: string): void {
|
|
159
|
+
const sites = routeSites.get(server)
|
|
160
|
+
if (sites === undefined) routeSites.set(server, new Map([[key, site]]))
|
|
161
|
+
else if (!sites.has(key)) sites.set(key, site)
|
|
162
|
+
}
|
|
163
|
+
|
|
133
164
|
const outer: (string | undefined)[] = []
|
|
134
165
|
|
|
135
166
|
/** Mark the plugin a `use()` is applying, until the matching {@link leavePlugin}. */
|
|
@@ -151,3 +182,7 @@ export const hookSourceOf = (hook: unknown): unknown =>
|
|
|
151
182
|
export const staticHeadersOf = (server: object): readonly StaticHeadersTag[] =>
|
|
152
183
|
statics.get(server) ?? []
|
|
153
184
|
export const adoptionsOf = (server: object): readonly AdoptionTag[] => adoptions.get(server) ?? []
|
|
185
|
+
export const mergeAuthGapsOf = (server: object): readonly MergeAuthGapTag[] =>
|
|
186
|
+
mergeAuthGaps.get(server) ?? []
|
|
187
|
+
export const routeSitesOf = (server: object): ReadonlyMap<string, string> =>
|
|
188
|
+
routeSites.get(server) ?? new Map()
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { StandardSchemaV1 } from "../schema/standard.ts"
|
|
11
11
|
import { markAuthenticatedRequest } from "../server/auth.ts"
|
|
12
|
+
import { declaredHeaderNames } from "../server/headers.ts"
|
|
12
13
|
import { pathnameOf } from "../server/http.ts"
|
|
13
14
|
import { queryObjectOf } from "../server/query.ts"
|
|
14
15
|
import type { Registry } from "../server/registry.ts"
|
|
@@ -35,7 +36,12 @@ import type {
|
|
|
35
36
|
export type ProgramValidationKind = "headers" | "cookies" | "params" | "body" | "query"
|
|
36
37
|
|
|
37
38
|
export type RouteProgramStage =
|
|
38
|
-
| {
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: ProgramValidationKind
|
|
41
|
+
readonly schema: StandardSchemaV1
|
|
42
|
+
/** Headers only: the names a closed schema validates, read case-insensitively. */
|
|
43
|
+
readonly names?: readonly string[] | undefined
|
|
44
|
+
}
|
|
39
45
|
| { readonly kind: "derive"; readonly run: RawDerive }
|
|
40
46
|
| { readonly kind: "before"; readonly run: RawBeforeHandle }
|
|
41
47
|
| { readonly kind: "handler"; readonly run: InternalHandler }
|
|
@@ -90,7 +96,8 @@ export function compileRouteProgram(input: RouteProgramInput): RouteProgram {
|
|
|
90
96
|
for (const kind of ["headers", "cookies", "params", "body", "query"] as const) {
|
|
91
97
|
const validator = schema?.[kind]
|
|
92
98
|
if (validator !== undefined) {
|
|
93
|
-
const
|
|
99
|
+
const names = kind === "headers" ? declaredHeaderNames(validator) : undefined
|
|
100
|
+
const stage = { kind, schema: validator, names }
|
|
94
101
|
stages.push(stage)
|
|
95
102
|
validationStages.push(stage)
|
|
96
103
|
}
|
|
@@ -142,6 +149,7 @@ interface RouteProgramRuntime {
|
|
|
142
149
|
ctx: RawContext,
|
|
143
150
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
144
151
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
152
|
+
fallback?: () => T,
|
|
145
153
|
): MaybePromise<T>
|
|
146
154
|
finishProgramResult<T>(
|
|
147
155
|
entry: RouteEntry,
|
|
@@ -243,6 +251,21 @@ function validAuthenticationFailureReason(
|
|
|
243
251
|
)
|
|
244
252
|
}
|
|
245
253
|
|
|
254
|
+
/** A failed stage still answers 503, but its cause takes the handler-error path (onError, else the
|
|
255
|
+
* logger) instead of vanishing. */
|
|
256
|
+
function failAuthentication<T>(
|
|
257
|
+
host: RouteProgramRuntime,
|
|
258
|
+
entry: RouteEntry,
|
|
259
|
+
ctx: RawContext,
|
|
260
|
+
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
261
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
262
|
+
error: unknown,
|
|
263
|
+
): ProgramResult<T> {
|
|
264
|
+
return host.handleProgramError(entry, error, ctx, finalize, wrapResponse, () =>
|
|
265
|
+
finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx),
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
246
269
|
function runAuthenticationStages<T>(
|
|
247
270
|
host: RouteProgramRuntime,
|
|
248
271
|
entry: RouteEntry,
|
|
@@ -287,8 +310,8 @@ function runAuthenticationStages<T>(
|
|
|
287
310
|
let outcome: MaybePromise<import("../server/auth.ts").AuthenticationResult<unknown>>
|
|
288
311
|
try {
|
|
289
312
|
outcome = stage.run(input)
|
|
290
|
-
} catch {
|
|
291
|
-
return
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
|
|
292
315
|
}
|
|
293
316
|
if (outcome instanceof Promise) {
|
|
294
317
|
return outcome.then(
|
|
@@ -312,7 +335,7 @@ function runAuthenticationStages<T>(
|
|
|
312
335
|
return host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
|
|
313
336
|
}
|
|
314
337
|
},
|
|
315
|
-
() =>
|
|
338
|
+
(error) => failAuthentication(host, entry, ctx, finalize, wrapResponse, error),
|
|
316
339
|
)
|
|
317
340
|
}
|
|
318
341
|
return finishAuthentication(
|
|
@@ -344,27 +367,23 @@ function finishAuthentication<T>(
|
|
|
344
367
|
if (
|
|
345
368
|
result === null ||
|
|
346
369
|
typeof result !== "object" ||
|
|
347
|
-
(result.kind
|
|
370
|
+
(result.kind === "rejected"
|
|
371
|
+
? !validAuthenticationFailureReason(result.reason) ||
|
|
372
|
+
(result.response !== undefined &&
|
|
373
|
+
!(result.response instanceof Response) &&
|
|
374
|
+
!isResponseResult(result.response))
|
|
375
|
+
: result.kind !== "authenticated" ||
|
|
376
|
+
result.principal === null ||
|
|
377
|
+
result.principal === undefined)
|
|
348
378
|
) {
|
|
349
|
-
|
|
379
|
+
const id = program.authStages[next - 1]!.id
|
|
380
|
+
const error = new TypeError(`authenticate() stage "${id}" returned an invalid decision`)
|
|
381
|
+
return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
|
|
350
382
|
}
|
|
351
383
|
if (result.kind === "rejected") {
|
|
352
|
-
if (!validAuthenticationFailureReason(result.reason)) {
|
|
353
|
-
return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
|
|
354
|
-
}
|
|
355
|
-
if (
|
|
356
|
-
result.response !== undefined &&
|
|
357
|
-
!(result.response instanceof Response) &&
|
|
358
|
-
!isResponseResult(result.response)
|
|
359
|
-
) {
|
|
360
|
-
return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
|
|
361
|
-
}
|
|
362
384
|
const response = result.response ?? defaultAuthenticationResponse(result.reason)
|
|
363
385
|
return finalize(response, responseSet(ctx), ctx)
|
|
364
386
|
}
|
|
365
|
-
if (result.principal === null || result.principal === undefined) {
|
|
366
|
-
return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
|
|
367
|
-
}
|
|
368
387
|
Object.defineProperty(ctx, "principal", {
|
|
369
388
|
value: result.principal,
|
|
370
389
|
enumerable: true,
|
package/src/mount.ts
CHANGED
|
@@ -76,6 +76,31 @@ export function preRouteMountPaths(app: unknown): readonly string[] {
|
|
|
76
76
|
return paths
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const RESIDENT_BODIES = new WeakSet<Response>()
|
|
80
|
+
|
|
81
|
+
/** Set once an in-process client exists, so the leanest response lane records its bodies too. */
|
|
82
|
+
export let residentBodiesRecorded = false
|
|
83
|
+
|
|
84
|
+
/** Called by an in-process client: nothing else reads the record, so until then it can be skipped. */
|
|
85
|
+
export function recordResidentBodies(): void {
|
|
86
|
+
residentBodiesRecorded = true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Record a Response whose body the framework serialized into memory before building it. An
|
|
91
|
+
* in-process client reads only such a body whole; any other, a handler's own stream included, is
|
|
92
|
+
* read under its byte cap.
|
|
93
|
+
*/
|
|
94
|
+
export function markResidentBody(response: Response): Response {
|
|
95
|
+
RESIDENT_BODIES.add(response)
|
|
96
|
+
return response
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether {@link markResidentBody} recorded `response`. Another copy of this module did not. */
|
|
100
|
+
export function hasResidentBody(response: Response): boolean {
|
|
101
|
+
return RESIDENT_BODIES.has(response)
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/** Structural mount capability exposed by an in-process typed client. */
|
|
80
105
|
export interface BackendMount<Env = unknown> {
|
|
81
106
|
readonly [NIFRA_BACKEND_MOUNT]: BackendMountHandler<Env>
|
package/src/reflection.ts
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
hookOriginOf,
|
|
21
21
|
hookScopeOf,
|
|
22
22
|
hookSourceOf,
|
|
23
|
+
type MergeAuthGapTag,
|
|
24
|
+
mergeAuthGapsOf,
|
|
25
|
+
routeSitesOf,
|
|
23
26
|
staticHeadersOf,
|
|
24
27
|
} from "./internal/hook-origins.ts"
|
|
25
28
|
import {
|
|
@@ -33,6 +36,8 @@ import { NIFRA_BACKEND_EVIDENCE } from "./mount.ts"
|
|
|
33
36
|
import type { StandardSchemaV1 } from "./schema/standard.ts"
|
|
34
37
|
import type { ToolAnnotations } from "./server/server.ts"
|
|
35
38
|
|
|
39
|
+
export { declaredHeaderNames } from "./server/headers.ts"
|
|
40
|
+
|
|
36
41
|
/** JSON Schema permits either a schema object or the boolean schemas `true` and `false`. */
|
|
37
42
|
export type JsonSchema = boolean | Readonly<Record<string, unknown>>
|
|
38
43
|
|
|
@@ -460,7 +465,9 @@ export interface ReflectedHook {
|
|
|
460
465
|
readonly kind: HookKind
|
|
461
466
|
/** The plugin that registered it, else the function's own name, else `(anonymous)`. */
|
|
462
467
|
readonly name: string
|
|
463
|
-
/** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown
|
|
468
|
+
/** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown`; `route`
|
|
469
|
+
* for a runtime the route resolved at registration (`responseContract`, `effectLedger`,
|
|
470
|
+
* `idempotency`), whose `name` is the contract mode or the runtime. */
|
|
464
471
|
readonly site: string
|
|
465
472
|
/** The header names a static `responseHeaders()` declaration sets. */
|
|
466
473
|
readonly headers?: readonly string[]
|
|
@@ -559,6 +566,51 @@ const catalogOf = (
|
|
|
559
566
|
const joinPath = (base: string, path: string): string =>
|
|
560
567
|
base === "" ? path : path === "/" ? base : `${base}${path}`
|
|
561
568
|
|
|
569
|
+
/** A `merge()` that authenticates none of its routes under a parent that authenticates its own. */
|
|
570
|
+
export type ReflectedMergeAuthGap = MergeAuthGapTag
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Every `merge()` in the app, its merged servers included, whose server authenticates none of its
|
|
574
|
+
* routes while the parent's order-scoped auth - an `authenticate()` stage, or `authenticated` evidence
|
|
575
|
+
* scoped to subsequent routes - covers the parent's own: that auth never runs for a merged route.
|
|
576
|
+
* Recorded by a development load only (the same provenance {@link reflectHooks} reads), so reflect the
|
|
577
|
+
* app outside production. `nifra check` reports each one (NF-C043).
|
|
578
|
+
*/
|
|
579
|
+
export function reflectMergeAuthGaps(source: unknown): readonly ReflectedMergeAuthGap[] {
|
|
580
|
+
if (!isServerRecord(source)) return []
|
|
581
|
+
const gaps: ReflectedMergeAuthGap[] = []
|
|
582
|
+
const seen = new Set<object>()
|
|
583
|
+
const visit = (server: object): void => {
|
|
584
|
+
if (seen.has(server)) return
|
|
585
|
+
seen.add(server)
|
|
586
|
+
gaps.push(...mergeAuthGapsOf(server))
|
|
587
|
+
for (const adoption of adoptionsOf(server)) visit(adoption.source)
|
|
588
|
+
}
|
|
589
|
+
visit(source)
|
|
590
|
+
return gaps
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Where each route was registered: `METHOD /path` (`WS` for a socket) mapped to the `file:line:col` of
|
|
595
|
+
* the call outside nifra's core that registered it, through every `group()` and `merge()`. Recorded only
|
|
596
|
+
* by a development load with `NIFRA_ROUTE_SITES=1` (the CLI sets it while it loads `nifra.assurance.ts`),
|
|
597
|
+
* so the map is empty otherwise. The paths are absolute, which is why they stay out of
|
|
598
|
+
* {@link reflectRoutes}, whose output is written into artifacts.
|
|
599
|
+
*/
|
|
600
|
+
export function reflectRouteSites(source: unknown): ReadonlyMap<string, string> {
|
|
601
|
+
const sites = new Map<string, string>()
|
|
602
|
+
if (!isServerRecord(source)) return sites
|
|
603
|
+
const seen = new Set<object>()
|
|
604
|
+
const visit = (server: object): void => {
|
|
605
|
+
if (seen.has(server)) return
|
|
606
|
+
seen.add(server)
|
|
607
|
+
for (const [key, site] of routeSitesOf(server)) if (!sites.has(key)) sites.set(key, site)
|
|
608
|
+
for (const adoption of adoptionsOf(server)) visit(adoption.source)
|
|
609
|
+
}
|
|
610
|
+
visit(source)
|
|
611
|
+
return sites
|
|
612
|
+
}
|
|
613
|
+
|
|
562
614
|
/**
|
|
563
615
|
* Every hook that actually runs for each route, and for each prefix's unmatched paths, with the plugin
|
|
564
616
|
* that registered it and where: the app, a `group()`, a `merge()`d server, or a `mount()`ed one. It
|
|
@@ -698,16 +750,24 @@ export function reflectHooks(source: unknown): ReflectedHookReport {
|
|
|
698
750
|
const entry = recordOf(route.entry)
|
|
699
751
|
const chain = (field: string, kind: HookKind) =>
|
|
700
752
|
entry === undefined ? [] : listOf(entry, field).flatMap((hook) => entriesOf(hook, kind))
|
|
753
|
+
// A runtime is pinned into the route when it registers (or is merged), so it is listed with the
|
|
754
|
+
// route's site rather than a hook's; the mode is the name so a warn -> enforce change shows.
|
|
755
|
+
const mode = recordOf(recordOf(entry?.responseContract)?.runtime)?.mode
|
|
756
|
+
const pinned = (present: boolean, kind: HookKind, name: string): ReflectedHook[] =>
|
|
757
|
+
present ? [{ kind, name, site: "route" }] : []
|
|
701
758
|
routes.push({
|
|
702
759
|
method: route.method,
|
|
703
760
|
path: reportPath(route.path),
|
|
704
761
|
hooks: [
|
|
705
762
|
...requestPhase(probes),
|
|
706
763
|
...chain("around", "around"),
|
|
764
|
+
...pinned(entry?.ledgered !== undefined, "effectLedger", "effectLedger"),
|
|
707
765
|
...chain("authStages", "authenticate"),
|
|
708
766
|
...chain("derives", "derive"),
|
|
709
767
|
...chain("beforeHandle", "beforeHandle"),
|
|
768
|
+
...pinned(entry?.idempotent !== undefined, "idempotency", "idempotency"),
|
|
710
769
|
...chain("afterHandle", "afterHandle"),
|
|
770
|
+
...pinned(typeof mode === "string", "responseContract", String(mode)),
|
|
711
771
|
...chain("onError", "onError"),
|
|
712
772
|
...responsePhase(probes),
|
|
713
773
|
],
|
package/src/server/context.ts
CHANGED
|
@@ -312,9 +312,12 @@ export interface RouteSchema {
|
|
|
312
312
|
* The schema must cover every path param; with the strict default an undeclared path param is rejected.
|
|
313
313
|
* When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
|
|
314
314
|
readonly params?: StandardSchemaV1
|
|
315
|
-
/** Optional request-header schema
|
|
316
|
-
*
|
|
317
|
-
* case-
|
|
315
|
+
/** Optional request-header schema; the validated output is `c.headers` and a failure is a `422`.
|
|
316
|
+
* Every client sends headers a route never declares (`host`, `user-agent`, `accept`), so a closed
|
|
317
|
+
* object schema (`t.object`) validates only the names it declares, each read case-insensitively and
|
|
318
|
+
* keyed as declared. `t.headers` is the open form: it receives every header, names lower-cased,
|
|
319
|
+
* and coerces values like `t.query`. A schema that does not expose its JSON Schema (a zod
|
|
320
|
+
* `.strict()` or valibot `strictObject`) receives every header, so declare it open. */
|
|
318
321
|
readonly headers?: StandardSchemaV1
|
|
319
322
|
/** Optional request-cookie schema. It validates the cookies parsed from the `Cookie` header -
|
|
320
323
|
* names as sent, values URL-decoded strings, the first of a repeated name - before the handler
|
|
@@ -446,7 +449,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
446
449
|
/** Read one request header without materializing a Web `Request` on the Node adapter hot path. */
|
|
447
450
|
readonly header: (name: string) => string | null
|
|
448
451
|
readonly params: ParamsOf<S, Path>
|
|
449
|
-
/**
|
|
452
|
+
/** Request headers, names lower-cased; when `schema.headers` is declared, its validated output. */
|
|
450
453
|
readonly headers: HeadersOf<S>
|
|
451
454
|
readonly query: QueryOf<S>
|
|
452
455
|
readonly body: BodyOf<S>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
const registry = globalThis as unknown as Record<symbol, string[] | undefined>
|
|
12
12
|
const COPIES = Symbol.for("nifra.core.copies")
|
|
13
|
+
const VERSIONS = Symbol.for("nifra.core.copy-versions")
|
|
13
14
|
registry[COPIES] ??= []
|
|
14
15
|
const copies = registry[COPIES]
|
|
15
16
|
const here = String(import.meta.url).split("?")[0] as string
|
|
@@ -19,3 +20,42 @@ if (!copies.includes(here) && copies.push(here) > 1) {
|
|
|
19
20
|
`[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}`,
|
|
20
21
|
)
|
|
21
22
|
}
|
|
23
|
+
|
|
24
|
+
/** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
|
|
25
|
+
* `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
|
|
26
|
+
const COPY_VERSION = "5.3.0"
|
|
27
|
+
|
|
28
|
+
/** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
|
|
29
|
+
* so a production bundle drops it with the version; a copy that built a server has recorded one. */
|
|
30
|
+
export function recordCopyVersion(): void {
|
|
31
|
+
versionsByUrl().set(here, COPY_VERSION)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The process-wide URL -> version map every copy shares. */
|
|
35
|
+
const versionsByUrl = (): Map<unknown, unknown> => {
|
|
36
|
+
const found: unknown = Reflect.get(globalThis, VERSIONS)
|
|
37
|
+
if (found instanceof Map) return found
|
|
38
|
+
const created = new Map<unknown, unknown>()
|
|
39
|
+
Reflect.set(globalThis, VERSIONS, created)
|
|
40
|
+
return created
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const labelled = (url: string): string => {
|
|
44
|
+
const version = versionsByUrl().get(url)
|
|
45
|
+
return typeof version === "string" ? `${url} (${version})` : url
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Why `merge()` refused a value that is not a server of this copy: when another copy is loaded, the
|
|
49
|
+
* value is most likely its server, so both copies are named, with their versions where recorded
|
|
50
|
+
* (copies before 5.2 record none), and the way to collapse them. Called only outside production. */
|
|
51
|
+
export function mergeRefusal(): string {
|
|
52
|
+
const base = "merge() requires a server() from this copy of @nifrajs/core"
|
|
53
|
+
const others = copies.filter((url) => url !== here)
|
|
54
|
+
if (others.length === 0) return base
|
|
55
|
+
return [
|
|
56
|
+
`${base}, and ${copies.length} copies are loaded:`,
|
|
57
|
+
` this copy: ${labelled(here)}`,
|
|
58
|
+
...others.map((url) => ` other copy: ${labelled(url)}`),
|
|
59
|
+
"A server() from another copy cannot be merged. Make every package that builds a server() resolve one copy: in a linked repository, install the same nifra version (`nifra upgrade <version> --write`, then `bun install`); `nifra doctor` names each importer.",
|
|
60
|
+
].join("\n")
|
|
61
|
+
}
|
package/src/server/headers.ts
CHANGED
|
@@ -9,3 +9,42 @@ export function headerObjectOf(headers: Headers): Record<string, string> {
|
|
|
9
9
|
for (const [name, value] of headers) out[name.toLowerCase()] = value
|
|
10
10
|
return out
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
const HEADER_NAME = /^[\w!#$%&'*+.^`|~-]+$/
|
|
14
|
+
|
|
15
|
+
/** The parts of a header schema's JSON Schema that decide how the slot reads the request. */
|
|
16
|
+
interface HeaderJsonSchema {
|
|
17
|
+
readonly type?: unknown
|
|
18
|
+
readonly additionalProperties?: unknown
|
|
19
|
+
readonly patternProperties?: unknown
|
|
20
|
+
readonly properties?: object
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The names a closed header schema declares, or `undefined` when it is open or lists no names.
|
|
25
|
+
* Every client sends headers no route declares (`host`, `user-agent`, `accept`), so a closed schema
|
|
26
|
+
* fed the whole record would refuse all real traffic; it is fed the names it declares instead,
|
|
27
|
+
* which is also how OpenAPI publishes it. A name that is not a valid header token is never sent.
|
|
28
|
+
*/
|
|
29
|
+
export function declaredHeaderNames(schema: object): readonly string[] | undefined {
|
|
30
|
+
const json: HeaderJsonSchema | null | undefined = Reflect.get(schema, "jsonSchema")
|
|
31
|
+
return json?.type === "object" &&
|
|
32
|
+
json.additionalProperties === false &&
|
|
33
|
+
json.patternProperties === undefined &&
|
|
34
|
+
json.properties
|
|
35
|
+
? Object.keys(json.properties).filter((name) => HEADER_NAME.test(name))
|
|
36
|
+
: undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The declared headers present on the request, each under the name as declared. */
|
|
40
|
+
export function declaredHeaders(
|
|
41
|
+
headers: Headers,
|
|
42
|
+
names: readonly string[],
|
|
43
|
+
): Record<string, string> {
|
|
44
|
+
const out: Record<string, string> = Object.create(null)
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const value = headers.get(name)
|
|
47
|
+
if (value !== null) out[name] = value
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
package/src/server/registry.ts
CHANGED
|
@@ -65,27 +65,26 @@ type IsSuccessCode<Code extends number> = `${Code}` extends `2${string}` ? true
|
|
|
65
65
|
|
|
66
66
|
type EmptyResponseMap = NonNullable<unknown>
|
|
67
67
|
|
|
68
|
-
type
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
type InferredResponses<Output> = (Exclude<
|
|
68
|
+
type PlainOutput<Output> = Exclude<Output, StatusResponse<number, unknown> | Response>
|
|
69
|
+
|
|
70
|
+
type HasDynamicStatus<Output> =
|
|
71
|
+
Output extends StatusResponse<infer Code, unknown> ? (number extends Code ? true : never) : never
|
|
72
|
+
|
|
73
|
+
/** A dynamic code may be an error at runtime, but whenever it is a 2xx its body is the one passed to
|
|
74
|
+
* `status()`, so it joins the 200 arm (`StatusBodyAt` matches it there) instead of reading `unknown`. */
|
|
75
|
+
type UnnamedSuccessCode<Output> = [PlainOutput<Output> | HasDynamicStatus<Output>] extends [never]
|
|
76
|
+
? never
|
|
77
|
+
: 200
|
|
78
|
+
|
|
79
|
+
/** One map, so a plain return and a `status(200, …)` union on the 200 arm rather than intersect. */
|
|
80
|
+
type InferredResponses<
|
|
82
81
|
Output,
|
|
83
|
-
|
|
84
|
-
> extends never
|
|
82
|
+
Codes extends number = StatusCodeOf<Output> | UnnamedSuccessCode<Output>,
|
|
83
|
+
> = [Codes] extends [never]
|
|
85
84
|
? EmptyResponseMap
|
|
86
|
-
: {
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
: {
|
|
86
|
+
[Code in Codes]: StatusBodyAt<Output, Code> | (Code extends 200 ? PlainOutput<Output> : never)
|
|
87
|
+
}
|
|
89
88
|
|
|
90
89
|
type ExplicitResponses<S extends RouteSchema> = (S extends {
|
|
91
90
|
response: infer R extends StandardSchemaV1
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* sits below the server module in the graph.
|
|
6
6
|
*/
|
|
7
7
|
import type { RequestBudget } from "../budget.ts"
|
|
8
|
+
import { markResidentBody } from "../mount.ts"
|
|
8
9
|
import { applyTransportCap, readBoundedBytes } from "./body.ts"
|
|
9
10
|
import { readBoundedJsonSource } from "./body-lane.ts"
|
|
10
11
|
import type { Platform } from "./context.ts"
|
|
@@ -348,7 +349,7 @@ export class RequestContext implements RawContext {
|
|
|
348
349
|
reply = defer(text, i?.status ?? 200, headers)
|
|
349
350
|
}
|
|
350
351
|
}
|
|
351
|
-
reply ??= Response.json(body, i)
|
|
352
|
+
reply ??= markResidentBody(Response.json(body, i))
|
|
352
353
|
// Remembered so a response contract holds the value to the route's schema: this is the
|
|
353
354
|
// framework's JSON helper, not a raw-Response escape hatch.
|
|
354
355
|
this.jsonBody = body
|
|
@@ -377,8 +378,10 @@ export class RequestContext implements RawContext {
|
|
|
377
378
|
}
|
|
378
379
|
}
|
|
379
380
|
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
380
|
-
if (i?.headers !== undefined) return new Response(body, i)
|
|
381
|
-
return
|
|
381
|
+
if (i?.headers !== undefined) return markResidentBody(new Response(body, i))
|
|
382
|
+
return markResidentBody(
|
|
383
|
+
new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } }),
|
|
384
|
+
)
|
|
382
385
|
}
|
|
383
386
|
|
|
384
387
|
get query(): unknown {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dot segments in a request target, resolved the way a WHATWG URL parser resolves them
|
|
2
|
+
* Dot segments in a request target, resolved the way a WHATWG URL parser resolves them, and the
|
|
3
|
+
* characters it percent-encodes in a path, encoded.
|
|
3
4
|
*
|
|
4
5
|
* Bun and workerd hand an app a request whose URL is already parsed: a client that sends
|
|
5
6
|
* `/users/../posts`, `/users/%2e%2e/posts` or `/users/..\posts` is asking for `/posts`, and that
|
|
@@ -7,24 +8,28 @@
|
|
|
7
8
|
* exactly as it was sent. Routing the raw text on those runtimes and the parsed text on the others
|
|
8
9
|
* sends one request to two different handlers - with two different sets of guards - depending on
|
|
9
10
|
* where the app runs. So a raw target is resolved before anything reads it, and every runtime routes
|
|
10
|
-
* the same path.
|
|
11
|
+
* the same path. The same goes for a raw `/vault<tag>`: the parsed URL is `/vault%3Ctag%3E`, the
|
|
12
|
+
* path a route registered for that text matches.
|
|
11
13
|
*
|
|
12
14
|
* No imports: `@nifrajs/node` carries a generated copy of this module, since it never depends on
|
|
13
15
|
* core at runtime.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
|
-
// A `.` or `..` segment (`%2e` counts as a dot, in either case),
|
|
17
|
-
// parser reads as a `/` in an http(s) path
|
|
18
|
-
|
|
18
|
+
// A `.` or `..` segment (`%2e` counts as a dot, in either case), a backslash, which a WHATWG
|
|
19
|
+
// parser reads as a `/` in an http(s) path, or, before any `?` or `#`, a character outside the
|
|
20
|
+
// class the parser keeps as written in a path: a control, space, `"`, `<`, `>`, `^`, `` ` ``, `{`,
|
|
21
|
+
// `}`, or anything past ASCII, all of which it percent-encodes.
|
|
22
|
+
const DOT_SEGMENT =
|
|
23
|
+
/\\|\/(?:\.|%2e){1,2}(?:[/?#]|$)|^[^?#]*[^!#$%&'()*+,\-./0-9:;=?@A-Z[\\\]_a-z|~]/i
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Whether `target` - an absolute URL or an origin-form `/path?query` - may hold a `.` or `..`
|
|
22
|
-
* segment, raw or percent-encoded,
|
|
23
|
-
* when the path has one. It can be true when only
|
|
24
|
-
* resolving that target leaves its path and query as they
|
|
25
|
-
* always percent-encodes.
|
|
27
|
+
* segment, raw or percent-encoded, a backslash, or a character a WHATWG parser percent-encodes in a
|
|
28
|
+
* path: what that parser would rewrite. Never false when the path has one. It can be true when only
|
|
29
|
+
* the query looks like one (`?next=/../x`): resolving that target leaves its path and query as they
|
|
30
|
+
* are, apart from the characters a parser always percent-encodes.
|
|
26
31
|
*/
|
|
27
|
-
export function
|
|
32
|
+
export function needsResolving(target: string): boolean {
|
|
28
33
|
// One regex pass: measured faster on Node and Deno than substring scans that gate it.
|
|
29
34
|
return DOT_SEGMENT.test(target)
|
|
30
35
|
}
|
|
@@ -37,10 +42,14 @@ export function hasDotSegment(target: string): boolean {
|
|
|
37
42
|
* `*`, or text no URL parser accepts - comes back as it arrived: there is no path to resolve.
|
|
38
43
|
*/
|
|
39
44
|
export function resolveDotSegments(target: string): string {
|
|
45
|
+
// Bun, Node and workerd's parsers percent-encode a `^` in a path; Deno's keeps it as written.
|
|
40
46
|
if (target.charCodeAt(0) === 47 /* / */) {
|
|
41
47
|
// Appended to an origin, not resolved against one: `//host/..` must stay a path on this origin.
|
|
42
48
|
const url = new URL(`http://localhost${target}`)
|
|
43
|
-
return url.pathname + url.search
|
|
49
|
+
return url.pathname.replaceAll("^", "%5E") + url.search
|
|
44
50
|
}
|
|
45
|
-
|
|
51
|
+
if (!URL.canParse(target)) return target
|
|
52
|
+
const url = new URL(target)
|
|
53
|
+
url.pathname = url.pathname.replaceAll("^", "%5E")
|
|
54
|
+
return url.href
|
|
46
55
|
}
|
package/src/server/respond.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* spine types, so the wire-response format lives in one module distinct from the request engine.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { hasResidentBody, markResidentBody, residentBodiesRecorded } from "../mount.ts"
|
|
7
8
|
import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
|
|
8
9
|
import { plainError } from "./http.ts"
|
|
9
10
|
import {
|
|
@@ -227,7 +228,8 @@ const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
|
|
|
227
228
|
|
|
228
229
|
/**
|
|
229
230
|
* Responses known to carry a body and take header writes: framework renders built with one, and
|
|
230
|
-
* foreign ones `writableResponse` has already checked. A body
|
|
231
|
+
* foreign ones `writableResponse` has already checked. A body the framework serialized itself is
|
|
232
|
+
* recorded by `markResidentBody` instead, and counts the same. A body is sent once, so such a response belongs
|
|
231
233
|
* to whichever request holds it and is written in place without reading `.body` again - on Bun that
|
|
232
234
|
* read turns a string body into a stream, measured at about 6% of a request.
|
|
233
235
|
*/
|
|
@@ -252,10 +254,9 @@ export function rememberRenderedResponse(response: Response, hasBody: boolean):
|
|
|
252
254
|
return response
|
|
253
255
|
}
|
|
254
256
|
|
|
255
|
-
/** `rememberRenderedResponse` for a Response the framework
|
|
257
|
+
/** `rememberRenderedResponse` for a Response the framework built around a body it serialized. */
|
|
256
258
|
function withBody(response: Response): Response {
|
|
257
|
-
|
|
258
|
-
return response
|
|
259
|
+
return markResidentBody(response)
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
@@ -289,7 +290,7 @@ function headersTakeWrites(headers: Headers): boolean {
|
|
|
289
290
|
* handed back on any other request, that copy is copied again.
|
|
290
291
|
*/
|
|
291
292
|
export function writableResponse(response: Response, request?: Request): Response {
|
|
292
|
-
if (WRITABLE_BODY_RESPONSES.has(response)) return response
|
|
293
|
+
if (hasResidentBody(response) || WRITABLE_BODY_RESPONSES.has(response)) return response
|
|
293
294
|
if (response.body === null) {
|
|
294
295
|
// A 101 upgrade and the status-0 `Response.error()` cannot be constructed, and a runtime makes
|
|
295
296
|
// them per request.
|
|
@@ -426,7 +427,8 @@ export function fusedRespondNoSet(
|
|
|
426
427
|
// hook walk is empty; an app that registers ANY response hook keeps the record, because the
|
|
427
428
|
// alternative is `writableResponse` reading `.body` and probing the headers of every response (each
|
|
428
429
|
// one is fresh, so nothing amortizes) - the probe alone measured 19% slower than the WeakSet write.
|
|
429
|
-
// Default false: an unknown caller pays one WeakSet write rather than a probe per response.
|
|
430
|
+
// Default false: an unknown caller pays one WeakSet write rather than a probe per response. Once an
|
|
431
|
+
// in-process client exists the write is no longer dead: it reads a recorded body whole.
|
|
430
432
|
if (
|
|
431
433
|
unstamped &&
|
|
432
434
|
(tagResponseBody === false || tagResponseBody === undefined) &&
|
|
@@ -437,7 +439,8 @@ export function fusedRespondNoSet(
|
|
|
437
439
|
!(result instanceof Response) &&
|
|
438
440
|
!isResponseResult(result)
|
|
439
441
|
) {
|
|
440
|
-
|
|
442
|
+
const response = Response.json(result)
|
|
443
|
+
return residentBodiesRecorded ? markResidentBody(response) : response
|
|
441
444
|
}
|
|
442
445
|
if (
|
|
443
446
|
result !== undefined &&
|