@nifrajs/core 2.2.0 → 2.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/README.md +2 -2
- package/dist/assurance.d.ts +48 -4
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +87 -9
- package/dist/assurance.js.map +1 -1
- package/dist/binary.d.ts +54 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +90 -0
- package/dist/binary.js.map +1 -0
- package/dist/budget.d.ts +1 -1
- package/dist/budget.js +1 -1
- package/dist/capabilities.d.ts +19 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +60 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/classification.d.ts +1 -1
- package/dist/classification.js +1 -1
- package/dist/diff.d.ts +6 -6
- package/dist/diff.js +6 -6
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +36 -70
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts +2 -2
- package/dist/durable-execution.js +2 -2
- package/dist/idempotency.d.ts +3 -3
- package/dist/idempotency.js +4 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/internal/brand.d.ts +1 -1
- package/dist/internal/brand.js +1 -1
- package/dist/internal/route-catalog.d.ts +58 -0
- package/dist/internal/route-catalog.d.ts.map +1 -0
- package/dist/internal/route-catalog.js +58 -0
- package/dist/internal/route-catalog.js.map +1 -0
- package/dist/internal/route-execution.d.ts +75 -0
- package/dist/internal/route-execution.d.ts.map +1 -0
- package/dist/internal/route-execution.js +2 -0
- package/dist/internal/route-execution.js.map +1 -0
- package/dist/internal/same-origin.d.ts +29 -0
- package/dist/internal/same-origin.d.ts.map +1 -0
- package/dist/internal/same-origin.js +47 -0
- package/dist/internal/same-origin.js.map +1 -0
- package/dist/internal/secret.d.ts +1 -1
- package/dist/internal/secret.js +1 -1
- package/dist/ledger.d.ts +9 -9
- package/dist/ledger.js +5 -5
- package/dist/reflection.d.ts +1 -1
- package/dist/router/pattern.js +4 -4
- package/dist/router/router.js +3 -3
- package/dist/schema/standard.d.ts +2 -2
- package/dist/schema/standard.js +2 -2
- package/dist/seo.d.ts +3 -3
- package/dist/seo.js +4 -4
- package/dist/server/body.d.ts +2 -2
- package/dist/server/body.js +2 -2
- package/dist/server/context.d.ts +18 -18
- package/dist/server/contract.d.ts +7 -7
- package/dist/server/contract.js +3 -3
- package/dist/server/cookies.d.ts +4 -4
- package/dist/server/cookies.js +8 -8
- package/dist/server/edge.d.ts +83 -0
- package/dist/server/edge.d.ts.map +1 -0
- package/dist/server/edge.js +71 -0
- package/dist/server/edge.js.map +1 -0
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts +7 -7
- package/dist/server/logger.js +4 -4
- package/dist/server/registry.d.ts +5 -5
- package/dist/server/response-contract-lane.d.ts +91 -0
- package/dist/server/response-contract-lane.d.ts.map +1 -0
- package/dist/server/response-contract-lane.js +81 -0
- package/dist/server/response-contract-lane.js.map +1 -0
- package/dist/server/server.d.ts +52 -91
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +130 -186
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +4 -4
- package/dist/server/sse.js +7 -7
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.js +5 -5
- package/dist/server/websocket.d.ts +19 -19
- package/dist/server/websocket.js +9 -9
- package/dist/server/ws-bun.d.ts +4 -4
- package/dist/server/ws-bun.js +3 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/transport-codec.d.ts +12 -0
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +19 -4
- package/dist/transport-codec.js.map +1 -1
- package/package.json +12 -2
- package/src/assurance.ts +154 -8
- package/src/binary.ts +128 -0
- package/src/budget.ts +1 -1
- package/src/capabilities.ts +71 -9
- package/src/classification.ts +1 -1
- package/src/diff.ts +8 -8
- package/src/durable-adapters.ts +40 -70
- package/src/durable-execution.ts +2 -2
- package/src/idempotency.ts +4 -4
- package/src/index.ts +3 -3
- package/src/internal/brand.ts +1 -1
- package/src/internal/route-catalog.ts +94 -0
- package/src/internal/route-execution.ts +106 -0
- package/src/internal/same-origin.ts +43 -0
- package/src/internal/secret.ts +1 -1
- package/src/ledger.ts +11 -11
- package/src/reflection.ts +1 -1
- package/src/router/pattern.ts +4 -4
- package/src/router/router.ts +5 -5
- package/src/schema/standard.ts +2 -2
- package/src/seo.ts +6 -6
- package/src/server/body.ts +2 -2
- package/src/server/context.ts +18 -18
- package/src/server/contract.ts +13 -13
- package/src/server/cookies.ts +8 -8
- package/src/server/edge.ts +150 -0
- package/src/server/install.ts +5 -0
- package/src/server/logger.ts +8 -8
- package/src/server/registry.ts +5 -5
- package/src/server/response-contract-lane.ts +164 -0
- package/src/server/server.ts +159 -366
- package/src/server/sse.ts +11 -11
- package/src/server/webhook.ts +5 -5
- package/src/server/websocket.ts +22 -22
- package/src/server/ws-bun.ts +4 -4
- package/src/server.ts +8 -5
- package/src/transport-codec.ts +24 -5
package/src/index.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/core`
|
|
2
|
+
* `@nifrajs/core` - Nifra's lean HTTP server API.
|
|
3
3
|
*
|
|
4
4
|
* Nifra 2.0 keeps the package root intentionally lean. Optional systems live at documented
|
|
5
5
|
* subpaths so importing the root cannot activate unrelated runtimes.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Current package version. A hardcoded literal on purpose
|
|
9
|
+
* Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't
|
|
10
10
|
* read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
|
|
11
11
|
* `check:publish` asserts it equals `@nifrajs/core`'s package version.
|
|
12
12
|
*/
|
|
13
|
-
export const VERSION = "2.
|
|
13
|
+
export const VERSION = "2.3.0" as const
|
|
14
14
|
|
|
15
15
|
export type Version = typeof VERSION
|
|
16
16
|
|
package/src/internal/brand.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every developer-facing string (error prefixes, the default OpenAPI title, the
|
|
5
5
|
* logger tag) pulls from here, so renaming the brand is a one-line change. The
|
|
6
|
-
* npm scope (`@nifrajs/*`) is the other naming axis
|
|
6
|
+
* npm scope (`@nifrajs/*`) is the other naming axis - `scripts/rename.ts` flips
|
|
7
7
|
* both at once.
|
|
8
8
|
*/
|
|
9
9
|
export const FRAMEWORK_NAME = "Nifra" as const
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's route table: one canonical fact per registered route, and the router built over them.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not in the server kernel
|
|
5
|
+
*
|
|
6
|
+
* Nothing here knows what a `Server` is. It owns matching, reflection, assurance and native-compilation
|
|
7
|
+
* input, and the kernel holds one instance - which is the whole relationship. The reason it lived in
|
|
8
|
+
* `server.ts` is that `RouteEntry` used to be declared there, and until that vocabulary moved to
|
|
9
|
+
* `internal/` this could not be split out without exporting the engine's internals as public types.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this costs the request path nothing
|
|
12
|
+
*
|
|
13
|
+
* Per-request code calls `this.catalog.find(...)`: a property load and a method call on an instance.
|
|
14
|
+
* Which module DECLARED the class does not enter into that. The only cross-module operation is the
|
|
15
|
+
* `new RouteCatalog()` in the server's constructor, once per server; every other method here runs at
|
|
16
|
+
* registration.
|
|
17
|
+
*/
|
|
18
|
+
import type { CompiledRoutePattern } from "../router/pattern.ts"
|
|
19
|
+
import { type Method, Router, type RouterMatch } from "../router/router.ts"
|
|
20
|
+
import type { RouteDescriptor } from "../server/server-types.ts"
|
|
21
|
+
import type { AssuranceDeclaration } from "./route-assurance.ts"
|
|
22
|
+
import type { RouteEntry } from "./route-execution.ts"
|
|
23
|
+
|
|
24
|
+
/** One canonical runtime route fact. The catalog owns matching, reflection, assurance, tool metadata,
|
|
25
|
+
* replay, and native compilation input so batch registration has one commit point. */
|
|
26
|
+
export interface CatalogRoute {
|
|
27
|
+
readonly method: Method
|
|
28
|
+
readonly path: string
|
|
29
|
+
readonly pattern: CompiledRoutePattern
|
|
30
|
+
readonly entry: RouteEntry
|
|
31
|
+
readonly descriptor: RouteDescriptor
|
|
32
|
+
readonly assurance: readonly AssuranceDeclaration[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
|
|
37
|
+
* the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
|
|
38
|
+
* after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
|
|
39
|
+
* reflection unchanged.
|
|
40
|
+
*
|
|
41
|
+
* Coverage note: this file reports 90% functions with every method exercised. `bun test --coverage`
|
|
42
|
+
* (1.3.14) counts one synthetic function per class that it never marks hit - a one-method class with a
|
|
43
|
+
* passing test for that method reports 50%. So 90 is this file's ceiling, not a missing test; do not go
|
|
44
|
+
* looking for the tenth function.
|
|
45
|
+
*/
|
|
46
|
+
export class RouteCatalog {
|
|
47
|
+
private matcher = new Router<RouteEntry>()
|
|
48
|
+
private records: CatalogRoute[] = []
|
|
49
|
+
/** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
|
|
50
|
+
private descriptors: RouteDescriptor[] = []
|
|
51
|
+
private assurancePresent = false
|
|
52
|
+
|
|
53
|
+
add(route: CatalogRoute): void {
|
|
54
|
+
this.matcher.add(route.method, route.pattern, route.entry)
|
|
55
|
+
this.records.push(route)
|
|
56
|
+
this.descriptors.push(route.descriptor)
|
|
57
|
+
if (route.assurance.length > 0) this.assurancePresent = true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addBatch(routes: readonly CatalogRoute[]): void {
|
|
61
|
+
if (routes.length === 0) return
|
|
62
|
+
const nextRecords = this.records.concat(routes)
|
|
63
|
+
const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor))
|
|
64
|
+
const nextAssurancePresent =
|
|
65
|
+
this.assurancePresent || routes.some((route) => route.assurance.length > 0)
|
|
66
|
+
const staged = new Router<RouteEntry>()
|
|
67
|
+
for (const route of this.records) staged.add(route.method, route.pattern, route.entry)
|
|
68
|
+
for (const route of routes) staged.add(route.method, route.pattern, route.entry)
|
|
69
|
+
this.matcher = staged
|
|
70
|
+
this.records = nextRecords
|
|
71
|
+
this.descriptors = nextDescriptors
|
|
72
|
+
this.assurancePresent = nextAssurancePresent
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
find(method: string, path: string): RouterMatch<RouteEntry> {
|
|
76
|
+
return this.matcher.find(method, path)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
entries(): readonly CatalogRoute[] {
|
|
80
|
+
return this.records
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
routeDescriptors(): ReadonlyArray<RouteDescriptor> {
|
|
84
|
+
return this.descriptors
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
lastDescriptor(): RouteDescriptor | undefined {
|
|
88
|
+
return this.records[this.records.length - 1]?.descriptor
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
hasAssurance(): boolean {
|
|
92
|
+
return this.assurancePresent
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a compiled route IS, and the registration-time decision that produces it.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not in the server kernel
|
|
5
|
+
*
|
|
6
|
+
* `compileExecutionPlan` is BOOT work: it runs from `register`/`registerBatch`, both gated by
|
|
7
|
+
* `assertConfigurable`, so nothing here is reachable once the server is serving. Moving it costs the
|
|
8
|
+
* request path nothing - per-request code still calls the same frozen `run` closure it always did.
|
|
9
|
+
*
|
|
10
|
+
* ## Why `internal/`
|
|
11
|
+
*
|
|
12
|
+
* These types are the engine's vocabulary, not the package's API. They lived in `server.ts`, which IS
|
|
13
|
+
* published as `./server`, so every attempt to split the kernel had the same choice: export the engine's
|
|
14
|
+
* internals as public types, or not split. `internal/` is not a published subpath, so this moves the
|
|
15
|
+
* vocabulary somewhere it can be shared without becoming API. That is the step that unblocks the rest.
|
|
16
|
+
*
|
|
17
|
+
* The `Server` import is type-only and erased, so there is no runtime cycle back into the kernel.
|
|
18
|
+
*/
|
|
19
|
+
import type { RequestBudget } from "../budget.ts"
|
|
20
|
+
import type { StandardSchemaV1 } from "../schema/standard.ts"
|
|
21
|
+
import type { Platform, RouteSchema } from "../server/context.ts"
|
|
22
|
+
import type { ResolvedIdempotency } from "../server/idempotency-lane.ts"
|
|
23
|
+
import type { ResolvedEffectLedger } from "../server/ledger-lane.ts"
|
|
24
|
+
import type { Registry } from "../server/registry.ts"
|
|
25
|
+
import type { ResponseContractRuntime } from "../server/response-contract-lane.ts"
|
|
26
|
+
import type { HandlerResult } from "../server/runtime-core.ts"
|
|
27
|
+
import type { CtxSet, MaybePromise, RawContext, RequestSource, Server } from "../server/server.ts"
|
|
28
|
+
|
|
29
|
+
export type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
|
|
30
|
+
|
|
31
|
+
/** A `derive` computes per-request context extensions; stored path-erased. */
|
|
32
|
+
export type RawDerive = (ctx: RawContext) => MaybePromise<object>
|
|
33
|
+
export type RawBeforeHandle = (ctx: RawContext) => MaybePromise<unknown>
|
|
34
|
+
export type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
35
|
+
export type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
36
|
+
export type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
37
|
+
|
|
38
|
+
export type RouteExecutionRunner = <T, R extends Registry, Ctx>(
|
|
39
|
+
runtime: Server<R, Ctx>,
|
|
40
|
+
entry: RouteEntry,
|
|
41
|
+
source: RequestSource,
|
|
42
|
+
params: Record<string, string>,
|
|
43
|
+
search: string | undefined,
|
|
44
|
+
signal: AbortSignal,
|
|
45
|
+
budget: RequestBudget,
|
|
46
|
+
platform: Platform | undefined,
|
|
47
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
48
|
+
wrapResponse: (response: Response) => T,
|
|
49
|
+
) => MaybePromise<T>
|
|
50
|
+
|
|
51
|
+
export type ContextRouteRunner = <T, R extends Registry, Ctx>(
|
|
52
|
+
runtime: Server<R, Ctx>,
|
|
53
|
+
entry: RouteEntry,
|
|
54
|
+
source: RequestSource,
|
|
55
|
+
ctx: RawContext,
|
|
56
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
57
|
+
wrapResponse: (response: Response) => T,
|
|
58
|
+
) => MaybePromise<T>
|
|
59
|
+
|
|
60
|
+
/** Registration-compiled route behavior. Every adapter invokes the same runner; the optional fused
|
|
61
|
+
* renderer is only a response-format specialization of that same selected route semantics. */
|
|
62
|
+
export interface RouteExecutionPlan {
|
|
63
|
+
readonly run: RouteExecutionRunner
|
|
64
|
+
readonly fusedWeb: FusedWebRunner | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RouteEntry {
|
|
68
|
+
readonly handler: InternalHandler
|
|
69
|
+
readonly schema: RouteSchema | undefined
|
|
70
|
+
/** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
|
|
71
|
+
readonly idempotent: ResolvedIdempotency | undefined
|
|
72
|
+
/** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
|
|
73
|
+
readonly ledgered: ResolvedEffectLedger | undefined
|
|
74
|
+
/** The installed response-contract runtime paired with this route's declared schema, resolved once
|
|
75
|
+
* at registration; `undefined` = not checked (the default, and the only state in which the route can
|
|
76
|
+
* still take the fused/native lanes). */
|
|
77
|
+
readonly responseContract:
|
|
78
|
+
| { readonly runtime: ResponseContractRuntime; readonly schema: StandardSchemaV1 }
|
|
79
|
+
| undefined
|
|
80
|
+
/** Per-request context extensions captured at registration (order-scoped). */
|
|
81
|
+
readonly derives: ReadonlyArray<RawDerive>
|
|
82
|
+
/** Static context extensions captured at registration. */
|
|
83
|
+
readonly decorations: Record<PropertyKey, unknown>
|
|
84
|
+
/** Whether {@link decorations} has any keys - precomputed so the hot path skips a no-op
|
|
85
|
+
* `Object.assign` on the (common) no-decoration route. */
|
|
86
|
+
readonly hasDecorations: boolean
|
|
87
|
+
/** Lifecycle hooks captured at registration (order-scoped). */
|
|
88
|
+
readonly beforeHandle: ReadonlyArray<RawBeforeHandle>
|
|
89
|
+
readonly afterHandle: ReadonlyArray<RawAfterHandle>
|
|
90
|
+
readonly onError: ReadonlyArray<RawErrorHandler>
|
|
91
|
+
/** Wraps the matched route lifecycle. Empty for the common no-around path. */
|
|
92
|
+
readonly around: ReadonlyArray<RawAround>
|
|
93
|
+
/** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
|
|
94
|
+
readonly execution: RouteExecutionPlan
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
|
|
98
|
+
export type FusedWebRunner = (
|
|
99
|
+
source: RequestSource,
|
|
100
|
+
params: Record<string, string>,
|
|
101
|
+
search: string | undefined,
|
|
102
|
+
signal: AbortSignal,
|
|
103
|
+
budget: RequestBudget,
|
|
104
|
+
platform: Platform | undefined,
|
|
105
|
+
nativeContext: boolean,
|
|
106
|
+
) => MaybePromise<Response>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The same-origin test every browser-facing endpoint in Nifra uses.
|
|
3
|
+
*
|
|
4
|
+
* One owner, because there are two of these seams - the WebSocket handshake's CSWSH default and the
|
|
5
|
+
* server-function mount - and they answered differently for the same request. A browser that could
|
|
6
|
+
* open a socket was told its POST was cross-origin.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True when `origin` is the request's own origin, as observed from inside a possibly-proxied server.
|
|
11
|
+
*
|
|
12
|
+
* The host must match exactly. The scheme must be equal or STRONGER on the Origin side, never weaker:
|
|
13
|
+
*
|
|
14
|
+
* - `https://app` page → `http://app` request URL: accepted. This is what every TLS-terminating
|
|
15
|
+
* proxy looks like from the origin server - Cloudflare, a tunnel, an ingress. The socket is plain
|
|
16
|
+
* HTTP, so `request.url` says `http:` while the browser correctly reports an `https:` page.
|
|
17
|
+
* - `http://app` page → `https://app` request URL: rejected. Nifra terminated TLS itself here, so a
|
|
18
|
+
* plaintext page on the same host is the downgrade an attacker would need and buys nothing real.
|
|
19
|
+
*
|
|
20
|
+
* Comparing full origins instead is not the stricter option, it is an outage: measured, it rejects
|
|
21
|
+
* every server-function POST behind a terminating proxy, while the cross-origin attacker it aims at
|
|
22
|
+
* was already rejected by the host comparison. Nifra deliberately does not read `X-Forwarded-Proto` -
|
|
23
|
+
* a forwarded header is attacker-controlled unless something upstream is proven to overwrite it - so
|
|
24
|
+
* the scheme in `request.url` is the socket's, not the browser's, and this asymmetry is how the two
|
|
25
|
+
* are reconciled without trusting a header.
|
|
26
|
+
*
|
|
27
|
+
* An unparseable Origin is not same-origin.
|
|
28
|
+
*/
|
|
29
|
+
export function isSameOriginRequest(origin: string, request: Request): boolean {
|
|
30
|
+
try {
|
|
31
|
+
const from = new URL(origin)
|
|
32
|
+
const own = new URL(request.url)
|
|
33
|
+
if (from.host !== own.host) return false
|
|
34
|
+
// Written out rather than ranked through a lookup table: a table is a shipped object, and there
|
|
35
|
+
// are exactly two schemes to order. Any other scheme on either side falls through to `false`,
|
|
36
|
+
// which is what refuses `Origin: null`, `file:` and webview schemes.
|
|
37
|
+
if (from.protocol === "https:") return own.protocol === "https:" || own.protocol === "http:"
|
|
38
|
+
if (from.protocol === "http:") return own.protocol === "http:"
|
|
39
|
+
return false
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/internal/secret.ts
CHANGED
|
@@ -7,7 +7,7 @@ const MIN_SECRET_BYTES = 32
|
|
|
7
7
|
const SECRET_ENCODER = new TextEncoder()
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Boot-time guard: reject an HMAC secret under 256 bits, loudly, at construction
|
|
10
|
+
* Boot-time guard: reject an HMAC secret under 256 bits, loudly, at construction - never at the
|
|
11
11
|
* first request. UTF-8 byte length (not char count), so a multibyte secret is measured honestly.
|
|
12
12
|
* The single source of truth for the framework's secret-strength floor; every HMAC entry point
|
|
13
13
|
* (signed cookies, `@nifrajs/auth` sessions, webhook verification, signed upload/image URLs) routes
|
package/src/ledger.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Every owned effect seam (a repository write, an external call, an email send) records one entry via
|
|
5
5
|
* the `useCapability` beacon. The entry type is deliberately **token-only**: capability ids, phase,
|
|
6
6
|
* an adapter/resource token, dimensionless cost counters, an optional keyed digest, and an error code.
|
|
7
|
-
* There is no free-form payload field
|
|
7
|
+
* There is no free-form payload field - request bodies, rows, and values cannot enter the ledger, so
|
|
8
8
|
* redaction holds by construction rather than by filtering. The sealed ledger carries the **route
|
|
9
9
|
* pattern** (`/users/:id`), never the concrete URL, so path parameters cannot leak either.
|
|
10
10
|
*
|
|
@@ -29,7 +29,7 @@ export type EffectCost = Readonly<Record<string, number>>
|
|
|
29
29
|
|
|
30
30
|
/** Token-only caller metadata shared by an effect intent and outcome. */
|
|
31
31
|
export interface EffectMetadata {
|
|
32
|
-
/** Adapter/resource token (`repo:orders`, `provider:payments`). A token
|
|
32
|
+
/** Adapter/resource token (`repo:orders`, `provider:payments`). A token - never a value or a row. */
|
|
33
33
|
readonly target?: string
|
|
34
34
|
/** Dimensionless counters; see {@link EffectCost}. At most {@link MAX_COST_AXES} axes. */
|
|
35
35
|
readonly cost?: EffectCost
|
|
@@ -45,7 +45,7 @@ export interface EffectEntryInput extends EffectMetadata {
|
|
|
45
45
|
readonly capability: string
|
|
46
46
|
/** Default `"intent"`. Record `committed`/`failed`/`compensated` only after the outcome is known. */
|
|
47
47
|
readonly phase?: EffectPhase
|
|
48
|
-
/** Outcome error as a bounded token code
|
|
48
|
+
/** Outcome error as a bounded token code - never a message, never a stack. */
|
|
49
49
|
readonly error?: { readonly code: string }
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -75,10 +75,10 @@ export interface EffectChain {
|
|
|
75
75
|
/** The immutable result of sealing a request's ledger. Token-only; safe to hand to any sink. */
|
|
76
76
|
export interface SealedEffectLedger {
|
|
77
77
|
readonly method: string
|
|
78
|
-
/** The registered route pattern (`/users/:id`)
|
|
78
|
+
/** The registered route pattern (`/users/:id`) - never the concrete request URL. */
|
|
79
79
|
readonly path: string
|
|
80
80
|
readonly entries: readonly EffectEntry[]
|
|
81
|
-
/** The route's declared capability tokens
|
|
81
|
+
/** The route's declared capability tokens - the runtime-enforcement view: recorded ⊆ declared is
|
|
82
82
|
* guaranteed by the beacon, and `declared` minus the recorded ids is the unused declaration set. */
|
|
83
83
|
readonly declared: readonly string[]
|
|
84
84
|
/** Present when the ledger was created with `chain: true`. */
|
|
@@ -99,7 +99,7 @@ export class EffectLedgerOverflowError extends Error {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/** Thrown by `append` after `seal()`
|
|
102
|
+
/** Thrown by `append` after `seal()` - e.g. an effect attempted while streaming a response body. */
|
|
103
103
|
export class EffectLedgerSealedError extends Error {
|
|
104
104
|
constructor() {
|
|
105
105
|
super("effect ledger is sealed: effects cannot be recorded after the response settles")
|
|
@@ -115,14 +115,14 @@ export interface RequestLedger {
|
|
|
115
115
|
entries(): readonly EffectEntry[]
|
|
116
116
|
readonly size: number
|
|
117
117
|
readonly sealed: boolean
|
|
118
|
-
/** Finalize the ledger (computing the chain when enabled). Idempotent
|
|
118
|
+
/** Finalize the ledger (computing the chain when enabled). Idempotent - always the same result. */
|
|
119
119
|
seal(): Promise<SealedEffectLedger>
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export interface CreateRequestLedgerOptions {
|
|
123
123
|
/** HTTP method of the matched route. */
|
|
124
124
|
readonly method: string
|
|
125
|
-
/** The registered route pattern
|
|
125
|
+
/** The registered route pattern - callers must never pass the concrete request URL. */
|
|
126
126
|
readonly path: string
|
|
127
127
|
/** The route's declared capability tokens, surfaced verbatim on the sealed ledger. Default `[]`. */
|
|
128
128
|
readonly declared?: readonly string[]
|
|
@@ -255,7 +255,7 @@ class BoundedRequestLedger implements RequestLedger {
|
|
|
255
255
|
if (input.error !== undefined && !ERROR_CODE.test(input.error.code)) {
|
|
256
256
|
throw new TypeError("effect ledger: error code must be a bounded lowercase token")
|
|
257
257
|
}
|
|
258
|
-
// Only the known token fields are copied
|
|
258
|
+
// Only the known token fields are copied - a stray `payload`-like property never survives append.
|
|
259
259
|
const at = this.clock()
|
|
260
260
|
if (!Number.isFinite(at) || at < 0) {
|
|
261
261
|
throw new TypeError("effect ledger: clock must return a finite non-negative number")
|
|
@@ -315,7 +315,7 @@ export function createRequestLedger(options: CreateRequestLedgerOptions): Reques
|
|
|
315
315
|
return new BoundedRequestLedger(options)
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
/** Canonical, key-ordered serialization of one entry
|
|
318
|
+
/** Canonical, key-ordered serialization of one entry - the hash-chain input. */
|
|
319
319
|
function canonicalEntry(entry: EffectEntry): string {
|
|
320
320
|
const cost =
|
|
321
321
|
entry.cost === undefined
|
|
@@ -382,7 +382,7 @@ export function createMemoryLedgerSink(options: MemoryLedgerSinkOptions = {}): M
|
|
|
382
382
|
/** Minimum digest key material. A short key would make the keyed digest brute-forceable. */
|
|
383
383
|
export const MIN_DIGEST_KEY_BYTES = 16
|
|
384
384
|
|
|
385
|
-
/** Fresh random digest key (32 bytes). Per-process by default
|
|
385
|
+
/** Fresh random digest key (32 bytes). Per-process by default - persist one externally to correlate across restarts. */
|
|
386
386
|
export function randomEffectDigestKey(): Uint8Array {
|
|
387
387
|
const bytes = new Uint8Array(32)
|
|
388
388
|
crypto.getRandomValues(bytes)
|
package/src/reflection.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface SchemaReflection {
|
|
|
40
40
|
export interface ReflectedRouteSchema {
|
|
41
41
|
readonly body?: SchemaReflection
|
|
42
42
|
readonly query?: SchemaReflection
|
|
43
|
-
/** Path-params schema
|
|
43
|
+
/** Path-params schema - constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
|
|
44
44
|
readonly params?: SchemaReflection
|
|
45
45
|
readonly response?: SchemaReflection
|
|
46
46
|
readonly errors?: Readonly<Record<string, SchemaReflection>>
|
package/src/router/pattern.ts
CHANGED
|
@@ -71,17 +71,17 @@ function validParamName(name: string): boolean {
|
|
|
71
71
|
*
|
|
72
72
|
* The grammar is per-segment: a segment is wholly static or wholly a parameter, so everything after
|
|
73
73
|
* the colon is the name. `/v/:id.json` therefore asks for a parameter literally named `id.json`,
|
|
74
|
-
* which is not what anyone means by it
|
|
74
|
+
* which is not what anyone means by it - but the bare "invalid parameter" that produces reads as a
|
|
75
75
|
* typo rather than as a rule, leaving the author to guess whether the dot, the length, or the casing
|
|
76
76
|
* was the problem. Naming the actual limitation and showing the two ways out is the difference
|
|
77
77
|
* between a five-second fix and a trip to the source.
|
|
78
78
|
*/
|
|
79
79
|
function paramNameHint(name: string): string {
|
|
80
|
-
if (RESERVED_PARAM_NAMES.has(name)) return `
|
|
81
|
-
if (name.length === 0) return `
|
|
80
|
+
if (RESERVED_PARAM_NAMES.has(name)) return ` - "${name}" is reserved (prototype key)`
|
|
81
|
+
if (name.length === 0) return ` - ":" needs a name after it`
|
|
82
82
|
// No "a segment is wholly static or wholly a parameter" case here any more: mixed segments made
|
|
83
83
|
// that message obsolete. `:id.json` was the shape it explained, and `:id.json` now compiles.
|
|
84
|
-
return `
|
|
84
|
+
return ` - a name must match ${PARAM_NAME.source}`
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function escapeRegex(value: string): string {
|
package/src/router/router.ts
CHANGED
|
@@ -35,7 +35,7 @@ export type RouterMatch<T> =
|
|
|
35
35
|
/**
|
|
36
36
|
* Payload at a terminal node: per-method handlers plus the ordered param names
|
|
37
37
|
* for this path shape. Bundling them means "a node that has handlers always has
|
|
38
|
-
* param names" is enforced by the type, not by a runtime invariant
|
|
38
|
+
* param names" is enforced by the type, not by a runtime invariant - which
|
|
39
39
|
* removes the otherwise-dead defensive branches in `find`.
|
|
40
40
|
*/
|
|
41
41
|
interface Terminal<T> {
|
|
@@ -85,7 +85,7 @@ interface RouteNode<T> {
|
|
|
85
85
|
* no allocation, and one `undefined` check on the match path.
|
|
86
86
|
*/
|
|
87
87
|
mixedChildren: MixedChild<T>[] | undefined
|
|
88
|
-
/** Catch-all `*` child, if any
|
|
88
|
+
/** Catch-all `*` child, if any - always terminal. */
|
|
89
89
|
wildcardChild: RouteNode<T> | undefined
|
|
90
90
|
/** Set once this node terminates one or more routes. */
|
|
91
91
|
terminal: Terminal<T> | undefined
|
|
@@ -129,7 +129,7 @@ function sameNames(a: readonly string[], b: readonly string[]): boolean {
|
|
|
129
129
|
* instead of pre-splitting it into an array. `start` is the index of the first
|
|
130
130
|
* char of the current segment; the segment runs to the next `/` (or end). Tries
|
|
131
131
|
* static, then param, then wildcard; if a static branch dead-ends, param/wildcard
|
|
132
|
-
* are still attempted
|
|
132
|
+
* are still attempted - so `/a/b/c` and `/a/:x/d` coexist and `/a/b/d` resolves
|
|
133
133
|
* to the param route. Fills `paramValues` along the successful path; abandoned
|
|
134
134
|
* branches are popped. Avoiding the split keeps `find` allocation-light: only the
|
|
135
135
|
* segment substrings needed for map lookups (and matched param values) are cut.
|
|
@@ -179,9 +179,9 @@ function matchNode<T>(
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const paramChild = node.paramChild
|
|
182
|
-
// An empty segment (`/users//posts`) never matches a `:param`
|
|
182
|
+
// An empty segment (`/users//posts`) never matches a `:param` - matching would hand the handler
|
|
183
183
|
// `id: ""` and downstream code a `WHERE id = ''` class of bug. Peers (Hono, Elysia, Next) 404
|
|
184
|
-
// here too; a static "" child can still match (none is ever registered
|
|
184
|
+
// here too; a static "" child can still match (none is ever registered - INVALID_PATH guards).
|
|
185
185
|
if (paramChild !== undefined && seg.length > 0) {
|
|
186
186
|
paramValues.push(seg)
|
|
187
187
|
if (isLast) {
|
package/src/schema/standard.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Standard Schema v1 interface (https://standardschema.dev), vendored as
|
|
3
|
-
* types + a tiny runtime helper so any compliant validator
|
|
4
|
-
* arktype, …
|
|
3
|
+
* types + a tiny runtime helper so any compliant validator - zod, valibot,
|
|
4
|
+
* arktype, … - validates requests without coupling the framework to one lib.
|
|
5
5
|
* The spec is MIT-licensed and explicitly designed to be copied.
|
|
6
6
|
*/
|
|
7
7
|
|
package/src/seo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `sitemap.xml` + `robots.txt` builders
|
|
2
|
+
* `sitemap.xml` + `robots.txt` builders - pure, edge-safe string generators (no I/O, no runtime deps).
|
|
3
3
|
* Wire them to a route on any runtime:
|
|
4
4
|
*
|
|
5
5
|
* app.get("/sitemap.xml", () =>
|
|
@@ -21,7 +21,7 @@ const XML_ESCAPES: Readonly<Record<string, string>> = {
|
|
|
21
21
|
"'": "'",
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Escape the five XML metacharacters
|
|
24
|
+
/** Escape the five XML metacharacters - `<loc>` and `<lastmod>` carry app- or DB-derived strings. */
|
|
25
25
|
function escapeXml(value: string): string {
|
|
26
26
|
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] as string)
|
|
27
27
|
}
|
|
@@ -48,10 +48,10 @@ const CHANGE_FREQS: ReadonlySet<string> = new Set([
|
|
|
48
48
|
export interface SitemapEntry {
|
|
49
49
|
/** An absolute URL, or a path that `hostname` will make absolute. */
|
|
50
50
|
readonly url: string
|
|
51
|
-
/** Last-modified
|
|
51
|
+
/** Last-modified - a `Date` (serialized as ISO 8601) or a pre-formatted W3C-datetime string. */
|
|
52
52
|
readonly lastmod?: string | Date
|
|
53
53
|
readonly changefreq?: SitemapChangeFreq
|
|
54
|
-
/** Crawl priority, `0.0
|
|
54
|
+
/** Crawl priority, `0.0`-`1.0`. */
|
|
55
55
|
readonly priority?: number
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -67,7 +67,7 @@ const SITEMAP_MAX_URLS = 50_000
|
|
|
67
67
|
export function sitemap(entries: readonly SitemapEntry[], options: SitemapOptions = {}): string {
|
|
68
68
|
if (entries.length > SITEMAP_MAX_URLS) {
|
|
69
69
|
throw new RangeError(
|
|
70
|
-
`sitemap: ${entries.length} entries exceeds the ${SITEMAP_MAX_URLS}-URL per-file limit
|
|
70
|
+
`sitemap: ${entries.length} entries exceeds the ${SITEMAP_MAX_URLS}-URL per-file limit - split into a sitemap index`,
|
|
71
71
|
)
|
|
72
72
|
}
|
|
73
73
|
const base = options.hostname?.replace(/\/+$/, "")
|
|
@@ -96,7 +96,7 @@ export function sitemap(entries: readonly SitemapEntry[], options: SitemapOption
|
|
|
96
96
|
|
|
97
97
|
function resolveLoc(url: string, base: string | undefined): string {
|
|
98
98
|
if (/^https?:\/\//i.test(url)) return url // already absolute
|
|
99
|
-
if (base === undefined) return url // relative
|
|
99
|
+
if (base === undefined) return url // relative - caller's choice (sitemaps prefer absolute)
|
|
100
100
|
return `${base}${url.startsWith("/") ? "" : "/"}${url}`
|
|
101
101
|
}
|
|
102
102
|
|
package/src/server/body.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded request-body reading
|
|
2
|
+
* Bounded request-body reading - the single source of truth for nifra's body-size cap. A lying or
|
|
3
3
|
* absent `Content-Length` can't force us to buffer an oversized payload: a declared length over the
|
|
4
4
|
* cap is rejected *before* buffering, and a chunked / length-less body is aborted mid-stream once the
|
|
5
5
|
* running byte count exceeds the cap. Shared by the server's schema path, `c.boundedBody`, and
|
|
@@ -26,7 +26,7 @@ export function parseContentLength(value: string): number | undefined {
|
|
|
26
26
|
|
|
27
27
|
/** The shared streaming byte-cap loop: read until done, or cancel + 413 once over `maxBytes`.
|
|
28
28
|
* A single-chunk body (the common case for small chunked payloads) returns the runtime's own
|
|
29
|
-
* chunk directly
|
|
29
|
+
* chunk directly - no chunk array, no copy-merge. */
|
|
30
30
|
export async function drainCapped(
|
|
31
31
|
body: ReadableStream<Uint8Array>,
|
|
32
32
|
maxBytes: number,
|