@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,2384 @@
|
|
|
1
|
+
# @nifrajs/core
|
|
2
|
+
|
|
3
|
+
## 5.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1f3ff32: A closed request-header schema (`headers: t.object({...})`) validates only the header names it declares, each read case-insensitively and keyed as declared. Every client sends headers a route never declares (`host`, `user-agent`, `accept`), so such a schema used to answer every over-the-wire request with a 422; it now matches the OpenAPI document, which lists one `in: header` parameter per declared name. Open schemas, and schemas that do not expose their JSON Schema, still receive every header. `declaredHeaderNames` is exported from `@nifrajs/core/reflection`.
|
|
8
|
+
|
|
9
|
+
`t.headers({...})` is the open header-slot schema: every header arrives with its name lower-cased, and declared `t.integer()` / `t.number()` / `t.boolean()` fields are coerced from text. It refuses an upper-case name.
|
|
10
|
+
|
|
11
|
+
`runAdversarialContract` sends the headers real clients send (`accept`, `accept-encoding`, `accept-language`, `user-agent`), validates a header witness the way the route's slot does, and names the refused fields when a witness is rejected or a valid witness is answered with a validation 422.
|
|
12
|
+
- cdff19d: `responseContract(mode, { extra: "strip" })` projects a union whose object branches all require one key holding literals no two branches share (`ok: true` / `ok: false`, `kind: "a"` / `kind: "b"`): each value is projected onto the branch its tag names. A union with one object branch and one array branch projects both. Unions where more than one branch could accept a value are still left to the validator.
|
|
13
|
+
|
|
14
|
+
`responseContract("enforce", { logStripped: true })` logs the names of the fields that never reached the wire, whether `extra: "strip"` or a stripping validator dropped them: nested fields as `user.email`, array items as `items[].cost`, once per route for each distinct set of names. Values are never logged.
|
|
15
|
+
- 000b4cb: `nifra check` reports NF-C043, a warning, for a `merge()` whose server authenticates none of its routes while the server it is merged into authenticates its own with `authenticate()` or an auth plugin that records `authenticated` for the routes after it. A merged route keeps the hooks of the server that declared it, so that authentication never runs for it. A merged server that authenticates any route of its own is not reported. `// nifra-expect NF-C043: <reason>` on the merge line accepts a module that is public on purpose. The rule reads the app `nifra.assurance.ts` loads.
|
|
16
|
+
|
|
17
|
+
`@nifrajs/core/reflection` exports `reflectMergeAuthGaps(app)`, the record behind the rule, collected by a development load.
|
|
18
|
+
- c5455fb: `responseContract(mode, { onReport })` calls a hook for every response the contract reports on, for a metric or an alert. The kinds are `violation` (enforce mode, a 500 went out instead), `warn` (warn mode, sent anyway) and `stripped` (enforce mode, sent without some fields). Each report carries the route's method and its registered pattern (`/users/:id`), the field names where they apply, and the message the log line carries. Values are never passed. A hook that throws, rejects or is not a function is logged once and changes no response. Logging is unchanged. An app that installs the plugin carries about 0.3 KB more gzipped; the kernel carries nothing.
|
|
19
|
+
- 6082ba5: Capability provenance now also starts from the module that registered each route. While the CLI loads `nifra.assurance.ts`, a development load records the `file:line:col` of each route registration through every `group()` and `merge()`, and `reflectRouteSites()` from `@nifrajs/core/reflection` returns it. The walk adds that module to the ones the static text scan finds, so a route with a computed path, or on a server a helper builds, is covered without `provenance.routeModules`. Recording happens only with `NIFRA_ROUTE_SITES=1` outside production, and a production build carries none of it. The sites stay out of `reflectRoutes()`, so no absolute path reaches a written artifact.
|
|
20
|
+
- fd58beb: The unused order-scoped hook check (`unusedScopedHooks`) now reports three more silent gaps, in development and test only:
|
|
21
|
+
|
|
22
|
+
- A hook followed only by `merge()` calls. A merged route keeps the hooks of the server that declared it, so `app.use(session).merge(users)` ran `session` on none of the merged routes and was never reported, because the merged routes counted as routes declared after the hook. The report marks such a hook "only merge()d routes follow it".
|
|
23
|
+
- `authenticate()`, which the check did not record at all.
|
|
24
|
+
- `responseContract()` or `effectLedger()` installed after routes it would have covered: a route declaring a response, or capabilities, registered or merged before the install. Both are decided per route at registration, so those routes ran without them. The report names up to five of them.
|
|
25
|
+
|
|
26
|
+
With `unusedScopedHooks: "error"` each of these now throws `UNUSED_SCOPED_HOOKS` at the first request or `listen()`, as a dead `derive()` already did. A production build drops the check.
|
|
27
|
+
- ed13e36: `nifra upgrade` carries a 5.1.0 recipe, so an upgrade across 5.1.0 prints what can change for an app: merged routes recompiled under the parent's response contract and effect ledger, cross-site form posts refused with `403`, and `clientIp.trustedHops` with no socket peer. `nifra upgrade --list` prints the notes of every release after the version the project declares, and `--json` carries `installed` and every version's `notes`.
|
|
28
|
+
|
|
29
|
+
`reflectHooks` (and so `nifra routes --hooks`, and the hook-scope diff `nifra upgrade` saves) lists the runtimes a route resolved at registration beside its hooks: `responseContract` named by its mode, `effectLedger` and `idempotency`, each with the site `route`. A merged route that gains a parent's contract on upgrade now shows up in `nifra routes --hooks --since`.
|
|
30
|
+
|
|
31
|
+
The release gate fails a minor or major release that has no upgrade recipe of its own.
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- 9498cf8: The package ships its `CHANGELOG.md`, so the installed package has the notes for every version, as the GitHub release does. Before, the tarball held only the code, `README.md`, `LICENSE` and `package.json`.
|
|
36
|
+
- 4bb8e52: The `mountFetch()` reference says what the WebSockets guide already did: a mounted fetch handler carries HTTP only, so a WebSocket upgrade under its prefix reaches it as an ordinary request. Serve those sockets with `app.ws()` routes or a mounted child app.
|
|
37
|
+
- 5edd0ba: `nifra upgrade` stops before writing anything when a `link:` or `file:` dependency outside the project installs its own `@nifrajs/*` at a version other than the target. Its imports resolve from its own `node_modules`, so after the upgrade a `server()` it builds could no longer be merged into the app, and that only failed at the first `merge()`. The refusal names each linked repository, its versions and the commands that upgrade it; `--allow-linked-skew` upgrades the project anyway. A dry run prints the plan with the same blocker and exits non-zero. A linked copy that resolves back into the project follows its install and does not count.
|
|
38
|
+
|
|
39
|
+
`merge()` given a server from another loaded copy of `@nifrajs/core` names every copy in its error, this one first, with the version of each copy that has built a `server()`, and says how to collapse them. With one copy loaded the message is unchanged. Copies older than this release are named by path alone, and a production build keeps the short message.
|
|
40
|
+
- ea25a61: The single-copy registrar reports the packages a linked tree skews together in one warning: both `node_modules` folders, both versions, the package list and the fix. A linked repository on another release skews every declared package at once, and each process (every CLI command included) printed one near-identical block per package. A single skewed package keeps its own report, and strict mode throws the combined text.
|
|
41
|
+
- 4ba2cf2: An `authenticate()` stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, still answers 503 `{ ok: false, error: "auth_unavailable" }`, and its cause now goes where a handler's error goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger as `unhandled request error`. Before, the cause was dropped, so a database outage during authentication left only a 503 in the access log. A malformed decision is reported as a `TypeError` naming the stage. A stage that throws a `Response` or `status(...)`, such as `requirePrincipal`'s 401 or redirect, now has it sent as thrown, as from a handler, instead of a 503. `authed()` from `@nifrajs/better-auth` no longer catches provider failures itself, so they are reported the same way.
|
|
42
|
+
|
|
43
|
+
## 5.1.0
|
|
44
|
+
|
|
45
|
+
### Minor Changes
|
|
46
|
+
|
|
47
|
+
- b276c67: `responseContract("enforce", { extra: "strip" })` drops the fields a `t` schema does not declare before validating, instead of failing the response: a schema exposing its JSON Schema as `jsonSchema` lists its declared keys, so the payload is projected onto them, nested objects, arrays and records included; a closed record (`t.record(v, { additionalProperties: false })`) keeps every key its pattern admits. A union is projected only when exactly one branch can hold an object. The default, `extra: "reject"`, is unchanged: a closed `t.object` still turns an extra field into a 500 whose log names the field. In `"warn"` mode the option makes the log name the fields enforcement would drop. The security guide's example claimed the stripped output for plain `"enforce"`; it now shows all three outcomes.
|
|
48
|
+
|
|
49
|
+
`merge()` now recompiles a merged route under the response contract and effect ledger the parent installed before the merge, when the route declares a response (or capabilities) and its own server installed neither. `group()` routes already inherited both, so the same `.use()` used to enforce a grouped route and let its merged twin leak. A route that resolved its own contract or ledger keeps it. This can change a response: a merged route whose payload carries fields its closed `t.object` does not declare now answers 500 under a parent's `responseContract("enforce")`, as a grouped one already did. The `merge()` JSDoc and the plugins guide list what a parent's `.use()` covers for each.
|
|
50
|
+
|
|
51
|
+
Route assurance policies gain a `hasResponse` selector (routes declaring a success `response` schema) and `baseline` rules, checked on every route they match in addition to the first-match rule that owns it. "Every route that declares a response enforces it" is one baseline entry: `{ name: "response-contract", match: { hasResponse: true }, require: [NIFRA_ASSURANCE.RESPONSE_CONTRACT] }`. A baseline rule does not classify a route, and its name shares the `rules` namespace.
|
|
52
|
+
|
|
53
|
+
`RouteCapabilityEvidence` carries an optional `uncoveredReason`, appended to the `provenance-uncovered` finding.
|
|
54
|
+
- 0b68be9: A body-schema route now refuses a urlencoded, multipart or `text/plain` body whose `Origin` names another site, with `403 { ok: false, error: "forbidden_origin" }`, before the body is read. A body-schema route reads urlencoded as well as JSON, and a form on any site can post urlencoded with the visitor's cookies and no preflight, so a route written for JSON took cross-site form posts. The JSON agent, A2A and AG-UI mounts had the same opening. Same-origin posts, requests without an `Origin` such as server-to-server calls, and `Origin: null` with `Sec-Fetch-Site: same-origin` (a form under `Referrer-Policy: no-referrer`) still pass. JSON bodies are unchanged: a browser preflights them, so CORS decides.
|
|
55
|
+
|
|
56
|
+
A frontend on another origin that posts forms or `FormData` to the API, or an identity provider's `form_post` callback, now needs its origin in the new `formOrigins` server option: an array of exact origins, or a function that receives the `Origin`. `@nifrajs/edge` takes the same option. `isSameOriginForm` is exported next to `isSameOriginRequest`, which now accepts anything with a `url`.
|
|
57
|
+
|
|
58
|
+
### Patch Changes
|
|
59
|
+
|
|
60
|
+
- 2a0a5d3: The single-copy resolver and the build's duplicate-copy scan treat a `file:` dependency Bun installs as a folder of linked files the way they treat a linked package. Before, only a symlinked package directory counted, so the second React that such a package's own `node_modules` held went unredirected and unreported by `nifra check`.
|
|
61
|
+
- 72e1b4b: The single-copy resolver lets Bun resolve a declared package itself when Bun's own `node_modules` lookup from the importing file already reaches the app's copy. Before, it returned a path for every declared import, and Bun drops a package's `sideEffects: false` pruning for a path a plugin returns. A browser import of one helper from a package entry that also re-exports server modules then pulled in all of them. With `"singleCopy": ["@nifrajs/*"]` (or `true`), `import { canonical } from "@nifrajs/web"` in browser code made `nifra dev` refuse `node:fs`, `node:path` and `node:stream`. Imports that would reach another copy are still redirected to the app's.
|
|
62
|
+
- 0fb2d25: `clientIp: { trustedHops: n }` reads the caller from the same `X-Forwarded-For` entry whether or not the adapter observed a socket peer. With no peer (Workers and edge handlers, Bun on a unix socket, a Node socket already closed) it took the entry one place to the left, which the client itself wrote, so `c.clientIp` and everything keyed on it could be forged. With no peer, `trustedHops: 0` is now `undefined` rather than the rightmost header entry.
|
|
63
|
+
- ed6bb22: `redirect()` and the auth guards' `redirectTo` refuse a path whose dot segments resolve it to `//host`, such as `/.//evil.example/x` or `/a/..//evil.example/x` (`isSameOriginPath` is false for them). A redirect answering a client navigation or a JS-enhanced form submit no longer hands the router such a path either: a same-origin target whose path starts with `//` rides `x-nifra-redirect` as an absolute URL, so the browser stays on this origin instead of reading the path as a protocol-relative URL to another host.
|
|
64
|
+
- 36f1db9: Signed cookies, and the `@nifrajs/auth` sessions built on them, import each secret's HMAC key once instead of on every sign and verify. A verify measured about 16% faster on Bun (one run). Up to 64 secrets are kept, and the list starts over past that.
|
|
65
|
+
- 2e1c425: The `protoPoisoning` option's documentation now matches what it does: every guarded JSON body pays one pass over its parsed object nodes, and `"ignore"` skips it. It used to promise that clean payloads paid only a text pre-scan, which the guard has not done since it began walking the parsed value.
|
|
66
|
+
- ef82971: Decoding transport data now refuses a `bigint` longer than 4,096 decimal digits with `WireDecodeError`. Parsing a long digit string costs more than linear time: a 900 KB token took about 60 ms, and the 16 MiB byte budget allowed far longer. It is reachable from any request body the transport codec decodes. `WireDecodeLimits.maxBigIntDigits` raises or lowers the limit.
|
|
67
|
+
- 0b13a46: On Bun, a mounted app's `wsMaxPayloadBytes` now holds for its own sockets. Before, `listen()` applied only the parent's cap: a child that declared 16 bytes still received a 5000-byte message, and a child that declared more than the parent had its messages cut off at the parent's cap. The listener's frame limit is now the largest cap among the parent and its mounted apps, and a socket whose app declares less closes with `1009` when a message exceeds it.
|
|
68
|
+
- 5ab361c: Checking `wsMaxPayloadBytes` on Deno, and on runtimes wired through `attachWebSocket` (Cloudflare Workers), no longer UTF-8 encodes every text message to measure it: the size is counted in place, and a message whose length alone settles the answer is not scanned at all. Before, each capped text message was copied once just to be measured. The `@nifrajs/deno` docs now state the limit of that check: `Deno.upgradeWebSocket` takes no size limit, so Deno receives each message whole (up to its own 64 MiB on Deno 2.9) before the cap closes the socket with `1009`.
|
|
69
|
+
- 7c8b345: A WebSocket route with `validateSend: true` now checks its outbound frames against `sendSchema` on Node and Deno too, as it already did on Bun: a frame the schema refuses is dropped before it reaches the client and reported to the route's `error()`. Before, `@nifrajs/node` and `@nifrajs/deno` sent every frame unchecked. The upgrade outcome a nifra app hands an adapter now carries a `sender` that adapters wiring their own sockets build `ws.send` with.
|
|
70
|
+
|
|
71
|
+
## 5.0.0
|
|
72
|
+
|
|
73
|
+
### Major Changes
|
|
74
|
+
|
|
75
|
+
- 0aed425: Required `basicAuth`, `bearer`, `apiKey` and `jwt` refuse in the `authenticate()` stage: before the body is read or validated and before any `derive` or `beforeHandle`. An unauthenticated request gets the 401 and its challenge, never a 413 or a 422, on every route shape, lane and WebSocket handshake. With valid credentials an invalid body is still a 422. `optional: true` never refuses and keeps validation first. A `verify` that throws in required mode is a 503. A rate limiter written as a `beforeHandle` behind these guards no longer sees refused requests; `rateLimit()` runs in `onRequest` and still counts them.
|
|
76
|
+
|
|
77
|
+
The `authenticate()` input adds `params`, the raw `search`, the parsed and frozen `query` (the parser `c.query` uses; a repeated key is an array) and the `request` (its body must not be read). The query is for short-lived, single-use tickets, such as one for a WebSocket handshake.
|
|
78
|
+
|
|
79
|
+
Project evidence and reflection keep a route's chosen `validationOrder`.
|
|
80
|
+
|
|
81
|
+
`nifra check` reports a route that claims runtime authentication, declares an input schema and authenticates in a hook that runs after validation (NF-S008, warning). It reads the backend the contract-lock check loads, so it runs when `contracts.lock.json` exists. Fix it with `authenticate()`, or with `validationOrder: "auth-before-validation"` on the route.
|
|
82
|
+
|
|
83
|
+
The dev feed (`nifra_logs`, `nifra_errors`, request traces and the persisted log) records a query's keys and never its values.
|
|
84
|
+
|
|
85
|
+
- a5963cb: An order-scoped hook (`derive`, `decorate`, `beforeHandle`, `afterHandle`, `around`, `aroundCapability`, `onError`) added to a `createWebApp` app after it returns now throws a `FrameworkError` with code `HOOK_AFTER_PAGES` outside production, naming the call site. Every page copies its hook chain when it is declared, so such a hook never ran on a page. 4.x accepted it without a word. Apply it in `createWebApp({ use })`, or inside `app.group(prefix, ...)` to scope it to the routes declared there. Global hooks (`onRequest`, `onResponse`), routes, groups and re-applying a named plugin that `use` already applied stay allowed.
|
|
86
|
+
- 4064f36: A `Response` a handler or hook returns or throws gets the request's `c.set.headers` it does not already carry, as well as its queued cookies, on every lane. A guard's `cache-control: no-store` now reaches a handler-built `Response`. The `Response`'s own headers win. `content-type`, `content-length`, `content-encoding`, `content-range` and `transfer-encoding` come from it alone, and `c.set.status` does not change its status. A `Response` that answers many requests is copied first, so one request's headers never reach another. A 101 upgrade and `Response.error()` take no headers. Mounted apps and `onRequest` responses are unchanged.
|
|
87
|
+
- 1290c6d: A WebSocket handshake runs the route chain an HTTP `GET` registered at the same point runs: the `authenticate()` stages, `derive`s and `beforeHandle`s added before `app.ws()`, then `upgrade()`. Auth middleware such as `basicAuth`, `bearer`, `apiKey`, `jwt` and `authed()` guards the sockets registered after it, and a guard refuses the handshake with the response it would send an HTTP request. `upgrade(c)` sees, typed, what the guards derived and the app's decorations. To keep a socket public, register it before the guard.
|
|
88
|
+
|
|
89
|
+
`upgrade()` may reject with `status(...)` as well as a `Response`. A thrown `Response` rejects the handshake with that response; any other throw goes to the route's `onError` hooks, then a flat 500. The guards share the upgrade timeout.
|
|
90
|
+
|
|
91
|
+
In development, a `derive`, `decorate`, `beforeHandle` or `onError` followed only by WebSocket routes is no longer reported as applying to no route.
|
|
92
|
+
|
|
93
|
+
### Minor Changes
|
|
94
|
+
|
|
95
|
+
- eaf030b: The server records each request an authentication stage accepts, and `isAuthenticatedRequest(req)` from `@nifrajs/core/server` answers for the request response hooks receive, on every runtime and after an `onRequest` hook replaces the request. `markAuthenticatedRequest(req)` records a request an integration verified outside a stage; the optional `bearer`, `apiKey`, `jwt` and `basicAuth` guards call it. `cache()` treats a response to such a request as private, so it is stored only when the response is explicitly `public` or carries `s-maxage`, whatever header carried the credential, including an `apiKey({ header })` outside `authenticatedHeaders`.
|
|
96
|
+
- 4199ff3: The adversarial contract laboratory witnesses and mutates declared `params` and `headers`. A `t` schema synthesizes them, the witness's `params` and `headers` override, and each is validated before any case runs, so a body or query case is rejected by the input it mutates rather than by a placeholder param or a missing header. An opaque params or headers schema without a witness is a `NO_WITNESS` gap; placeholders no longer stand in for it.
|
|
97
|
+
|
|
98
|
+
The laboratory never invents credentials: `authorization`, `proxy-authorization`, `cookie`, `x-api-key`, and names ending in `-key` or containing `token`, `secret` or `signature` come from the witness and `prepareRequest` only and are never mutated, and runtime-owned headers are never set. A schema that requires a credential the witness lacks is a `NO_WITNESS` gap naming it. Cookies are never synthesized: a route with a `cookies` schema needs `headers.cookie` in its witness, else a `cookies` gap. A params or header field typed as bare text is not a `NO_REJECTED_MUTATION` gap. `ContractTarget` adds `"headers"`, `"cookies"` and `"params"`.
|
|
99
|
+
|
|
100
|
+
`autoReflectJsonSchema` resolves zod from the project directory first, then from `@nifrajs/mock`. `explainAutoReflectJsonSchema` says why a zod schema stayed opaque (zod not resolvable, older than 4, or its converter threw), and a laboratory gap for such a schema carries the reason.
|
|
101
|
+
|
|
102
|
+
- 90d9ee6: `securityBaseline()` treats a route under a custom method, such as `PURGE` registered with `method()`, as a mutation. It used to match no rule, so an authenticated custom-method route was never asked to prove CSRF. Every method but GET, HEAD and OPTIONS is now held to the mutation rules; WebSocket routes are still not. Assurance rules gain an `exceptMethods` selector for this ("every method but these"), evidence declarations gain `exceptMethods` and may name custom methods in `methods`, and `@nifrajs/core/methods` exports `isRegistrableMethod`.
|
|
103
|
+
- d91fd44: `@nifrajs/edge` holds a handler's own reads of `c.request` to `maxBodyBytes` on a route with no body schema, as the full server does for `c.req`. A body past the cap, or a declared Content-Length over it, makes the read throw a `413` response; a malformed Content-Length throws a `400`. Before, such a route read the whole body whatever its size. The cap is built only when the handler first touches `c.request`. `@nifrajs/core/edge-kit` exports the helper, `capBodyBytes(request, maxBytes)`.
|
|
104
|
+
- 1a6616f: `reflectHooks(server)` from `@nifrajs/core/reflection` reports, for every route and for each prefix's unmatched paths, the hooks that run in order: their kind, the plugin or function name, and the site that registered them (`app`, `group <prefix>`, `merge #n`, `mount <path>`). Static `responseHeaders()` declarations appear with their header names. Origins are recorded only outside production builds; a production report sets `complete: false`.
|
|
105
|
+
|
|
106
|
+
`nifra routes --hooks [--json]` prints that report, and `--since <file>` prints only what runs differently from a saved one. `nifra upgrade --write` saves the report to `.nifra/hooks-before-upgrade.json` before it edits anything and prints the hook-scope diff (`--no-hooks` skips it). `nifra check --json` includes the report as `hooks` when `nifra.assurance.ts` names a `source`.
|
|
107
|
+
|
|
108
|
+
- 846e2f6: `invariants` in `nifra.assurance.ts` accepts the L4 runner's fixture options - `witnesses`, `prepareRequest`, `isRejected`, `include` and `maxMutationsPerInput` - and `nifra levels` and the `nifra_levels` MCP tool pass them to every L4 run. `invariants.setup` provisions a throwaway target only when L4 runs: it is called once, before the first L4 request and never when the config is imported, and the `teardown` it returns runs after the run, a failing one included. A config needs an `executor`, a `setup`, or both. The contract witness and case-context types now live in `@nifrajs/core/assurance`; `@nifrajs/testing` re-exports them unchanged.
|
|
109
|
+
- fc0284a: Add opt-in server components across React, Vue, Svelte, Solid and Preact. Closed schemas project public props, owner GET admission and read capabilities govern every refresh, and private responses bypass shared caches. React uses native Flight; Vue, Svelte, Solid and Preact preserve native island state and owned ranges.
|
|
110
|
+
|
|
111
|
+
Add coordinated Vite builds and development generations, guarded component references, generated Bun/Node/Deno/Workers/Pages/Vercel outputs, typed outlets, router and server-function refresh integration, and release certification for browsers, packaged consumers, resource limits and performance. Existing applications retain their default rendering pipeline.
|
|
112
|
+
|
|
113
|
+
Reuse registered Web middleware continuations across requests while retaining request-local state.
|
|
114
|
+
|
|
115
|
+
- 9f58295: An opaque mount can declare the routes it serves: `opaque: { reason, routes: [{ method, path, capabilities }] }` on `mount()`, `mountFetch()` and a `createWebApp` `mounts` entry. Capability assurance reports those routes with `analysis: "declared"` beside the known gap, checks their capabilities against the policy (an unknown id, or a domain write on a safe method, fails), and records them in the lockfile. `reflectMounts` returns them, project evidence carries them, the `nifra context` index lists them, and an adversarial contract run notes them as having no contract to test. A malformed declaration or a path outside the mount stops the analysis with an error that names it. A mount without a reason fails as before.
|
|
116
|
+
- 7a165de: A Response returned to more than one request no longer carries one request's headers to another. A Response without a body (a module-level `new Response(null, { status: 204 })`, a kept redirect) can be sent any number of times, so the cookies a handler queues with `c.set.cookie`, the app's `responseHeaders`, and the headers written by `onResponse` hooks, response observers and response middleware go on a copy made for the request; the Response the handler returned stays as it was built. A Response with a body can be sent once: returning it again answers `500` with `{"ok":false,"error":"internal_error"}` and none of the headers written for the earlier request. On `@nifrajs/node`, a response whose body cannot be written is answered with a `500` that carries none of the headers set for it.
|
|
117
|
+
|
|
118
|
+
`writableResponse(response, request?)`, exported from `@nifrajs/core/server`, returns the Response a request may write headers on: the response itself when it has a body and writable headers, otherwise a copy. Pass the request and a copy already made for it is reused by its later writers. `toResponse`, exported from `@nifrajs/core/edge-kit`, takes the same optional last argument: the copies and body-less responses it builds are then reused by that request's later writers instead of being copied again. A Response the handler returned is never treated as the request's own.
|
|
119
|
+
|
|
120
|
+
`renderResult(result)`, exported from `@nifrajs/core/edge-kit`, renders a framework `ResponseResult` exactly as `toResponse` does without taking a handler's `Response`. `@nifrajs/edge` renders its rejections with it; responses are unchanged and the bundle is smaller.
|
|
121
|
+
|
|
122
|
+
- 48aad8c: One `resume` or `compensate` call owns a saga at a time. It claims the saga with a lease that every transition renews (`createSagaEngine({ leaseMs })`, default 5 minutes) and releases it when it returns or throws. A second call while the lease is live throws `SagaConcurrencyError` and changes nothing; a call that died is taken over once its lease lapses, and a step it left executing goes to manual review as before. A call that loses a write to another one stops without compensating, and a step whose record is gone is never written back. `SagaRecord` gains the optional `owner` and `leaseUntil` fields; stored records without them load unchanged.
|
|
123
|
+
- 41050ed: `app.sse()` registers a POST stream when its schema declares a `body`: the body validates as on any POST, the handler reads `c.body`, and the typed client's `.subscribe(onEvent, { body })` sends it, typed from the body schema. A chat stream that posts its prompt no longer needs a hand-written `fetch`. A POST stream does not reconnect unless `reconnect: true` is passed, since a reconnect sends the body again; a GET stream keeps reconnecting by default.
|
|
124
|
+
|
|
125
|
+
`.subscribe()` now runs the client's `onRequest` hook before each connect, as every other request does, so a refreshed auth token reaches the stream.
|
|
126
|
+
|
|
127
|
+
- fc837cb: `sse()` and `.sse()` routes drop a client that stops reading. Once a client has `maxBufferedBytes` of events unread (a new `SSEInit` option, default 1 MiB), `stream.signal` aborts with `sse_client_too_slow`, the queued frames are released, and later sends are no-ops. Before, every `send()` queued in memory until the client read it or disconnected. A producer that sends a burst larger than 1 MiB before the client can read raises the option; `Infinity` turns the limit off.
|
|
128
|
+
- 9b1acc9: The Vite dev server, `buildClientVite` and `buildServerVite` apply the `package.json` single-copy declaration (`"nifra": { "singleCopy": [...] }`): each declared package, and each package installed under a declared `@scope/*`, joins `resolve.dedupe`, so a linked package with its own copy loads the app's copy, as the Bun build does. `singleCopyPackageNames(cwd)` from `@nifrajs/core/single-copy` returns that list.
|
|
129
|
+
- 740109f: `verifyWebhook` accepts `encoding: "base64url"` for providers that sign in the URL-safe alphabet. Padded and unpadded signatures verify; any character outside the alphabet, misplaced padding or an impossible length is refused as `malformed_signature`. Verification stays constant-time.
|
|
130
|
+
- e0a25fe: `app.ws(path, { capabilities })` declares the capability ids a socket's code may reach, as an HTTP route's `capabilities` does. Reflection lists them on the `"WS"` route, and `nifra capabilities check` covers sockets: it finds each `.ws("/path", ...)` registration, compares the declaration with the effects reachable from that module, and reports an undeclared reach. A write whose definition needs request idempotency fails on a socket, which has no request idempotency. The handshake context carries the guard, so a runtime capability beacon in `upgrade()` fails closed on an undeclared capability.
|
|
131
|
+
- 3630b6e: `reflectRoutes()` lists each WebSocket route under the pseudo-method `"WS"`, after the HTTP routes, with the evidence its handshake enforces: `nifra.authenticated` from the auth guards registered before it and `nifra.rate-limited` from the app's request hooks. A socket never claims evidence whose enforcement its message handlers bypass. Route assurance therefore classifies every socket, and an assurance rule selects sockets with `methods: ["WS"]`; a policy whose rules name only HTTP methods reports each socket as `unclassified-route`.
|
|
132
|
+
|
|
133
|
+
### Patch Changes
|
|
134
|
+
|
|
135
|
+
- a0a02f4: After an async `authenticate` stage, a `derive` or handler that throws synchronously now reaches `onError`. The rest of the route ran inside the stage's promise callback, so the throw rejected the request instead. `app.fetch` rejected, `toFetchHandler` rejected, and `Bun.serve` answered its own 500 without calling `onError` or the logger.
|
|
136
|
+
- 7de0397: A loader, action or render failure that an `_error` page answers is written to the server's `logger` as `unhandled request error`, with the request line and the detail `errorLogDetail` allows, the same entry an error with no boundary gets. A soft-navigation data request that a boundary answers with a 500 is logged the same way. Control flow such as a thrown `redirect()` is not logged. `@nifrajs/core/edge-kit` exports `emitRequestErrorLog` and `ErrorLogDetail`, the function the server itself logs through.
|
|
137
|
+
- 364a234: A streamed request body that passes its byte cap fails with 413 at once, even when the app took a `clone()` it never reads. Before, the rejection waited until the clone was read or cancelled.
|
|
138
|
+
- c06cba5: `server({ clientIp: { trustedHops } })` refuses a hop count that could never resolve a caller. A negative, fractional or non-number `trustedHops`, or a declaration with neither `trustedHops` nor `header`, used to be accepted, and `c.clientIp` was then `undefined` on every request. It now throws when the server is created.
|
|
139
|
+
- f4d3852: An owned effect whose `errorCode` or `failurePhase` mapper throws or returns an invalid value is still settled when it fails. The validation error used to be thrown before the `failed` transition and the lifecycle event, so the durable record stayed unfinished until reconciliation found it. The effect now settles with the default code and phase, and the validation error is thrown after.
|
|
140
|
+
- 4a82ae0: The email pattern in `commonSecretPatterns` scans a long run of address characters once. A logged value like `a.a.a.` repeated restarted the match at every letter, so 64 KB of it took most of a second to redact. It now starts a match only where such a run starts, and redacts the same addresses (plus a leading `+` or `.` that used to be left in front of one).
|
|
141
|
+
- 0ff867a: An idempotency `namespace` resolver receives the request's method, URL and headers, as its type says, instead of a full clone of the request. A resolver that read the clone's body could read past the route's body limit, and the clone kept a copy of the body alive. The resolver no longer gets a body at all.
|
|
142
|
+
- e7391bf: `merge()` refuses a server that holds `mount()`s or `mountFetch()`es, as it already refused one with WebSocket routes, with a `RouteConfigError` that says to add them on the parent. It used to fold in the server's routes and drop its mounts without a word, so every request under a mount prefix answered `404`.
|
|
143
|
+
- 0b80a59: A mounted app's `onRequest` hooks run once for a WebSocket handshake that its HTTP route answers. Under a `stripPrefix` mount the HTTP answer stripped the prefix into a fresh request, so the child ran its hooks again after the handshake check had already run them, and a rate limiter in the child counted the request twice. The child now gets the same stripped request the check gave it.
|
|
144
|
+
- c64db12: With `acceptInboundDeadlines` on, a route Bun's native table serves runs its header-only response hooks (`onResponseHeaders`) on a request that carries no deadline, and answers a render that throws through the app's error path. That lane used to skip the hooks, so the headers they set were missing from those responses.
|
|
145
|
+
- 3753104: `toNetlifyHandler()` and `toLambdaHandler()` keep the Host header's origin when the event carries no `rawUrl` (Netlify) or `requestContext.domainName` (API Gateway). A path that starts with `//`, such as `//evil.example/admin`, used to become the request URL's host (`https://evil.example/admin`), so an origin comparison against `request.url` saw the wrong host. It is now the path `//evil.example/admin` on the Host.
|
|
146
|
+
- 4b3b1fa: Reconciling the in-memory effect and saga stores, or a store that only implements `list()`, no longer skips records. Their scan cursor was an offset into the matching records, so a page reconciled before the next was read shifted the rest past it: 250 stale effects in pages of 100 reached the handler only 150 times. The cursor is now the last id returned, as with the durable adapters, and these scans return records in id order.
|
|
147
|
+
- 3562557: `reflectHooks` (and so `nifra routes --hooks`) finishes on an app that mounts itself, or two servers that mount each other. It used to follow the mount without end and never return. A mount of a server already on the path is now reported once, as that prefix's unmatched entry.
|
|
148
|
+
- 53087f3: `responseContract()` now holds every `status(...)` and `c.json(...)` reply to the route's declared schema, not only the one the handler returns. A reply the handler throws, a reply a `derive` or `beforeHandle` hook returns early, and a reply `onError` answers with used to reach the wire unchecked, undeclared fields included. They are now validated like a returned one: `enforce` strips or refuses them, `warn` logs them. If a schema throws while checking an `onError` reply, the request answers 500 instead of running `onError` again. A hand-built `Response` still passes unchecked.
|
|
149
|
+
- 2def6c7: A route written with non-ASCII text or a space (`.get("/café")`, `.get("/a b")`) now matches: its literal text is registered as a request spells it, composed to NFC and percent-encoded. Such a route used to answer 404 for every request.
|
|
150
|
+
- b5b5bc3: The single-copy runtime plugin hands back a linked copy's file that has no counterpart in the app's copy with the loader its extension names. It always used the `js` loader, so a TypeScript or JSX source file (`.ts`, `.mts`, `.cts`, `.tsx`, `.jsx`) in a linked package failed to parse instead of loading from the linked copy.
|
|
151
|
+
- 22e1777: `executeTool` resolves with the call's own result when the idempotency store's `abandon()` throws. A store that failed while releasing a reservation (after a budget refusal, a cancellation, a dry run or an executor that threw) used to make `executeTool` reject with the store's error instead. The key then stays reserved until its lease lapses.
|
|
152
|
+
- 3a483cd: `toolHttpResult` and `createToolHttpHandler` answer an idempotency refusal as the HTTP idempotency lane does: a key already used is `409`, a call still running under the key is `409` with `Retry-After: 1`, and a full idempotency store is `503` with `Retry-After: 1`. All three used to be `500`, which reads as a server fault a client should not retry.
|
|
153
|
+
- 35f52c7: `transportCodecs()` documents that it decodes a codec request body before routing, so a route's `bodyLimit` and its `authenticate()` stages run after the decode, and that its `maxBytes` option is the only bound on that work.
|
|
154
|
+
- 343cb81: A refused WebSocket handshake carries the app's `responseHeaders()` the way an HTTP answer does. A guard's refusal already did, but the cross-origin 403, a refusal returned by an `onRequest` hook, a malformed-path 400, the upgrade timeout's 503, an internal 500 and a refusal from a mounted child's socket went out without them.
|
|
155
|
+
- b13e071: A route whose result cannot be rendered (a `BigInt` in the JSON, a header value with a line break) is now logged and answered `500` on every lane, with `onResponseFinalized` and the app's response headers applied. The fast lanes a route without `onError` takes threw instead: `app.fetch` threw synchronously or rejected, `onResponseFinalized` never ran, and on `listen()` the 500 lacked the headers `responseHeaders()` and `onResponse` add. On the Node-direct lane the request failed instead of answering.
|
|
156
|
+
- 077657f: `server({ gracefulSignals: { drainMs } })`, and `serve(app, { signals: { drainMs } })` on Node and Deno, set how long in-flight requests get to finish when SIGTERM or SIGINT stops the server before the stop forces them closed. `true` keeps the 10 second default. `nifra doctor` counts the object form as a graceful lifecycle.
|
|
157
|
+
- 05aac0e: `reflectHooks` and `nifra routes --hooks` list each WebSocket route as `WS /path` with the hooks its handshake runs: the request hooks, then the authentication stages, `derive`s, `beforeHandle`s and `onError` declared before it. `nifra upgrade` and `--since` diffs include them.
|
|
158
|
+
- 7775c2b: Recheck current route authentication, authorization and prerequisite hooks before idempotent replay,
|
|
159
|
+
without repeating the handler, effect ledger or stored response transformations. Preserve stored
|
|
160
|
+
status, safe headers and the replay marker after admission.
|
|
161
|
+
|
|
162
|
+
Enforce typed SSE event limits in UTF-8 bytes before dispatch for complete and fragmented frames,
|
|
163
|
+
with bounded, linear fragment accounting independent of chunk boundaries.
|
|
164
|
+
|
|
165
|
+
Bound the in-memory GraphQL pubsub subscriber queue to 1024 events by default. Overloaded subscribers
|
|
166
|
+
detach and fail with `GraphqlPubSubOverflowError`; configure `maxQueuedEvents` or explicit
|
|
167
|
+
`drop-oldest`/`drop-newest` policies when needed. Returning or throwing into an iterator clears queued
|
|
168
|
+
events and ends delivery.
|
|
169
|
+
|
|
170
|
+
- aadd9fc: A merged group's request hooks now run for an `OPTIONS` request to a path the group serves. A group's hooks were scoped to the method and path of its routes, so a CORS preflight to a merged `POST /items` never reached the group's `cors()` and was answered `405`.
|
|
171
|
+
- ea16748: In development, `mount()` refuses an app that leads back to the mounting one without `stripPrefix` (itself, or a chain of mounts whose prefixes overlap) with a `RouteConfigError` coded `MOUNT_CYCLE`. A request under that prefix used to come back to the same app unchanged until the stack overflowed, which ended the process. Production bundles drop the check, as they do the other dev-time diagnostics.
|
|
172
|
+
- 5b22f4d: On Node, a hook that throws past `app.fetch` (an `onRequest` hook that reads `new Request(req)`, a mount handler that throws) used to answer `500` with nothing logged anywhere. The error now goes through the app's own `logger`, as Bun's `listen()` logs it, and the README says to copy a hook's request with `req.clone()`.
|
|
173
|
+
- 1b94593: `onRequest` hooks can return a `status()` result, typed by `OnRequestResult`, to refuse a request through the normal response path: the body is serialized with its content-type, and declared `responseHeaders` and `onResponse` hooks apply. A WebSocket handshake refused this way answers with the same status and body.
|
|
174
|
+
- b9608ac: `runReconciliationWorker` no longer releases its lease while handlers are still running. When one handler in a page failed, the worker rejected at once, so the lease was released while the other handlers kept going. A second worker could then take the lease and handle the same findings at the same time. On a failure the worker now starts no new handlers, waits for the running ones to settle, and only then releases the lease and rethrows the first error.
|
|
175
|
+
- b80ce77: `executeTool` no longer releases a tool's idempotency key once its executor has run. A store that throws while completing the call is now treated as one that refused it (`idempotency_capacity`), and an output validator that throws keeps the key reserved, so a retry cannot repeat a side effect that may have committed.
|
|
176
|
+
- d0a7d58: A GET carrying `Upgrade: websocket` to a path no WebSocket route takes now runs the `onRequest` hooks once. The handshake check ran them, found no socket route, and the HTTP route that answered ran them again, so a rate limiter or request counter in `onRequest` counted such a request twice. The HTTP route now runs with what the hooks returned during the check, including a rewritten request. This holds on Bun, Node and Deno.
|
|
177
|
+
|
|
178
|
+
## 4.0.2
|
|
179
|
+
|
|
180
|
+
## 4.0.1
|
|
181
|
+
|
|
182
|
+
## 4.0.0
|
|
183
|
+
|
|
184
|
+
### Minor Changes
|
|
185
|
+
|
|
186
|
+
- dde125b: feat(core): `bodyParser(schema, { types, parse })` from `@nifrajs/core/body-parser` lets one route
|
|
187
|
+
read a request body in a media type other than JSON, urlencoded, or multipart. `parse` is handed the
|
|
188
|
+
body's bytes and what it returns is validated by `schema`, so the handler sees the same typed body
|
|
189
|
+
whatever format it arrived in. JSON and urlencoded bodies still reach the schema through their own
|
|
190
|
+
lanes, and a route that does not opt in still answers `415`.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { bodyParser } from "@nifrajs/core/body-parser";
|
|
194
|
+
import { parse } from "yaml";
|
|
195
|
+
|
|
196
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
197
|
+
|
|
198
|
+
app.post(
|
|
199
|
+
"/pipelines",
|
|
200
|
+
{
|
|
201
|
+
body: bodyParser(Pipeline, {
|
|
202
|
+
types: ["application/yaml"],
|
|
203
|
+
parse: (bytes) => parse(utf8.decode(bytes), { maxAliasCount: 0 }),
|
|
204
|
+
}),
|
|
205
|
+
},
|
|
206
|
+
(c) => c.body.name
|
|
207
|
+
);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- The request's media type is matched in full against `types`: case folded, parameters set aside.
|
|
211
|
+
`parse` also receives `{ mediaType, contentType }`.
|
|
212
|
+
- The body is read under the route's `bodyLimit` before `parse` runs: `413 payload_too_large` over
|
|
213
|
+
it, `400 invalid_content_length` for a length that is not one.
|
|
214
|
+
- A `parse` that throws or rejects answers `400 invalid_body`.
|
|
215
|
+
- The decoded value must be a tree. An object, array, `Map` or `Set` reached twice, which is what an
|
|
216
|
+
alias or a cycle decodes to, answers `400 invalid_body` under every `protoPoisoning` setting.
|
|
217
|
+
- `protoPoisoning` applies to the decoded value: a `__proto__` key, a `constructor` that carries a
|
|
218
|
+
`prototype`, and a plain object whose prototype the decoder replaced are rejected or stripped.
|
|
219
|
+
Instances of a class, such as a `Date` or a `Uint8Array`, pass through as they are.
|
|
220
|
+
- `types` takes full `type/subtype` names and throws a `TypeError` for JSON, urlencoded, multipart,
|
|
221
|
+
and `text/plain`. The first three already have readers; `text/plain` is sent by a browser from any
|
|
222
|
+
site without a preflight.
|
|
223
|
+
- A decoder's own limits (nesting depth, alias expansion) are not covered: configure the decoder you
|
|
224
|
+
pass for untrusted input.
|
|
225
|
+
|
|
226
|
+
`bodyParser` and `multipartBody` compose in either order, and two `bodyParser` wraps each read their
|
|
227
|
+
own types; the outer one wins a type both name. `multipartBody` now hands a body that is not
|
|
228
|
+
multipart to a reader the schema already carried instead of answering `415` itself.
|
|
229
|
+
|
|
230
|
+
feat(schema): `toOpenAPI` and `toOpenAPIFromEvidence` list each media type a `bodyParser` schema
|
|
231
|
+
reads as its own entry under the operation's `requestBody.content`, in a fixed order, next to the
|
|
232
|
+
JSON or multipart entry. `reflectSchema` reports them as `mediaTypes`, and a project evidence
|
|
233
|
+
snapshot carries them, so a document built from a stored snapshot matches the live one.
|
|
234
|
+
|
|
235
|
+
- 3b090de: feat(core): a route schema takes `cookies`, validated before the handler like `headers` and `query`.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { server } from "@nifrajs/core/server";
|
|
239
|
+
import { t } from "@nifrajs/schema";
|
|
240
|
+
|
|
241
|
+
export const app = server().get(
|
|
242
|
+
"/dashboard",
|
|
243
|
+
{
|
|
244
|
+
cookies: t.cookies({
|
|
245
|
+
session: t.string(),
|
|
246
|
+
page: t.optional(t.integer()),
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
(c) => ({ session: c.cookies.session, page: c.cookies.page ?? 1 })
|
|
250
|
+
);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The schema checks the cookies parsed from the `Cookie` header (URL-decoded values, the first of a
|
|
254
|
+
repeated name) and its output types `c.cookies`. A request that fails it is a `422`, and
|
|
255
|
+
`onValidationError` receives the kind `"cookies"`; code that lists that parameter's kinds by hand
|
|
256
|
+
needs the new member. `defineContract` operations take `cookies` too. Route reflection, contract
|
|
257
|
+
snapshots and diffs, project evidence and the OpenAPI document carry the schema, and OpenAPI lists
|
|
258
|
+
each declared field as an `in: cookie` parameter.
|
|
259
|
+
|
|
260
|
+
`t.cookies` builds the schema for this slot. Like `t.query` it coerces declared number and boolean
|
|
261
|
+
fields from text and lets undeclared cookies through, since a browser sends every cookie the site
|
|
262
|
+
has set.
|
|
263
|
+
|
|
264
|
+
- 612a296: feat(core): a second copy of `@nifrajs/core` in one process is named when it loads. Two installed
|
|
265
|
+
copies (a linked sibling checkout, a nested install) keep separate request state, so a route from
|
|
266
|
+
one copy's `server()` merged into the other failed per request with an internal TypeError. The
|
|
267
|
+
second copy now prints `[nifra] @nifrajs/core is loaded 2 times` with the path of each copy, and
|
|
268
|
+
`.merge()` throws `merge() requires a server() from this copy of @nifrajs/core` at configuration
|
|
269
|
+
time for a server from another copy or any value that is not a `server()`. `mount()` crosses only
|
|
270
|
+
the fetch boundary and keeps working across copies. A dev server re-evaluating the same file is
|
|
271
|
+
one copy. `nifra check` remains the gate that fails the build on a duplicate install.
|
|
272
|
+
- 4af6f39: A group's `use()` of a plugin its parent server already applied throws a `RouteConfigError` with code `PLUGIN_RECONFIGURED` when it passes another instance under the same name - another `cors()` policy, a second `bearer()` verifier - where it used to skip it, so a group no longer appears to apply a configuration that never runs. Passing the parent's own instance is still a no-op, and a plugin the group applies still shares the parent's copy of a plugin it `use()`s itself. Plugins that name each instance, such as `rateLimit()`, `bodyLimit()`, `csrf()` and `securityHeaders()` with its own configuration, already apply inside a group and are unaffected.
|
|
273
|
+
- b53d64f: `schema.idempotency` takes `pendingTtlMs`, and `IdempotencyStore` an optional `renew()`. With a store that implements it, as `MemoryIdempotencyStore` does, a key whose handler is still running is held by a lease of `pendingTtlMs` (default 60 seconds, `DEFAULT_IDEMPOTENCY_PENDING_TTL_MS`) that the server renews every third of it until the response is stored, so a key reserved by a process that died frees after the lease instead of after `ttlMs`. `begin()` receives the lease as `pendingTtlMs`. A store without `renew()` keeps a running key reserved for `ttlMs` as before, and declaring `pendingTtlMs` on one is a registration error.
|
|
274
|
+
- 9c3d524: Route idempotency (`schema.idempotency`) releases a key when the handler answers 401, 403, 408, 409, 425, or 429 without an owned effect having begun, as it already did for a request refused before the handler. Each of those statuses says the request was not carried out, so a retry under the same key runs again, and callers who are refused no longer keep store entries for the full TTL.
|
|
275
|
+
|
|
276
|
+
A response whose body fails while it is being stored no longer leaves its key in progress until the TTL ends. With no owned effect begun the key is released; once one began, the key keeps a terminal 500 that a retry replays.
|
|
277
|
+
|
|
278
|
+
`MemoryIdempotencyStore` takes `maxEntriesPerNamespace`. Past it, only that namespace's new keys are refused, so one tenant or principal cannot use up `maxEntries` for the rest.
|
|
279
|
+
|
|
280
|
+
- 1b2d53a: `replacedRequestOf(request)` returns the request an `onRequest` hook replaced with this one. Response hooks receive the request the route ran with, so a middleware that keyed state on the request its `onRequest` saw can walk back to it. `idempotency()` uses this to keep its claim when a later hook such as `methodOverride()` rewrites the request: a retry replays the stored response instead of answering 409 and then running the handler a second time. `metrics()` uses it to count such requests and to decrement its in-flight gauge for them.
|
|
281
|
+
- 2e2d8c0: Tool idempotency gets the same lease as route idempotency. `ToolIdempotencyStore` takes an optional `renew()`, and `MemoryToolIdempotencyStore` implements it. With such a store, `executeTool` reserves the key with a lease of `pendingTtlMs` (default 60 seconds, or the tool's `idempotency.pendingTtlMs`) and renews it every third of that while the tool runs, so a key reserved by a process that died frees after the lease instead of after the store's `ttlMs`. A completed key is kept for `ttlMs` from completion. Stores without `renew()` keep a running key for their own TTL as before.
|
|
282
|
+
- 18aa5aa: A request body decoded by `transportCodecs()`, or a WebSocket frame decoded by a route's `transport`, may no longer hold a `RegExp` unless the lane opts in with `acceptRegExp: true`. A pattern from the client is code: one with catastrophic backtracking stalls the event loop the moment anything runs it. A refused body answers the same 400 as an undecodable one, and a refused frame goes to `onInvalidMessage`. Values the server sends are unaffected.
|
|
283
|
+
- ff5a779: A static response header a group declares replaces the value its enclosing scopes declared for that name, on the group's routes. A value the route sets itself still wins. `securityHeaders()` takes its configuration into its plugin name, so a group's `use(securityHeaders({ ... }))` with a different configuration applies over the app's on the group's routes, where it was skipped as a repeat; the same configuration applied twice is still a no-op.
|
|
284
|
+
- 10bc446: feat(core): `tls` serves HTTPS straight from the process, on Bun, Node and Deno.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { readFileSync } from "node:fs";
|
|
288
|
+
|
|
289
|
+
const tls = { cert: readFileSync("cert.pem"), key: readFileSync("key.pem") };
|
|
290
|
+
|
|
291
|
+
app.listen(443, { tls }); // Bun
|
|
292
|
+
await serve(app, { port: 443, tls }); // @nifrajs/node or @nifrajs/deno
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`cert` and `key` are PEM, as text or the files' bytes. Requests then arrive with `https:` URLs, and
|
|
296
|
+
WebSocket upgrades use the same port. Bun also takes `passphrase` for an encrypted key. On Node,
|
|
297
|
+
`tls` accepts any `node:tls` server option (`ca` and `requestCert` for client certificates,
|
|
298
|
+
`minVersion`, `SNICallback`), and the request protocol defaults to `https` when it is set. Without
|
|
299
|
+
`tls`, each runtime serves plain HTTP, which is what a proxy or platform that ends TLS expects.
|
|
300
|
+
|
|
301
|
+
- e8270d9: feat(web): SSR loader, action and boundary calls through `ctx.api` now carry the page request's
|
|
302
|
+
platform identity. A backend reached from a loader sees the visitor's `c.clientIp` (derived under the
|
|
303
|
+
page app's `server.clientIp` trust declaration), `c.env` and `c.waitUntil`, so per-visitor rate limits,
|
|
304
|
+
audit logs and bindings work during SSR. Headers are never copied: a loader call stays anonymous unless
|
|
305
|
+
the loader passes `cookie` or `authorization` itself. `inProcessClient()` / `testClient()` used outside a
|
|
306
|
+
render keep dispatching with no platform.
|
|
307
|
+
|
|
308
|
+
New seams in `@nifrajs/core/mount`: `NIFRA_BACKEND_BIND_PLATFORM` (returns a platform-bound view of an
|
|
309
|
+
in-process client) and `NIFRA_PLATFORM_CLIENT_IP_DERIVED` (marks a platform whose `clientIp` an enclosing
|
|
310
|
+
server already derived, so a backend with its own `clientIp` trust keeps it instead of re-reading
|
|
311
|
+
forwarding headers a synthesized request does not carry).
|
|
312
|
+
|
|
313
|
+
- ff4a062: feat(core): one handler can be registered under several methods, and under a method outside the
|
|
314
|
+
standard seven, with `all()` and `method()` from `@nifrajs/core/methods`:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { server } from "@nifrajs/core/server";
|
|
318
|
+
import { all, method } from "@nifrajs/core/methods";
|
|
319
|
+
|
|
320
|
+
const app = server()
|
|
321
|
+
// GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS /echo
|
|
322
|
+
.use(all("/echo", (c) => ({ method: c.req.method })))
|
|
323
|
+
.use(method("PURGE", "/cache/:key", (c) => ({ purged: c.params.key })))
|
|
324
|
+
.use(method(["GET", "POST"], "/search", (c) => ({ q: c.query.get("q") })));
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- Each method is an ordinary route: it is listed by `app.routes()`, takes a schema and hooks, works
|
|
328
|
+
inside `group()`, and throws `DUPLICATE_ROUTE` against a route already registered for the same
|
|
329
|
+
method and path. One call is one registration: if any of its routes is refused, none is added.
|
|
330
|
+
- `all()` is the seven standard methods, not a catch-all. A request with any other method is still a
|
|
331
|
+
`405` with an `Allow` header. `mount()` remains the way to pass every method through.
|
|
332
|
+
- A method name is case-insensitive and registered uppercase. It is a token of letters, digits and
|
|
333
|
+
hyphens that starts with a letter, at most 32 characters. `TRACE`, `CONNECT` and `TRACK` cannot be
|
|
334
|
+
registered; those and any other value throw `INVALID_METHOD`.
|
|
335
|
+
- The standard methods in a call join the typed registry and the typed client. A custom method has
|
|
336
|
+
no typed-client call.
|
|
337
|
+
- An assurance policy's `methods` selector takes standard methods only, so it never matches a
|
|
338
|
+
custom-method route. Classify such a route with a path rule; unmatched, it is reported as
|
|
339
|
+
`unclassified-route`.
|
|
340
|
+
- Whether a custom method reaches the app is up to the runtime's HTTP parser: `PROPFIND`, `REPORT`,
|
|
341
|
+
`PURGE` and `QUERY` arrive on Bun, Node, Deno and workerd, and a token the parser does not know
|
|
342
|
+
can be answered by the runtime itself.
|
|
343
|
+
- `Router.add` from `@nifrajs/core/router` accepts the same method tokens, and
|
|
344
|
+
`isRegistrableMethod(name)` from that subpath reports whether a name is one.
|
|
345
|
+
`RouteDescriptor.method` is typed `RouteMethod`: a standard `Method` or another such token.
|
|
346
|
+
|
|
347
|
+
feat(schema): `toOpenAPI` leaves a custom-method route out of the document. A path item has a field
|
|
348
|
+
for each standard method and none for any other.
|
|
349
|
+
|
|
350
|
+
feat(cli): `nifra check` reads the routes `all()` and `method()` register, so the duplicate,
|
|
351
|
+
overlap, reserved-segment and param-modifier rules cover them, reported once per call. The route
|
|
352
|
+
brief and `--json` output list a custom-method route with a `fetch` call. The capability report
|
|
353
|
+
attributes a module to every path an optional-param route serves, and to `all()` and `method()`
|
|
354
|
+
routes.
|
|
355
|
+
|
|
356
|
+
- 7bfa25e: feat(schema): a route body can be a `multipart/form-data` form with file fields. `@nifrajs/schema/form`
|
|
357
|
+
exports a `t` that is the `@nifrajs/schema` builder plus `t.file` and `t.form`; text fields and files
|
|
358
|
+
are validated before the handler runs and typed in `c.body`.
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
import { t } from "@nifrajs/schema/form";
|
|
362
|
+
|
|
363
|
+
app.post(
|
|
364
|
+
"/avatars",
|
|
365
|
+
{
|
|
366
|
+
body: t.form({
|
|
367
|
+
avatar: t.file({
|
|
368
|
+
maxBytes: 5_000_000,
|
|
369
|
+
accept: ["image/png", "image/jpeg"],
|
|
370
|
+
}),
|
|
371
|
+
caption: t.optional(t.string({ maxLength: 200 })),
|
|
372
|
+
}),
|
|
373
|
+
bodyLimit: 6_000_000,
|
|
374
|
+
},
|
|
375
|
+
(c) => ({ bytes: c.body.avatar.size })
|
|
376
|
+
);
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`@nifrajs/schema`
|
|
380
|
+
|
|
381
|
+
- `t.file({ maxBytes?, accept? })` validates a `File`. The size is checked before a byte is read.
|
|
382
|
+
`accept` takes exact types and `type/*` wildcards and is matched against the file's leading bytes,
|
|
383
|
+
never the type the client claimed; the validated file then carries the detected type. An `accept`
|
|
384
|
+
entry no signature can prove (`text/csv`, `image/svg+xml`, `*/*`) throws when the schema is built.
|
|
385
|
+
- `t.form(fields, options?)` is a route `body` as is. Text fields are coerced from their string form,
|
|
386
|
+
a repeated field is a list, and an undeclared field fails validation unless `additionalProperties`
|
|
387
|
+
is set. `options` also takes the body limits below.
|
|
388
|
+
- `t.array(file)` and `t.optional(file)` work with either `t`. `t.object`, `t.looseObject`, `t.query`,
|
|
389
|
+
`t.union`, `t.record`, and `t.paginated` throw when handed a file field.
|
|
390
|
+
- A zero-byte file part, or an empty text part in a file field, counts as no file. A required list
|
|
391
|
+
field with no entries is `[]`.
|
|
392
|
+
- A form with no required file also accepts `application/json` and
|
|
393
|
+
`application/x-www-form-urlencoded` bodies.
|
|
394
|
+
- `toOpenAPI` emits a body with a file field as `multipart/form-data` unless `requestContentType`
|
|
395
|
+
says otherwise.
|
|
396
|
+
- `@nifrajs/schema` now depends on `@nifrajs/uploads`.
|
|
397
|
+
|
|
398
|
+
`@nifrajs/core`
|
|
399
|
+
|
|
400
|
+
- `@nifrajs/core/multipart` exports `multipartBody(schema, limits?)`, which marks any Standard Schema
|
|
401
|
+
as a form body, and the `MultipartLimits` and `MultipartValue` types. The route reads the form under
|
|
402
|
+
its `bodyLimit` and hands the schema a record of strings and `File`s; a repeated name is an array.
|
|
403
|
+
- Limits: `maxFields` (default 100), `maxFiles` (10), `maxFieldBytes` (65536), `maxFileBytes`
|
|
404
|
+
(unbounded below `bodyLimit`).
|
|
405
|
+
- Responses: `415 unsupported_media_type` for another content type, `400 invalid_multipart` for a
|
|
406
|
+
body that is not a well-formed form or ends before its closing delimiter, and `413` with
|
|
407
|
+
`payload_too_large`, `too_many_parts`, `too_many_fields`, `too_many_files`, `field_too_large`, or
|
|
408
|
+
`file_too_large`.
|
|
409
|
+
- File names have path separators and control characters removed. Field names follow the server's
|
|
410
|
+
`protoPoisoning` policy.
|
|
411
|
+
|
|
412
|
+
`@nifrajs/uploads`
|
|
413
|
+
|
|
414
|
+
- `DETECTABLE_MIME_TYPES` and `FILE_TYPE_PREFIX_BYTES` are exported, and `@nifrajs/uploads/detect`
|
|
415
|
+
exports them with `detectFileType` and `FileType` on their own.
|
|
416
|
+
|
|
417
|
+
`@nifrajs/client`
|
|
418
|
+
|
|
419
|
+
- A body that holds a `File` or `Blob` is sent as `multipart/form-data`. An array value is sent as a
|
|
420
|
+
repeated field, `null` and `undefined` values are left out, and a caller-set `content-type` is
|
|
421
|
+
dropped so the generated boundary is used. A `FormData` body is sent as is. A nested object or list
|
|
422
|
+
cannot be a form field.
|
|
423
|
+
- `inProcessClient` and `testClient` send a `FormData` body with its `content-length`.
|
|
424
|
+
|
|
425
|
+
`@nifrajs/edge`
|
|
426
|
+
|
|
427
|
+
- A route whose body is a `t.form` or a `multipartBody` schema reads the form, with the same limits
|
|
428
|
+
and responses as the full server.
|
|
429
|
+
|
|
430
|
+
- 4a03d30: feat(core): `notFound(handler)` from `@nifrajs/core/not-found` answers a request no route matched,
|
|
431
|
+
in place of the default `404` body. Apply it with `use()`:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
import { notFound } from "@nifrajs/core/not-found";
|
|
435
|
+
|
|
436
|
+
app.use(
|
|
437
|
+
notFound(({ pathname, header }) => {
|
|
438
|
+
if (header("accept")?.includes("text/html")) {
|
|
439
|
+
return new Response("<h1>Nothing here</h1>", {
|
|
440
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return undefined; // the default { ok: false, error: "not_found" }
|
|
444
|
+
})
|
|
445
|
+
);
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
- It answers a `404` only. A path that exists under another method is still a `405` with `Allow`, a
|
|
449
|
+
malformed path parameter is still a `400`, and a `404` a route or a mounted app returned is left
|
|
450
|
+
alone.
|
|
451
|
+
- A `2xx` answer is sent as a `404`, keeping its body and headers. A `3xx`, `4xx` or `5xx` answer is
|
|
452
|
+
sent unchanged. `undefined` keeps the default body. A thrown `Response` is an answer.
|
|
453
|
+
- The handler is given `method`, `url`, `pathname` (as sent, not percent-decoded), `headers`,
|
|
454
|
+
`header(name)`, `signal` and `platform`. It is never given the request body.
|
|
455
|
+
- A throw, a rejection, or a returned value that is not a `Response` is logged once, honouring
|
|
456
|
+
`errorLogDetail`, and answered with the plain `500` `internal_error` body.
|
|
457
|
+
- With `requestTimeoutMs` set, an async handler that outlives it has `signal` aborted and the request
|
|
458
|
+
answered `503`. A deadline header on the request is not consulted for a request no route matched.
|
|
459
|
+
- The answer takes the normal response path: fixed response headers and `onResponse` hooks apply.
|
|
460
|
+
- One handler per server. A second `notFound()` throws, as does one applied inside a `group()` or
|
|
461
|
+
after `listen()`. `merge()` does not carry a merged server's handler across.
|
|
462
|
+
|
|
463
|
+
To serve unmatched paths - a single-page app's shell, another app behind this one - register a
|
|
464
|
+
wildcard route or a mount: those are matches, with their own status, the request body, and the full
|
|
465
|
+
route lifecycle.
|
|
466
|
+
|
|
467
|
+
feat(edge): `notFound(handler)` from `@nifrajs/edge` builds the same handler for the compact server,
|
|
468
|
+
passed as an option: `server({ notFound: notFound(handler) })`. The rules are the ones above, shared
|
|
469
|
+
with the full server. There is no logger and no request timeout on the compact server, so a fault is
|
|
470
|
+
the plain `500` and the handler bounds its own I/O. An app that does not import `notFound` ships
|
|
471
|
+
none of it.
|
|
472
|
+
|
|
473
|
+
- 8e30090: feat: a mount nifra cannot analyze is a declared known gap, not a silent hole. Routes behind
|
|
474
|
+
`mount()` and `mountFetch()` are invisible to route reflection, so capability assurance could not
|
|
475
|
+
see them and said nothing. Both now take `opaque: "<reason>"`, and so does a `createWebApp`
|
|
476
|
+
`mounts` entry. Capability assurance lists a mount with a reason as a known gap
|
|
477
|
+
(`report.gaps`, `{ kind: "opaque-mount", path, reason }`): `nifra check` and
|
|
478
|
+
`nifra capabilities check` print it on every run, and it fails nothing and lowers no level. A mount
|
|
479
|
+
on the analyzed app without a reason fails with the new `opaque-mount-undeclared` finding, which
|
|
480
|
+
suggests `merge()` for a nifra `server()` and `opaque` for anything else. A mount whose app publishes
|
|
481
|
+
composed evidence (the API `createWebApp` mounts) is not reported. `webProjectEvidence` accepts a
|
|
482
|
+
`mounts` entry without an evidence provider when it declares `opaque`. New exports:
|
|
483
|
+
`reflectMounts` and `ReflectedMount` from `@nifrajs/core/reflection`, `CapabilityGap` from
|
|
484
|
+
`@nifrajs/core/capabilities`; project evidence snapshots gain an optional `mounts` list, absent for
|
|
485
|
+
an app with no mounts.
|
|
486
|
+
- 28f3aaf: feat(core): a path can end in optional params, written `:name?`. The route serves the path with the
|
|
487
|
+
param and without it:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
app
|
|
491
|
+
// GET /users and GET /users/42
|
|
492
|
+
.get("/users/:id?", (c) =>
|
|
493
|
+
c.params.id === undefined ? { all: true } : { id: c.params.id }
|
|
494
|
+
)
|
|
495
|
+
// GET /archive, GET /archive/2026 and GET /archive/2026/09
|
|
496
|
+
.get("/archive/:year?/:month?", (c) => ({
|
|
497
|
+
year: c.params.year,
|
|
498
|
+
month: c.params.month,
|
|
499
|
+
}));
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
- Optional params are whole segments at the end of the path. Several in a row are filled left to
|
|
503
|
+
right: a later one is present only when every earlier one is. A `?` anywhere else (`/a/:id?/b`,
|
|
504
|
+
`/v-:id?`) is literal text, as before.
|
|
505
|
+
- An optional param is typed `string | undefined`. When the path omits it, it is absent from
|
|
506
|
+
`c.params`, not an empty string. A `params` schema that requires it answers `422` on the shorter
|
|
507
|
+
path.
|
|
508
|
+
- The route is registered once per path it serves, with the same handler, schema and hooks.
|
|
509
|
+
`app.routes()`, `group()`, `merge()`, `implement()`, `ws()` and the typed registry all see those
|
|
510
|
+
paths, so `GET /users` registered next to `GET /users/:id?` throws `DUPLICATE_ROUTE`. A route that
|
|
511
|
+
is rejected leaves none of its HTTP paths registered.
|
|
512
|
+
- The typed client calls each path: `api.users.get()` and `api.users({ id }).get()`.
|
|
513
|
+
- `defineContract` accepts the same paths; two operations that serve the same method and path are
|
|
514
|
+
refused.
|
|
515
|
+
- `expandOptionalParams(pattern)` from `@nifrajs/core/pattern` returns the paths a pattern serves,
|
|
516
|
+
shortest first. `Router.add` from `@nifrajs/core/router` registers exactly the pattern it is given;
|
|
517
|
+
a caller that wants the optional form adds each expanded pattern.
|
|
518
|
+
- `routePatternOverlap` compares every path each side serves, under one work budget for the call.
|
|
519
|
+
|
|
520
|
+
feat(edge): the compact server accepts the same optional params.
|
|
521
|
+
|
|
522
|
+
feat(schema): `toOpenAPI` emits one operation per path an optional-param route serves. A shorter
|
|
523
|
+
path declares only the parameters it has. For a contract, the operation name is the `operationId` of
|
|
524
|
+
the full path, and the shorter paths carry none.
|
|
525
|
+
|
|
526
|
+
feat(cli): `nifra check` reports `NF-C026` (warning) for a param followed by `?`, `*`, `+`, `{`, `(`
|
|
527
|
+
or `<` that the path grammar reads as literal text, and names a `?` route that no request can reach.
|
|
528
|
+
`// nifra-expect param-modifier` above the registration marks a deliberate literal. Overlap and
|
|
529
|
+
duplicate checks read an optional-param route as every path it serves.
|
|
530
|
+
|
|
531
|
+
- 6d20355: feat(core): a path param can say which values it accepts, written in braces after the name. A
|
|
532
|
+
request whose value does not fit is not served by that route:
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
app
|
|
536
|
+
// /users/me is its own route; /users/42 is this one; /users/ada is a 404
|
|
537
|
+
.get("/users/me", () => ({ me: true }))
|
|
538
|
+
.get("/users/:id{[0-9]+}", (c) => ({ id: Number(c.params.id) }))
|
|
539
|
+
// a list of values, and a count
|
|
540
|
+
.get("/img/:size{thumb|full}/:file", (c) => ({
|
|
541
|
+
size: c.params.size,
|
|
542
|
+
file: c.params.file,
|
|
543
|
+
}))
|
|
544
|
+
.get("/countries/:code{[A-Z]{2}}", (c) => ({ code: c.params.code }))
|
|
545
|
+
// inside a segment, and optional at the end of a path
|
|
546
|
+
.get("/files/:name.:ext{png|jpg}", (c) => ({
|
|
547
|
+
name: c.params.name,
|
|
548
|
+
ext: c.params.ext,
|
|
549
|
+
}))
|
|
550
|
+
.get("/posts/:page{[0-9]+}?", (c) => ({ page: c.params.page ?? "1" }));
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
- A constraint is one character class with an optional count (`[0-9]`, `[a-z0-9_-]+`, `\d{4}`,
|
|
554
|
+
`\w{2,8}`), or a list of two or more values (`png|jpg|webp`). A class holds letters, digits, ranges
|
|
555
|
+
of them, `\d`, `\w` and `. _ ~ ! $ & ' ( ) + , ; = @ -`; there is no negated class and a count
|
|
556
|
+
starts at one. Anything else in braces (`:id{int}`, `:id{.+}`, `:id{[0-9]+|[a-z]+}`) is literal
|
|
557
|
+
text, as before.
|
|
558
|
+
- The param stays a `string`, keyed by its bare name: `Params<"/users/:id{[0-9]+}">` is
|
|
559
|
+
`{ id: string }`.
|
|
560
|
+
- The value is checked as it was sent, before percent-decoding. `/users/4%32` does not fit
|
|
561
|
+
`:id{[0-9]+}`; a broader route beside it serves that request.
|
|
562
|
+
- The narrowest route answers, whatever the order of registration: literal text, then a list, then a
|
|
563
|
+
class, then a bare `:param`, then a wildcard. Between two constraints of a kind, the one that
|
|
564
|
+
accepts fewer values is tried first. A method the narrowest matching route does not have answers
|
|
565
|
+
`405`, as it does for a literal route beside a param route.
|
|
566
|
+
- Two spellings of one constraint (`[0-9]+`, `\d+`, `[0-9]{1,}`) are one route: the same method
|
|
567
|
+
registered on both throws `DUPLICATE_ROUTE`.
|
|
568
|
+
- Inside a segment the text around the params is placed first and each value is then checked; the
|
|
569
|
+
router does not look for another split.
|
|
570
|
+
- `routePatternOverlap` takes constraints into account: `/users/me` and `/users/:id{[0-9]+}` do not
|
|
571
|
+
overlap.
|
|
572
|
+
- `@nifrajs/core/pattern` exports `paramConstraint(text)`, which reads a constraint at the start of
|
|
573
|
+
`text`, and the `ParamConstraint` type. A param part of a compiled mixed segment carries its
|
|
574
|
+
constraint as `c`.
|
|
575
|
+
|
|
576
|
+
feat(edge): the compact server accepts the same constraints.
|
|
577
|
+
|
|
578
|
+
feat(schema): `toOpenAPI` writes a constrained param into the path template by its bare name
|
|
579
|
+
(`/users/{id}`). Its schema is `{ type: "string", pattern }` for a character class and
|
|
580
|
+
`{ type: "string", enum }` for a list of values; a declared `params` schema still takes precedence.
|
|
581
|
+
|
|
582
|
+
feat(client): a constrained param is passed by its bare name, `api.users({ id: "42" }).get()`. Two
|
|
583
|
+
param routes at one position (`/users/:id{[0-9]+}` beside `/users/:slug`, or a param beside a
|
|
584
|
+
wildcard) are each callable, picked by the name of the key. The client does not check a value
|
|
585
|
+
against its constraint.
|
|
586
|
+
|
|
587
|
+
feat(cli): `nifra check` accepts a supported constraint and keeps reporting other text in braces
|
|
588
|
+
(`NF-C026`); `NF-C024` and `NF-C025` follow the router's reading of a constraint. `nifra routes` and
|
|
589
|
+
the generated client calls print the bare name. `nifra scaffold` refuses a page path that carries a
|
|
590
|
+
constraint.
|
|
591
|
+
|
|
592
|
+
feat(testing): `runAdversarialContract` builds a request path whose values satisfy each param's
|
|
593
|
+
constraint, and fills a part-literal segment (`/files/:name.json`) param by param.
|
|
594
|
+
|
|
595
|
+
feat(web): a route file name that would read as a param constraint (`[id]{a|b}.tsx`) is refused at
|
|
596
|
+
build time with a message that names the file. `llms.txt` prints client calls with the bare name.
|
|
597
|
+
|
|
598
|
+
- b64c3ee: fix(web): the identity preflight behind `nifra check`, `nifra doctor` and the build now scans every
|
|
599
|
+
package declared in `"nifra": { "singleCopy": [...] }`, not only the built-in identity-sensitive set.
|
|
600
|
+
A declared package installed at two versions is a fatal `version-skew` finding with the same
|
|
601
|
+
remediation as a framework skew (align the ranges; nifra never redirects across versions), and one
|
|
602
|
+
version at two paths is reported as deduplicated.
|
|
603
|
+
|
|
604
|
+
feat(core): `@nifrajs/core/single-copy/register` no longer skips silently. Each declared package it
|
|
605
|
+
cannot collapse - a version skew, or a linked file with no counterpart in the app's copy - prints one
|
|
606
|
+
warning per process naming both copies, both versions and the reason. Strict mode turns that into a
|
|
607
|
+
startup failure: declare `"singleCopy": { "packages": [...], "strict": true }` or set
|
|
608
|
+
`NIFRA_SINGLE_COPY_STRICT=1`. `SingleCopySkip` gains optional `to`, `fromVersion` and `toVersion`;
|
|
609
|
+
`SingleCopyOptions` gains `strict` and `onSkip`; new exports `readSingleCopyStrict` and
|
|
610
|
+
`SINGLE_COPY_STRICT_ENV`.
|
|
611
|
+
|
|
612
|
+
- 81c720e: fix(client): a segment that is part literal, part param is callable through the typed client
|
|
613
|
+
|
|
614
|
+
A route such as `/files/:name.json`, `/post-:id` or `/v:major.:minor` could not be reached through
|
|
615
|
+
the typed client: `:name.json` was typed as a param named `name.json` whose call sent the value
|
|
616
|
+
without `.json`, and `post-:id` was typed as a property that sent the pattern text itself. Such a
|
|
617
|
+
segment is now a call with the segment as the request carries it:
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
await api.files("report.json").get(); // GET /files/:name.json, c.params.name === "report"
|
|
621
|
+
await api("post-42").get(); // GET /post-:id
|
|
622
|
+
await api("v1.2").get(); // GET /v:major.:minor
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
- The argument is typed as the segment's literal text around any string (`` `${string}.json` ``), so
|
|
626
|
+
`api.files("report.txt")` does not compile. A constraint is not part of the text:
|
|
627
|
+
`/img/:id{[0-9]+}.png` takes `` `${string}.png` ``, and the server decides whether the value fits.
|
|
628
|
+
- The value is sent as one encoded segment, as a param value is: a `/` in it never adds a path level.
|
|
629
|
+
- A static segment at the same position keeps its exact text (`api.files("index.json")` is
|
|
630
|
+
`/files/index.json` when that route exists, which is the route the server picks), and a
|
|
631
|
+
whole-segment param keeps its call by name (`api.files({ id })`).
|
|
632
|
+
- Two such segments at one position that accept the same text resolve to the types of the one
|
|
633
|
+
registered first.
|
|
634
|
+
- `@nifrajs/core` exports `RequestPath<Path>`, the same reading for a whole path:
|
|
635
|
+
`RequestPath<"/files/:name.json">` is `` `/files/${string}.json` ``, a constraint reads as
|
|
636
|
+
`${string}`, and a path ending in optional params is one template per form.
|
|
637
|
+
- The cli's route listings and the generated `llms.txt` print the call the same way
|
|
638
|
+
(`` api.files(`${name}.json`) ``), and print an unnamed wildcard as `({ "*": rest })`.
|
|
639
|
+
|
|
640
|
+
- ed60b23: feat(core): `app.group(prefix, build)` declares routes under a static path prefix. The prefix is part
|
|
641
|
+
of every route's served path, so the typed client (`api.admin.users.get()`), `routes()`, OpenAPI,
|
|
642
|
+
capability events, the effect ledger, and idempotency keys all see `/admin/users`. `JoinRoutePath`
|
|
643
|
+
and `PrefixRegistry` are exported for the registry types.
|
|
644
|
+
|
|
645
|
+
- The builder inherits the parent's chain as it stands at the call (`derive`, `decorate`,
|
|
646
|
+
`authenticate`, `beforeHandle`, `afterHandle`, `onError`, `around`, assurance, installed runtimes).
|
|
647
|
+
Middleware the builder adds covers only the group's routes.
|
|
648
|
+
- The group's `onRequest`, `onResponse`, `responseHeaders`, and `onResponseFinalized` hooks run only
|
|
649
|
+
for requests whose path is the prefix or under it. That includes that path's 404 and 405 responses.
|
|
650
|
+
- Groups nest, and a group's `"/"` route serves the prefix itself.
|
|
651
|
+
- A prefix must be static text. Params, wildcards, percent-escapes, empty or dot segments, and a
|
|
652
|
+
trailing slash are refused at registration.
|
|
653
|
+
- The builder must synchronously return the group it was given. The group is closed once the builder
|
|
654
|
+
returns or throws.
|
|
655
|
+
- A collision with an existing route throws `RouteConfigError`, and none of the group's routes are
|
|
656
|
+
added.
|
|
657
|
+
- `.ws()`, mounts, MCP tools, and `merge()` are refused inside a group.
|
|
658
|
+
|
|
659
|
+
`merge()` now also carries the merged server's `onStop` hooks.
|
|
660
|
+
|
|
661
|
+
- a158b74: feat(web): a page file under a mount fails at startup, at build and in `nifra check`, instead of
|
|
662
|
+
answering with the mount's 404. A mount in front of the page router - the backend at `apiPrefix`, a
|
|
663
|
+
`mounts` entry, or an `app.mount()` inside `use` - answers every request under its path, and its 404
|
|
664
|
+
is final (`fallbackOn: 404` only tries the next mount), so a page there could never render.
|
|
665
|
+
`createWebApp` now throws at startup, naming each file, the URL it serves and the mount; `nifra build`
|
|
666
|
+
refuses to build; and `nifra check` reports `NF-C027` for a page under the backend's prefix, reading
|
|
667
|
+
`apiPrefix` when `backend/framework.ts` exports it as a string literal (an `info` finding says so when it does
|
|
668
|
+
not).
|
|
669
|
+
|
|
670
|
+
`backend/framework.ts` can export `apiPrefix`, `apiStrip`, `mounts`, `csp` and `nonce`. `nifra dev`,
|
|
671
|
+
`nifra build` (the generated server entry and the static prerender), `nifra mcp`'s render tool and the
|
|
672
|
+
hydration gate pass the same set to `createWebApp`, and the render tool and the hydration gate now
|
|
673
|
+
apply `use` as well. A field of the wrong type fails at load, naming it, and a value
|
|
674
|
+
`nifra.config.ts` exports must be the one `backend/framework.ts` exports, or the build stops. `nifra routes`
|
|
675
|
+
lists the backend under the configured prefix, at the served path when `apiStrip` is set.
|
|
676
|
+
|
|
677
|
+
New exports: `preRouteMountPaths` from `@nifrajs/core/mount`; `shadowedPages`,
|
|
678
|
+
`formatShadowedPages`, `normalizeMountPath` and `ShadowedPage` from `@nifrajs/web/route-manifest`;
|
|
679
|
+
`SERVER_ENTRY_OPTIONS` and the `optionImports` option of `generateServerEntry` and `buildTarget` from
|
|
680
|
+
`@nifrajs/web/build`.
|
|
681
|
+
|
|
682
|
+
Upgrading: an app with a page file under its backend prefix (`routes/api/*` with a backend)
|
|
683
|
+
started before and answered that page with a 404; it now fails to start until the file moves out of
|
|
684
|
+
the prefix or the prefix changes.
|
|
685
|
+
|
|
686
|
+
### Patch Changes
|
|
687
|
+
|
|
688
|
+
- 72b62fa: fix(core): on Bun, `listen()` picks the same route for a request as `app.fetch()` does, and as every
|
|
689
|
+
other runtime does. Three cases:
|
|
690
|
+
|
|
691
|
+
- A path segment that mixes literal text and a parameter matches only what it is written to match.
|
|
692
|
+
`/files/:name.json` serves `/files/a.json` with `params.name` of `"a"`, and answers `/files/a` with
|
|
693
|
+
`404`.
|
|
694
|
+
- When the most specific route for a path has no handler for the request's method, the answer is
|
|
695
|
+
`405` with an `Allow` header. With `POST /users/me` and `GET /users/:id`, `GET /users/me` is `405`.
|
|
696
|
+
- A route that is more specific early in the path takes its requests ahead of a broader one. With
|
|
697
|
+
`GET /admin/*rest` and `GET /:section/:page`, `GET /admin/users` is served by `/admin/*rest`.
|
|
698
|
+
|
|
699
|
+
A route none of these touch is still served from Bun's own route table.
|
|
700
|
+
|
|
701
|
+
- aa44e93: fix(core): on Bun, `listen()` starts a server that mounts another nifra app with no WebSocket routes.
|
|
702
|
+
Every mounted nifra app counted as a WebSocket mount, so `listen()` required the `websocket()` runtime
|
|
703
|
+
and threw `INVALID_WS_RUNTIME`. `createWebApp({ api })` mounts its backend this way, so a full-stack
|
|
704
|
+
app with an `api` backend could not start on Bun.
|
|
705
|
+
|
|
706
|
+
A mounted app now takes part in Bun's WebSocket wiring only when it has a WebSocket route, directly or
|
|
707
|
+
in an app it mounts. `listen()` asks when it runs, so a runtime or route added to the child after it
|
|
708
|
+
was mounted is served, and an app mounted under itself to alias a prefix still starts. A mounted
|
|
709
|
+
object that exposes `resolveWebSocketUpgrade` without naming a runtime still needs `websocket()` on
|
|
710
|
+
the parent.
|
|
711
|
+
|
|
712
|
+
- 4a3ee60: fix(core): on Bun, `c.clientIp` is the socket peer on every route `listen()` serves
|
|
713
|
+
|
|
714
|
+
A static or `:param` route answered `undefined` for `c.clientIp` under `listen()` on Bun, while a
|
|
715
|
+
wildcard route, and any route of an app with a request hook, answered the socket peer. Every route
|
|
716
|
+
now answers the peer, as the docs describe. The address is looked up when a handler reads it, so a
|
|
717
|
+
request whose handler never reads it costs no lookup, and a second read returns the first answer.
|
|
718
|
+
|
|
719
|
+
- aad6297: An assurance rule whose selector can match no route is refused when the policy is defined: an empty `methods` or `paths` list, a `capabilities` entry that is not a valid capability id, and, in `defineAssuranceConfig` with a capability policy, a selected capability that policy does not define. Each made the rule inert, so its routes fell through to the next, laxer rule.
|
|
720
|
+
- dad0d41: On a route with a body schema that runs auth before validation, direct `c.req` body reads made by its derive and `beforeHandle` hooks are capped by the route's `bodyLimit`, as they are on a route without a body schema.
|
|
721
|
+
- 538adc2: Capability assurance treats `OPTIONS` as a safe method, as it already treated `GET` and `HEAD`: an `OPTIONS` route that declares a domain write is reported as `safe-method-domain-write`, and one that only reaches it as `unconfined-write-reach`.
|
|
722
|
+
- f47edd1: `aroundCapability`: a `next()` called after its interceptor returned or timed out no longer runs the interceptors after it. It returns a promise rejected with `CapabilityInterceptorProtocolError`, the error a second `next()` call already raises.
|
|
723
|
+
- df9530a: A length-less request body over its cap is answered with 413 promptly when the body was cloned first: through the transport codec lane, an idempotency `namespace` resolver, or a `c.req.clone()` read on a capped route.
|
|
724
|
+
- 3e6973f: The route manifest, project evidence snapshot, capability lockfile, and single-copy report sort by code unit instead of `localeCompare`, so their bytes and the manifest's `contentHash` no longer depend on the runtime's locale or ICU data. A manifest whose routes include paths that the two orders rank differently (mixed case, `-` beside `_`, non-ASCII) gets a new route order and `contentHash` once when it is rebuilt.
|
|
725
|
+
- 25e8edf: A request body decoded by `transportCodecs()` before routing is still held to the matched route's `bodyLimit` (and the server's body cap): one larger than the limit answers 413 like a JSON body would.
|
|
726
|
+
- 2b5e5fc: `responseContract()` holds a `c.json(...)` reply to the route's declared schema, as it does a returned value: `"enforce"` re-serializes the validated value with the reply's status and headers, and `"warn"` reports undeclared fields. In `"warn"` mode a `status(...)` result is now served with its own status and headers.
|
|
727
|
+
- da7d792: `diffRouteSnapshots`, and the `nifra diff`, contract proof, and manifest diffs built on it, compare the object schema around a section's fields as well as the fields. A change to `additionalProperties`, to the `$defs` a field's `$ref` points into, or to a required name that no field declares is reported as breaking, so a diff against an existing baseline may list changes it did not list before.
|
|
728
|
+
- fb14dfa: `DurableObjectRecordBackend.scan` returns every matching record when a reconciliation walk follows its cursor. A page that filled up now resumes after its own last record, where the cursor skipped the next matching record at each page boundary. `MemoryDurableRecordBackend.scan` sorts ids in the same code-unit order its cursor compares with, so mixed-case ids are no longer skipped either.
|
|
729
|
+
- 8ae97f6: The effect ledger, `useCapability`, `executeCapability`, effect lifecycle events, and the durable effect journal take a token field (`target`, `effectId`, `digest`, a capability id, an error code) only when it is a string of the token's shape. A row, a number, `null`, or an array in one of those fields is refused instead of stored, and a durable store record that holds one is refused when it is read.
|
|
730
|
+
- ca8b50d: On the Node direct lane, the header view a response hook receives answers only for real headers: `has("constructor")` is `false` and `get("constructor")` is `null`, as on a Web `Headers`, where it previously reported an inherited property or threw.
|
|
731
|
+
- b00a889: A body that a `derive` or `beforeHandle` hook reads through `c.req` stays readable for the framework readers that come after it. An auth-first route's body schema (`validationOrder: "auth-before-validation"`) and `c.boundedBody()` / `c.boundedJson()` now parse the bytes the hook read, for framed and chunked bodies on every adapter, where they answered 500.
|
|
732
|
+
- 66fd712: On an idempotent route, a request rejected before its handler runs (by an auth stage, validation, or a guard) no longer stores a result under its `Idempotency-Key`: the key is released and can be retried. Rejected requests therefore no longer fill the idempotency store.
|
|
733
|
+
- 738e7a1: `listen()` answers a request whose app-wide `onRequest`/`onResponse` hook throws with the same JSON `500 internal_error` a throwing route gets, and logs it through the app's logger. Bun's development error page, which shows the message, stack and source whenever `NODE_ENV` is not `production`, is no longer served.
|
|
734
|
+
- 4801cac: `diffNifraManifests` and `nifra manifest diff` diff a route that is new in the candidate manifest against an empty route. The capabilities it declares are reported as added and breaking, and a `pii` or `secret` response classification as an increase that is breaking, the same as when an existing route makes that change. A new route that declares no capability and returns public data is still only an added route.
|
|
735
|
+
- 25fe13d: A saga store refuses a `compareAndSet` whose `record.sagaId` differs from the `sagaId` it is written under, on `MemorySagaStore` and every durable execution adapter. `runDurableExecutionAdapterConformance` now checks that an adapter refuses it.
|
|
736
|
+
- 0852290: `sse()`'s `stream.signal` aborts when the runtime cancels the response body as well as when the request signal fires, so a producer looping on `stream.signal.aborted` stops once the client is gone on every runtime.
|
|
737
|
+
- 0589dbe: In development, when a hook reads the request body as a stream through `c.req.body`, the body readers after it - a body schema, `c.boundedJson()`, the handler's `c.req.json()` - fail with an error that says the body was read as a stream and to read it with `c.req.bytes()` in the hook, which later readers replay. Before, they failed on a locked stream or reported the body as invalid JSON. A production `NODE_ENV` build carries none of this check.
|
|
738
|
+
- 856f5ce: A request body or WebSocket frame decoded by a transport codec must be a tree. A value containing a cycle is answered as an invalid payload, and shared references may add at most 100,000 nodes to the tree they expand to. The `protoPoisoning` policy also applies to the members of a decoded `Map` or `Set`.
|
|
739
|
+
- cfd86b3: A 422 validation response lists at most the first 100 issues, and `t` schemas stop collecting issues at 100, so a large invalid body cannot produce a response many times its own size.
|
|
740
|
+
- 8ff96c9: `toFetchHandler` on Workers enforces `wsMaxPayloadBytes` on inbound WebSocket frames, as the Bun, Node, Deno and hub lanes do: a larger frame closes the socket with 1009.
|
|
741
|
+
- 4c46199: A WebSocket handshake resolves the `clientIp` trust declaration the way an HTTP request does, so `onRequest` hooks such as `ipRestriction()` and a route's `upgrade()` see the derived caller rather than the proxy's socket address.
|
|
742
|
+
- eef4932: A WebSocket route with a `messageSchema` parses each frame under the app's `protoPoisoning` policy, as a JSON body is parsed. Under the default `"reject"`, a frame with a `__proto__` key goes to `onInvalidMessage` as invalid JSON. `wrapWebSocketMessageValidation` takes the policy as an optional second argument.
|
|
743
|
+
- 6de8686: Only a GET request is treated as a WebSocket handshake. A POST or PUT that carries `Upgrade: websocket` reaches its HTTP route instead of the socket's `upgrade()` guard and a 426.
|
|
744
|
+
- d7892ea: fix(core): a request method is matched exactly. Method tokens are case-sensitive, so a request sent
|
|
745
|
+
as `post` or `Patch` does not reach a `POST` or `PATCH` route: it answers `405` with `Allow` when the
|
|
746
|
+
path has routes and `404` when it has none. `Router.find` from `@nifrajs/core/router` follows the
|
|
747
|
+
same rule.
|
|
748
|
+
|
|
749
|
+
A token no route can be registered under - anything other than uppercase letters, digits and
|
|
750
|
+
hyphens, a letter first, at most 32 characters - is answered by the route table alone. `onRequest`
|
|
751
|
+
hooks, handlers mounted with `mount` or `mountFetch`, and the WebSocket upgrade lane do not run for
|
|
752
|
+
it, so code that tests `request.method` never reads a token it would not recognise.
|
|
753
|
+
|
|
754
|
+
This is visible on a runtime that hands the token over as sent, such as Deno. Bun and Node refuse
|
|
755
|
+
such a request before it reaches the app.
|
|
756
|
+
|
|
757
|
+
- 4936309: perf(core,node): close the realistic-route Node gap to Fastify
|
|
758
|
+
|
|
759
|
+
The fused derive-before(-after) lifecycle lanes only had Web (`Response`-building)
|
|
760
|
+
renderers, so on the Node-direct lane a derive+before route fell back to the generic
|
|
761
|
+
route program while Bun rode the single closure. The same lanes now build a generic
|
|
762
|
+
(finalize/wrapResponse-parameterized) fused runner that the Node dispatcher prefers
|
|
763
|
+
exactly as it prefers the body-only runner - validate → derive → before → handler →
|
|
764
|
+
(after) in one frame, finalizing through the caller's own outcome renderer. Semantics
|
|
765
|
+
are stage-for-stage identical to the generic program (pinned by a Web-vs-Node-direct
|
|
766
|
+
parity suite); Bun's fused Web lanes are untouched.
|
|
767
|
+
|
|
768
|
+
`@nifrajs/node`'s JSON writer now emits lowercase `content-type`/`content-length`,
|
|
769
|
+
matching the other Node writer paths and the Web runtimes. Proven lowercase,
|
|
770
|
+
mutable records stay in place and receive framing headers without the old
|
|
771
|
+
rename-and-delete dictionary-mode transition; mixed-case, frozen, and explicitly
|
|
772
|
+
framed records keep the isolated normalization fallback.
|
|
773
|
+
|
|
774
|
+
Realistic-shape bench (oha, auth + security headers + CORS + request-id + cookie,
|
|
775
|
+
Node): GET 89% → 96% of Fastify, POST 94% → ~98%, body-hash 92% → 96% (each now at
|
|
776
|
+
or above the raw-`node:http` ceiling).
|
|
777
|
+
The Node adapter's parser-error drain guard keeps the same close-after-active-responses contract with a per-socket active counter and shared response-finish/close release listener, removing the per-request response Set while retaining close-only abort cleanup.
|
|
778
|
+
|
|
779
|
+
- bbdc5a1: fix(core, web): JSON request bodies are matched by media type
|
|
780
|
+
|
|
781
|
+
A body is parsed as JSON when its `Content-Type` media type is `application/json` or an
|
|
782
|
+
`application/*+json` type. A header that only mentions `application/json` in a parameter, such as
|
|
783
|
+
`text/plain; x=application/json`, is no longer parsed as JSON; browsers send that type cross-origin
|
|
784
|
+
without a preflight. Server functions apply the same comparison and answer such requests with 415.
|
|
785
|
+
|
|
786
|
+
- 43ba944: fix(core): `c.params` is typed by the names the router captures for a segment that mixes literal
|
|
787
|
+
text and parameters. `/files/:name.json` types `c.params.name`, `/v:major.:minor` types `major` and
|
|
788
|
+
`minor`, and `/b/:bucket.s3/*key` types `bucket` and `key`; each was previously typed as one
|
|
789
|
+
parameter named after the rest of the segment (`"name.json"`), so reading the real name did not
|
|
790
|
+
compile. A colon that is literal text, as in `/v1/things:batchGet`, no longer types a parameter.
|
|
791
|
+
Paths whose parameters are whole segments are typed as before.
|
|
792
|
+
- 46c741a: fix(core): a path segment that mixes literal text and parameters - `/:name.json`, `/v:major.:minor`,
|
|
793
|
+
`/:a-:b-:c.json` - is matched in one pass over the segment. The time a lookup takes grows with the
|
|
794
|
+
length of the segment and no longer with the number of parameters in it, for a hit and for a miss
|
|
795
|
+
alike. This holds for the server's router, `Router.find` from `@nifrajs/core/router`, and
|
|
796
|
+
`matchRoutePattern` from `@nifrajs/core/pattern`.
|
|
797
|
+
|
|
798
|
+
Which paths match and what each parameter captures are unchanged: a literal between two parameters
|
|
799
|
+
is taken at its first occurrence, a trailing literal ends the segment, and every parameter is at
|
|
800
|
+
least one character.
|
|
801
|
+
|
|
802
|
+
`@nifrajs/core/pattern` also exports the matcher itself: `mixedSegmentShape(parts)` lays a mixed
|
|
803
|
+
segment out once, and `matchMixedSegment(shape, segment, out)` appends the captures to `out` and
|
|
804
|
+
answers whether the segment matched.
|
|
805
|
+
|
|
806
|
+
- 4936309: perf(core): add opt-in Node-direct twin for `onResponseBody` hooks
|
|
807
|
+
|
|
808
|
+
`onResponseBody(fn)` took the portable hook everywhere: on the Node-direct lane it
|
|
809
|
+
ran through the shared native wrapper, allocating a header view per request even for
|
|
810
|
+
twins that only read the bytes and set a header. `onResponseBody(fn, node)` accepts
|
|
811
|
+
an optional `NodeResponseBodyHook` twin receiving the same serialized bytes plus the
|
|
812
|
+
outcome record, returning through the same replacement channel (`undefined` keeps the
|
|
813
|
+
body; bytes or `{ body, status }` are applied by the shared `applyBodyReplacement`).
|
|
814
|
+
The null-body skip, async continuations, and throw propagation are unchanged - the
|
|
815
|
+
twin only skips the view allocation - and Bun/Deno keep the portable hook untouched.
|
|
816
|
+
|
|
817
|
+
A `Middleware` may declare the twin as `onNodeResponseBody` next to `onResponseBody`
|
|
818
|
+
(a lone twin throws at registration, mirroring `onNodeResponseHeaders`).
|
|
819
|
+
|
|
820
|
+
- 6e257a6: On `@nifrajs/node`, a request under both a `mount()` prefix and a longer native `mountFetch()` prefix (a proxy) reaches the `mount()` first, as `app.fetch` does, so the composed app's own guards run.
|
|
821
|
+
- 6907cbe: fix(core): a request path is routed the same way on every runtime. A path with `.` or `..`
|
|
822
|
+
segments, written as-is or percent-encoded (`%2e`, in either case), or with a backslash, is
|
|
823
|
+
resolved the way a WHATWG URL parser resolves it before a route is chosen: `/users/../posts` and
|
|
824
|
+
`/users/%2e%2e/posts` are `/posts`, and `/users/..\posts` is `/posts` too.
|
|
825
|
+
|
|
826
|
+
Bun and workerd already hand an app the resolved URL. Now Deno, Bun's `listen()` route table and
|
|
827
|
+
`@nifrajs/node` route the same path, and `c.req.url` shows it. The same holds for a WebSocket
|
|
828
|
+
handshake. The query string is never touched, and neither is a dot inside a longer segment
|
|
829
|
+
(`/.well-known`, `/a..b`) or an encoded slash or backslash (`%2f`, `%5c`), which stay part of the
|
|
830
|
+
segment.
|
|
831
|
+
|
|
832
|
+
fix(node): the adapter resolves the request target before anything reads it, so static files,
|
|
833
|
+
mounts, the app and `c.req.url` all see the same path.
|
|
834
|
+
|
|
835
|
+
- bda9637: fix(core): a `Response` thrown after `c.set.cookie(...)` or `c.set.deleteCookie(...)` ships those
|
|
836
|
+
cookies, as the same `Response` returned does. A login handler or a guard that sets or clears a
|
|
837
|
+
session and then throws a redirect now sends the `Set-Cookie` on every runtime and lane, whether the
|
|
838
|
+
throw comes from the handler, `derive`, or `beforeHandle`. `c.set.headers` and `c.set.status` still
|
|
839
|
+
do not apply to a `Response`, thrown or returned, and a thrown `Response` still skips `onError`.
|
|
840
|
+
|
|
841
|
+
fix(core): queued cookies reach a `Response` whose headers are immutable, such as
|
|
842
|
+
`Response.redirect(...)` or a `fetch()` result on Node, Deno, and Workers, whether it is returned or
|
|
843
|
+
thrown.
|
|
844
|
+
|
|
845
|
+
- ff25d68: perf(core): an app that mounts nifra apps without WebSocket routes answers the WebSocket upgrade
|
|
846
|
+
check without reading the request's headers. `@nifrajs/deno` and `toFetchHandler` on Workers run that
|
|
847
|
+
check on every request, and Deno builds a request's headers only when they are read, so a plain
|
|
848
|
+
request to such an app no longer pays for them. A WebSocket route or a mount added later, at any
|
|
849
|
+
depth, is still seen.
|
|
850
|
+
|
|
851
|
+
On Bun, an app whose own WebSocket routes sit beside mounted apps without any keeps native pub/sub:
|
|
852
|
+
`ws.subscribe` and `app.publish` stay on Bun's own topic broadcast. While it does, a WebSocket route a
|
|
853
|
+
mounted app gains after `listen()` is not upgraded, since its sockets would share the server's
|
|
854
|
+
topics; the next `listen()` serves it.
|
|
855
|
+
|
|
856
|
+
## 3.5.0
|
|
857
|
+
|
|
858
|
+
### Patch Changes
|
|
859
|
+
|
|
860
|
+
- 6046984: Close fresh security and correctness gaps in table allowlists, ISR/cache behavior, streamed response capture, idempotency ownership, and canonical redirects.
|
|
861
|
+
- d5b7c22: Harden request boundaries, error handling, resource limits, signing, and cross-runtime adapters for safer production releases.
|
|
862
|
+
|
|
863
|
+
## 3.4.0
|
|
864
|
+
|
|
865
|
+
### Patch Changes
|
|
866
|
+
|
|
867
|
+
- 719d82e: Centralize release-facing evidence and certification seams so generated views, runtime adapters, and
|
|
868
|
+
consumer checks stay aligned.
|
|
869
|
+
|
|
870
|
+
## 3.3.0
|
|
871
|
+
|
|
872
|
+
## 3.2.0
|
|
873
|
+
|
|
874
|
+
### Patch Changes
|
|
875
|
+
|
|
876
|
+
- 8b58d1f: Improve the shared route lifecycle pipeline across Bun, Node, and Deno without changing the public
|
|
877
|
+
route, validation, response, or body-security contracts.
|
|
878
|
+
- 095c320: On Bun, `app.publish(topic, data)` now fans out through the runtime's native topic broadcast when no WebSocket route validates its outbound frames, so a broadcast reaches every subscriber without a per-connection loop. A route with `validateSend: true` keeps the portable per-socket path, since its frames are validated (and dropped when invalid) individually; other runtimes are unchanged.
|
|
879
|
+
- 7504864: Remove sideEffects entries that no longer resolve to a shipped module; the publish gate now verifies every sideEffects path against the packed tarball.
|
|
880
|
+
- e88c23a: Infer typed status responses across lifecycle hooks and contract-first routes, preserve precise
|
|
881
|
+
success and error narrowing in the client, and optionally include supported response types in
|
|
882
|
+
build-time OpenAPI output. Unsupported TypeScript types remain opaque and explicit runtime schemas
|
|
883
|
+
remain authoritative.
|
|
884
|
+
- c39712e: Move route policy compilation and deploy-target planning behind shared internal seams, pin the Solid adapter's Babel type identity, and add a cleanup-safe cross-runtime contract-lab runner.
|
|
885
|
+
- 9010fd3: Keep route-lane selection in a registration-time internal module so request dispatch continues to use the same compiled execution plans and adapter paths.
|
|
886
|
+
- ea2356e: Optimize routes with exactly one `derive`, `beforeHandle`, and `afterHandle` hook using a
|
|
887
|
+
registration-specialized lifecycle lane while preserving existing async, short-circuit, and error
|
|
888
|
+
semantics.
|
|
889
|
+
- a816b87: The Bun WebSocket message path now dispatches each frame without allocating a per-frame closure, bringing echo round-trip throughput to parity with the raw runtime. Error routing is unchanged: a synchronous throw or a rejected promise from a `message` handler still reaches `error()`.
|
|
890
|
+
|
|
891
|
+
## 3.1.0
|
|
892
|
+
|
|
893
|
+
### Minor Changes
|
|
894
|
+
|
|
895
|
+
- 1400f6c: Portable response header, body, and raw-response observation is now enabled explicitly with `responseObserver()` from `@nifrajs/core/response-observer`. Official middleware that uses these tiers remains compatible and enables the runtime automatically.
|
|
896
|
+
|
|
897
|
+
### Patch Changes
|
|
898
|
+
|
|
899
|
+
- 5b78473: New package `@nifrajs/edge`: a compact fetch-handler server for edge and serverless runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy, Bun). It keeps the `server().get().post()` DX and the full request trust boundary - bounded body read, Content-Length pre-reject, prototype-pollution guard, JSON / urlencoded framing - in a fraction of the bundle, and its rejection envelopes are byte-for-byte the full server's, so an app can graduate to `@nifrajs/core`'s `server()` without its clients noticing.
|
|
900
|
+
- a7db515: Frontend framework deduplication is now applied consistently across the Bun build, the Vite build, and the Vite dev server, so React, Preact, and Svelte each load a single copy in every client bundling path.
|
|
901
|
+
|
|
902
|
+
## 3.0.0
|
|
903
|
+
|
|
904
|
+
### Minor Changes
|
|
905
|
+
|
|
906
|
+
- f0fd370: The same-origin check behind `redirect()` and the guards' `redirectTo` now rejects the paths a URL parser resolves onto another origin, and lives in one place.
|
|
907
|
+
|
|
908
|
+
A leading `/` that is not `//` is not sufficient to keep a destination on this origin. Under a special scheme a backslash parses as a path separator, and tab, CR and LF are stripped from the input before parsing, so `/\evil.example` and `/<TAB>/evil.example` both pass a `//` test and then resolve to the host `evil.example` - an open redirect reachable from any unvalidated `?next=` parameter. Both forms are now refused: `redirect()` throws as it already did for `//host`, and an auth guard falls back to its configured destination instead of honouring the value.
|
|
909
|
+
|
|
910
|
+
New export `isSameOriginPath` from `@nifrajs/core/server`, which is the single implementation the three gates now share - a security predicate kept in three copies is three chances for one of them to be hardened alone. It answers about a path, so an absolute URL is false even when it names the current origin: the point of the gate is that the value never got to name a host at all.
|
|
911
|
+
|
|
912
|
+
A percent-encoded backslash (`/%5Cevil.example`) is still a same-origin path, because that is what it resolves to.
|
|
913
|
+
|
|
914
|
+
- 381bbf3: Route assurance gains a tiered `securityBaseline()` policy preset and two route selectors, `hasBody` and `bodyLimit`.
|
|
915
|
+
|
|
916
|
+
`securityBaseline({ level })` returns an `AssurancePolicy` built entirely from the public assurance engine, so it inherits fail-closed evaluation, evidence provenance, and selector validation. Its rules are ordered most-specific-first because evaluation is first-match-wins: each route is owned by one rule that carries the full requirement bundle for its class. Three levels, each a superset of the last:
|
|
917
|
+
|
|
918
|
+
- `"essential"` - never false-positives on a reasonable app: a body read must be bounded, an `unlimited` body may never claim `nifra.body-bounded`, an agent tool ingress must be bounded, and an authenticated state change must prove CSRF. Every requirement is either core-published from the route schema or demanded only where the route already opted into the risk.
|
|
919
|
+
- `"standard"` (default) - essential plus: a route the app classified `pii` or higher must be authenticated, read or write.
|
|
920
|
+
- `"strict"` - standard plus the opinionated requirements that need installed middleware: every route carries a response contract, every read carries security headers, every mutation is rate limited.
|
|
921
|
+
|
|
922
|
+
`unmatched` defaults to `"ignore"` for additive adoption; set `"error"` to close it into an allow-list. `requireRuntimeProvenance` defaults to `true` - an author label is not proof.
|
|
923
|
+
|
|
924
|
+
The new selectors let any policy match on body shape: `hasBody` matches routes that declare (or omit) a body schema, and `bodyLimit` distinguishes `"bounded"`, `"unlimited"`, and `"unset"`. Both are validated at `defineAssurancePolicy` time and refuse unknown values, so a typo cannot open a policy hole.
|
|
925
|
+
|
|
926
|
+
Drop the preset into a `nifra.assurance.ts` `policy`, or spread it and append project-specific rules ahead of the baseline so they own their routes first.
|
|
927
|
+
|
|
928
|
+
- 36801ae: New: a static `singleCopy` declaration that collapses an identity-sensitive package to one physical copy, for the duplicate no install can remove.
|
|
929
|
+
|
|
930
|
+
An app that consumes a package by `link:` from a **separate checkout** cannot deduplicate React (or `@nifrajs/*`) by installing differently. The linked files live in the other repo, so their imports resolve from that repo's real path, and that repo's install owns its `node_modules`: peer dependencies are already satisfied there, `overrides` govern the consuming install only, and a deleted nested copy returns on the sibling's next install. The build's existing per-framework dedupe covers bundled output, but nothing covered `bun test`, `bun run`, or a preloaded script - and `nifra check` failed the app with remediation ("deduplicate the install") that the topology makes impossible.
|
|
931
|
+
|
|
932
|
+
An app now declares the packages in its own `package.json`:
|
|
933
|
+
|
|
934
|
+
```json
|
|
935
|
+
{ "nifra": { "singleCopy": ["react", "react-dom", "@nifrajs/*"] } }
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
Entries are exact names or `@scope/*` patterns; `true` expands to the built-in identity-sensitive set (`@nifrajs/*`, `react`, `react-dom`, `preact`, `solid-js`, `svelte`, `vue`). `@nifrajs/*` is in that set because two copies of `@nifrajs/core` are two distinct `Server` classes, so `.merge()` stops accepting an app built against the other one. The declaration is static so `nifra check` can read it without importing the app's config, which would mean executing app code inside a preflight.
|
|
939
|
+
|
|
940
|
+
`buildClient` and `buildServer` inject the resolver from the declaration, so bundled output needs no wiring. Unbundled phases are not covered automatically - Bun's runtime resolver never offers a bare specifier to a plugin - so an app preloads `@nifrajs/core/single-copy/register` from `bunfig.toml` (`preload` for `bun run`, `[test].preload` for `bun test`). `nifra check` now names the phase that is left uncovered when the declaration exists without the preload.
|
|
941
|
+
|
|
942
|
+
The redirect refuses to cross versions: two copies at different versions are skipped as `version-skew` and stay fatal, because collapsing them would turn a loud install problem into a quiet behavioural one. A declared duplicate is reported, not suppressed - `nifra check` keeps printing the copies as a warning and `nifra doctor` lists them under `deduplicatedInstalls` - so the topology stays visible without failing the gate.
|
|
943
|
+
|
|
944
|
+
New exports: `@nifrajs/core/single-copy` (`singleCopyPlugin`, `registerSingleCopy`, `planSingleCopy`, `readSingleCopyDeclaration`, `readSingleCopyRegistration`, `matchesSingleCopyDeclaration`, `IDENTITY_SENSITIVE_PACKAGES`) and the side-effect entry `@nifrajs/core/single-copy/register`.
|
|
945
|
+
|
|
946
|
+
The registration proof reads `preload` as entries rather than as text: an entry counts only when it **is** the register specifier, not when it merely contains it. A neighbouring path such as `"./vendor/@nifrajs/core/single-copy/register-shim.ts"` used to satisfy the check while Bun loaded that other module and the registrar never ran, so enforcement was reported as armed on a process still loading both copies. A `preload` this cannot read as quoted entries - a multi-line array, an interpolated value - now reports "not registered" instead of standing in for proof.
|
|
947
|
+
|
|
948
|
+
- 9acadba: New `status(code, body?, init?)`: end a request from anywhere in the lifecycle without building a `Response`. Every error the framework renders itself now takes the same lane.
|
|
949
|
+
|
|
950
|
+
```ts
|
|
951
|
+
import { server, status } from "@nifrajs/core";
|
|
952
|
+
|
|
953
|
+
app.derive((c) => {
|
|
954
|
+
const user = sessionOf(c);
|
|
955
|
+
if (user === undefined)
|
|
956
|
+
return status(401, { ok: false, error: "unauthorized" });
|
|
957
|
+
return { user };
|
|
958
|
+
});
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
A `beforeHandle` could always short-circuit by returning a value, but a `derive`'s return **is** the context extension, so its only early exit was `throw new Response(...)` - the most expensive way to say 401. It is three costs stacked: constructing a Web `Response`, throwing it, and unwinding a lifecycle stage. On Node there is a fourth, because a `Response` built outside the handler is opaque to the adapter, so its body is drained back out through a Web stream and the reply goes out chunked instead of with a `content-length`.
|
|
962
|
+
|
|
963
|
+
`status(...)` is plain data - a status, optional headers, and a body the rendering lane serializes exactly like a handler's plain return - so a rejection now costs what an accepted request costs. It can be returned (preferred) or thrown, so a guard helper called for effect (`requireSession(c)`) can still end the request from inside a call it makes.
|
|
964
|
+
|
|
965
|
+
Measured on the Linux rig (4 server cores, 50 connections, medians; a rejecting `derive` vs the same `derive` returning `status(...)`, so both exit at the same point in the lifecycle):
|
|
966
|
+
|
|
967
|
+
| runtime | `throw new Response` | `status(...)` |
|
|
968
|
+
| ------- | -------------------- | ------------- |
|
|
969
|
+
| node | 39974 | 68577 |
|
|
970
|
+
| deno | 58403 | 89742 |
|
|
971
|
+
| bun | 79757 | 109328 |
|
|
972
|
+
|
|
973
|
+
The gap is largest on Node because of the drain, but the lifecycle cost was never runtime-specific: throwing to leave a `derive` cost every runtime real throughput.
|
|
974
|
+
|
|
975
|
+
The same render now serves the errors an application cannot move onto a faster lane itself - 404, 405, 400 on a malformed path, 415, 422 on a validation failure, 500, 504, and the admission rejections. Bytes are unchanged on every lane, and the Node lane gains a `content-length` it did not have. `Response` stays exactly what it was for everything that genuinely needs one: redirects, streams, and any handler that returns or throws one.
|
|
976
|
+
|
|
977
|
+
### Patch Changes
|
|
978
|
+
|
|
979
|
+
- f3d2a35: The body cap's 413/400 rejections are plain renders instead of `Response` objects.
|
|
980
|
+
|
|
981
|
+
`c.boundedBody()` and `c.boundedJson()` throw on an over-cap or malformed body, and the lifecycle catches that throw as control flow - the same contract as `throw redirect(...)`. What they threw was a built `Response`; it is now the same plain-data value `status(...)` produces, so a rejected request no longer pays for a `Response` on the way out. Identical on the wire: same status, same `{ ok: false, error }` body, same `content-type`.
|
|
982
|
+
|
|
983
|
+
The schema lane's own body read goes the same way, so a route with a `body` schema answers its 413/400 from plain data too.
|
|
984
|
+
|
|
985
|
+
The distinction is invisible to a handler that lets the throw propagate, which is the documented use. Code that catches it and inspects the value is affected: it is no longer a `Response`, so `.status` and `instanceof Response` are gone. Read `.plain` (`{ status, headers, body }`) or call `toResponse()`.
|
|
986
|
+
|
|
987
|
+
The urlencoded form reader takes the same lane, so a route whose schema parses a form answers its 413 and its 400 from plain data too, with the same envelope and the same bytes as the JSON reader's.
|
|
988
|
+
|
|
989
|
+
`jsonError` is unchanged and still exported - the surfaces that genuinely need a `Response` object keep it.
|
|
990
|
+
|
|
991
|
+
- 6e43c15: A failed boundary load no longer publishes the thrown error's own message. Boundary states are serialized into the document, so a driver or fetch failure was putting hosts, credentials, and query text on the page for every visitor who loaded it while the dependency was down. The client slot is now always `Boundary failed`, an `Error` subclass name is withheld for the same reason it names the failing internal library, and the real error is reported to the server console - the split already used for a rejected deferred value. A boundary that wants to show the user something specific catches its own failure inside `load` and returns that as data.
|
|
992
|
+
|
|
993
|
+
Opt-in outbound WebSocket validation (`validateSend`) contains a rejected diagnostic instead of recursing. An `error()` handler that answers a dropped frame by sending one of its own re-entered the reporter through the two paths that bypassed its guard - schema rejection and an async validator - and unwound only when the stack was exhausted, silently. Every failure path now reports through the guard.
|
|
994
|
+
|
|
995
|
+
A content index cursor is checked for the query that issued it separately from running off the end: a cursor from a different sort or filter is rejected at any position, and a matching cursor left past the last row by an index that shrank between pages returns an exhausted page rather than throwing.
|
|
996
|
+
|
|
997
|
+
- f0fd370: A plain render that Node refuses to write now answers 500 and leaves the server serving, and `status()` rejects an out-of-range code where it is written.
|
|
998
|
+
|
|
999
|
+
The plain lane goes straight to the socket, so a header value never passes through the `Headers` constructor that rejects CR/LF on the Web lane. Node rejects it at `writeHead` instead - as it does an invalid status, or any write after the head is already out. On the synchronous lane that throw escaped the request; on the asynchronous ones it surfaced as an unhandled rejection, which by Node's default terminates the process, so an application reflecting request data into `c.set.headers` or `status(...)` had a route-shaped input that could take the server down. Every write is now contained: an unsent head becomes the ordinary flat 500, and a head already on the wire ends the connection, because a status line cannot be recalled and a half-written body must not be left for the client to parse.
|
|
1000
|
+
|
|
1001
|
+
`status(code, ...)` now throws a `RangeError` for a code outside 200-599 or a non-integer. A plain render carries its status to `writeHead` unexamined, so an out-of-range value used to fail at the socket, far from the handler that produced it.
|
|
1002
|
+
|
|
1003
|
+
- 86a555b: The roadmap contract surfaces are now shipped across the public packages: shared island triggers,
|
|
1004
|
+
typed content indexes and joins, client loader/action hooks, and unified static, dynamic, and
|
|
1005
|
+
intercepting boundary modes. WebSocket routes also support opt-in synchronous outbound validation
|
|
1006
|
+
through `sendSchema` + `validateSend`; invalid or asynchronous outbound frames fail closed while the
|
|
1007
|
+
default remains type-level only.
|
|
1008
|
+
- 8c5f4cf: A route that declares an explicit finite `schema.bodyLimit` now publishes `nifra.body-bounded` route assurance evidence (source `route-schema`), the same as a route with a `schema.body`. Before this, the per-route transport cap bounded a body more strongly than the Content-Length `bodyLimit()` middleware - it also binds a raw `c.req.body` stream, which a Content-Length gate cannot - yet published nothing, so adopting the per-route cap could lower an app's L1 assurance level. `bodyLimit: "unlimited"` still publishes nothing, and the server-wide `maxBodyBytes` default is deliberately not evidence: it applies to every route whether or not anyone chose it.
|
|
1009
|
+
- 99fc683: A thrown `status(...)` now costs close to what a returned one costs, and the guards throw one.
|
|
1010
|
+
|
|
1011
|
+
`status(...)` is meant to be **returned** - from a `beforeHandle`, from a `derive`, from the handler. The one place a return cannot work is a helper called for effect: `requireSession(c)` decides the request is over from inside a call the handler makes, and only unwinding gets out of a half-finished handler. That is the whole remaining use of `throw`, and it now runs on the same lane as the return:
|
|
1012
|
+
|
|
1013
|
+
- The lifecycle error path is synchronous again. It was `async`, so a thrown `status(...)` - which needs no `await` at all - still allocated a promise and resumed a microtask later. The `onError` hook loop moved to its own async method, so only routes that registered a hook pay for one.
|
|
1014
|
+
- The two remaining sites that turned a thrown `status(...)` into a `Response` before rendering it (the bare and contextless fast paths) now render it as the plain data it is, through the request's own finalizer.
|
|
1015
|
+
- `requireSession` / `requireUser` / `requireAuthorization` throw a `status(...)` render instead of building a `Response`. The bytes on the wire are unchanged - a 401 JSON envelope, or a 302 with a `location` - and on Node they now carry a `content-length` instead of being drained back out as a stream.
|
|
1016
|
+
|
|
1017
|
+
**Behavior change:** what the guards throw is a `status(...)` render, not a `Response`. Code that catches a guard and tests `err instanceof Response` needs to stop doing that; nifra itself treats the two identically as control flow, so a guard thrown through a route behaves exactly as before.
|
|
1018
|
+
|
|
1019
|
+
Measured on the Linux rig (4 server cores, 50 connections, medians of 5 x 2s; a `derive` that rejects, returning vs throwing the same `status(401, ...)`). Only within-runtime deltas are readable - the host was under other load, and the return arm is included in both columns as the control:
|
|
1020
|
+
|
|
1021
|
+
| runtime | return | throw, before | throw, after |
|
|
1022
|
+
| ------- | ------ | ------------- | ------------ |
|
|
1023
|
+
| node | 85904 | 74471 | 80136 |
|
|
1024
|
+
| deno | 139477 | 85970 | 90355 |
|
|
1025
|
+
|
|
1026
|
+
On Node a throw now lands within the run's own spread of a return. On Deno it does not: a throw still costs ~35%, and it costs the same whether the thrown value is a `status(...)` render or a `Response` - so what remains there is the unwind itself, not the rendering.
|
|
1027
|
+
|
|
1028
|
+
That remainder is the runtime's, not the framework's. The same arms with no framework at all - a bare `Deno.serve` / `Bun.serve` / `node:http` handler answering identical bytes, one returning its payload and one throwing it - reproduce it, in CPU-microseconds per request:
|
|
1029
|
+
|
|
1030
|
+
| | return | throw | throw across await |
|
|
1031
|
+
| ---- | ------ | ----- | ------------------ |
|
|
1032
|
+
| node | 49.35 | +3.85 | +2.85 |
|
|
1033
|
+
| bun | 22.55 | +1.83 | +3.99 |
|
|
1034
|
+
| deno | 18.89 | +7.62 | +11.03 |
|
|
1035
|
+
|
|
1036
|
+
Against that, nifra's own rejection arms cost +3.35 (node), +3.41 (bun), +15.59 (deno). On node and bun the framework's entire remaining throw penalty _is_ the runtime's throw. On deno the runtime's throw-across-await alone accounts for ~70% of it, and the percentage reads worse than the others only because deno's baseline request is the cheapest of the three. Two candidate explanations were tested and ruled out: `Error.stackTraceLimit = 0` changes nothing (so it is not stack capture), and throwing three frames below the handler is no cheaper (so it is not proximity to the runtime boundary).
|
|
1037
|
+
|
|
1038
|
+
- 73d894d: `bodyLimit: "unlimited"` now actually skips the cap on every read path, not only on direct `c.req` reads. A route that declared the exemption alongside a `schema.body` (or that called `c.boundedJson()` / `c.boundedBody()` with no explicit cap) silently fell back to the server-wide `maxBodyBytes` and answered 413 at a bound the route had explicitly opted out of. The resolved route limit is now read through `UNLIMITED_BODY_BYTES` instead of `?? maxBodyBytes`, so `undefined` keeps meaning "the route opted out" everywhere rather than flipping to "use the default" at the reader. Routes that never declared `bodyLimit` are unaffected: their limit is resolved to `maxBodyBytes` at registration, exactly as before.
|
|
1039
|
+
|
|
1040
|
+
An unlimited route no longer publishes the `nifra.body-bounded` assurance id. It used to earn the id from its `body` schema or from a declared numeric limit that `"unlimited"` then overrode, so the one route in an application with no ceiling at all was the one certifying that its body was bounded - and a policy built on that id passed exactly where it should have failed. The exemption is documented as covering the transport cap only: a route that pairs it with a `body` schema, or with `idempotency`, still assembles every delivered byte in memory before anything examines them, so an unlimited body belongs on `c.req.body` as a stream, bounded by the protocol it speaks.
|
|
1041
|
+
|
|
1042
|
+
## 2.14.1
|
|
1043
|
+
|
|
1044
|
+
### Patch Changes
|
|
1045
|
+
|
|
1046
|
+
- bf93902: The in-memory durable stores (`MemoryDurableEffectStore`, `MemoryApprovalStore`, `MemorySagaStore`) no longer perform a redundant deep clone when persisting a state transition. Stored records are already fresh and frozen, and reads still hand back isolated copies, so behaviour is unchanged while each transition allocates less.
|
|
1047
|
+
|
|
1048
|
+
## 2.14.0
|
|
1049
|
+
|
|
1050
|
+
### Minor Changes
|
|
1051
|
+
|
|
1052
|
+
- 701961a: New `raw<T>(response)` escape hatch keeps a hand-built `Response` inside the typed contract. A route
|
|
1053
|
+
that returns a bare `Response` - a Server-Sent Events stream, a file download, a signed token payload -
|
|
1054
|
+
previously inferred `res.data: never` on the client. Wrap the return as `raw<T>(response)` and the
|
|
1055
|
+
client sees `res.data` as `Jsonify<T>` while the route still ships the exact `Response` at runtime.
|
|
1056
|
+
Branded binary responses from `bytes()` keep their `Blob` typing; only an unbranded `Response` return
|
|
1057
|
+
falls back to `never`.
|
|
1058
|
+
- 62133bf: Order-scoped hooks (`derive`, `decorate`, `beforeHandle`, `afterHandle`, `around`, `aroundCapability`,
|
|
1059
|
+
`onError`) are snapshotted into each route as it is declared, so one added after the last route was
|
|
1060
|
+
registered silently applies to nothing. This is the trap a route-registering factory sets: the app it
|
|
1061
|
+
returns has already declared its routes, so `app.use(requestId())` on it reaches zero of them.
|
|
1062
|
+
|
|
1063
|
+
The server now detects this at seal - the first `listen()`, `fetch`, or `resolveNode` - and reports
|
|
1064
|
+
every order-scoped hook that covers no route, naming the hook kind and the call site so the fix (move
|
|
1065
|
+
it before the routes it should cover) is obvious. A previously-silent mistake now logs once at startup.
|
|
1066
|
+
|
|
1067
|
+
New `unusedScopedHooks` server option: `"warn"` (default) logs the report, `"error"` throws a
|
|
1068
|
+
`FrameworkError` (`UNUSED_SCOPED_HOOKS`), `"off"` skips the check and its bookkeeping entirely. The
|
|
1069
|
+
report routes through a configured `logger`, else `console.warn`. Legitimate group scoping - a hook
|
|
1070
|
+
that intentionally covers only the routes declared after it - is never flagged, and app-global hooks
|
|
1071
|
+
(`onRequest`/`onResponse`/...) are unaffected because they are not order-scoped.
|
|
1072
|
+
|
|
1073
|
+
The audit is a development-time diagnostic: it is guarded by `process.env.NODE_ENV !== "production"`, so
|
|
1074
|
+
any bundler that defines `NODE_ENV` strips it entirely from production builds - it adds zero bytes to
|
|
1075
|
+
the shipped bundle and zero per-request cost. It fires in development, test, and CI, where the mistake
|
|
1076
|
+
is caught, not in production.
|
|
1077
|
+
|
|
1078
|
+
- 8dffdf4: `.use(plugin)` no longer silently collapses the app's typed route registry to `any` when a plugin's own
|
|
1079
|
+
types are unpinned. A plugin whose parameter and return infer as `Server<any, any>` - an auth or router
|
|
1080
|
+
plugin that widened - now makes `.use()` return the non-callable `PluginTypeCollapsed` marker at the
|
|
1081
|
+
call site, rather than an `any` that only surfaces hundreds of lines away as `never`/`any` in the typed
|
|
1082
|
+
client. Build the plugin with `defineIdentityPlugin`/`defineContextPlugin`, or pin its input server
|
|
1083
|
+
type, and it threads the caller's registry and context unchanged.
|
|
1084
|
+
|
|
1085
|
+
`serverFunctions()` now ships as such an identity plugin, so `app.use(serverFunctions(...))` keeps every
|
|
1086
|
+
route declared before and after it fully typed.
|
|
1087
|
+
|
|
1088
|
+
## 2.13.0
|
|
1089
|
+
|
|
1090
|
+
### Patch Changes
|
|
1091
|
+
|
|
1092
|
+
- e0b2dd6: Harden three regex-adjacent input paths against pathological input. The byte-range parser bounds an
|
|
1093
|
+
oversized `Range` header before the matcher runs rather than after; the problem-details type builder
|
|
1094
|
+
strips trailing slashes with a linear scan instead of a backtracking pattern; and the dev SSR
|
|
1095
|
+
import-graph specifier pattern no longer has an ambiguous whitespace group. Behavior is unchanged for
|
|
1096
|
+
valid input.
|
|
1097
|
+
- 7535ce1: A direct body read on Node - `c.req.json()`, `c.req.text()`, `c.req.arrayBuffer()`, `c.req.bytes()`
|
|
1098
|
+
on a raw-body route - now reads straight off the socket instead of first building the Web `Request`
|
|
1099
|
+
the adapter had been deferring. The body cap is unchanged and still enforced by the same bounded
|
|
1100
|
+
reader: an over-cap `Content-Length` is rejected before buffering, a chunked body is still aborted
|
|
1101
|
+
mid-stream rather than buffered first, `clone()` inherits the cap, and `c.boundedBody(explicit)`
|
|
1102
|
+
still overrides it in either direction. `c.req` keeps its identity and every other member behaves
|
|
1103
|
+
as before. Net: a raw-body `POST` that reads through `c.req` gets a large throughput gain - roughly
|
|
1104
|
+
+65% on the JSON-body workload in the Bun HTTP framework benchmark on Node - and is no longer the
|
|
1105
|
+
slowest lane in a nifra app.
|
|
1106
|
+
- 1704308: On Node, `c.text(...)` and `c.json(...)` now defer building the Web `Response`. The adapter writes a
|
|
1107
|
+
text or JSON body to the socket from a status, a header record, and the bytes directly, so the
|
|
1108
|
+
`Response` those helpers used to construct up front - about a quarter of the request budget on a
|
|
1109
|
+
small response - is built only if something actually reads the Web surface (a response hook,
|
|
1110
|
+
`app.fetch`, or user code touching `.headers`), and forwarded to from then on. The returned value is
|
|
1111
|
+
still a real `Response`: `instanceof Response` holds, the status, headers, and body are unchanged,
|
|
1112
|
+
and the content-type is byte-identical to what the eager `Response` carried. Bun and Deno, which hand
|
|
1113
|
+
the `Response` to their native server, are unaffected. Net: a plain text or JSON return on Node lands
|
|
1114
|
+
on the adapter's fastest write lane - on the Bun HTTP framework benchmark the text workloads (Ping,
|
|
1115
|
+
Query) go from roughly 0.75x of Fastify to level with it, and clear of Hono.
|
|
1116
|
+
|
|
1117
|
+
## 2.12.1
|
|
1118
|
+
|
|
1119
|
+
### Patch Changes
|
|
1120
|
+
|
|
1121
|
+
- fba30c7: A route that validates its body no longer arms the direct-read body cap on `c.req`. That lane
|
|
1122
|
+
already reads and bounds the body - at the route's own limit - before any derive, hook, or handler
|
|
1123
|
+
runs, so on a validated route the extra per-request cap only ever guarded a body that was already
|
|
1124
|
+
consumed. Dropping it removes a per-request write on the request object that, on V8, reshaped it
|
|
1125
|
+
every request and fed avoidable GC churn. Raw-body routes (no body schema), where a direct `c.req`
|
|
1126
|
+
read is the only body boundary, keep the cap exactly as before. Net: lower per-request allocation
|
|
1127
|
+
and a markedly tighter tail on validated `POST`/`PUT` routes, most visible on Deno.
|
|
1128
|
+
|
|
1129
|
+
## 2.12.0
|
|
1130
|
+
|
|
1131
|
+
### Minor Changes
|
|
1132
|
+
|
|
1133
|
+
- df100d3: Route-assurance rules can distinguish enforcement from assertion, and can select routes by data
|
|
1134
|
+
sensitivity.
|
|
1135
|
+
|
|
1136
|
+
- **`requireProvenance`** on a rule: `"any"` (default, unchanged) accepts every evidence entry;
|
|
1137
|
+
`"runtime"` accepts only evidence installed by middleware, a plugin, or framework runtime policy
|
|
1138
|
+
and rejects an author's inline `schema.assurance` claim; `"declared"` is the inverse, useful for
|
|
1139
|
+
reviewing what handlers assert about themselves. Provenance is carried non-enumerably on each
|
|
1140
|
+
evidence entry, so existing reflected route descriptors keep their exact serialized shape.
|
|
1141
|
+
- **`requireCsrfWithAuthenticated`** on a rule: an authenticated route selected by that rule must
|
|
1142
|
+
also carry runtime CSRF evidence. Intended for rules covering cookie- or session-authenticated
|
|
1143
|
+
browser routes; bearer-only APIs have no ambient-authority exposure and belong in their own rule.
|
|
1144
|
+
- **`match.classificationAtLeast`**: select routes whose declared response classification is at
|
|
1145
|
+
least `"public"`, `"pii"`, or `"secret"`, so a policy can demand more of the routes that carry
|
|
1146
|
+
more.
|
|
1147
|
+
|
|
1148
|
+
A failure message names the required provenance when it is not `"any"`, so the report says which
|
|
1149
|
+
kind of evidence is missing rather than only which id.
|
|
1150
|
+
|
|
1151
|
+
- 0efacea: Add a generic server `onStop` lifecycle hook and have OTLP tracing exporters flush and shut down automatically when attached to a server. Manual OTLP lifecycle calls remain available for standalone exporters.
|
|
1152
|
+
- df100d3: Canonical project evidence: a single reflected snapshot of a project's routes, schemas, assurance,
|
|
1153
|
+
and capabilities, exported as `@nifrajs/core/evidence` (`snapshotProjectEvidence`). Tools that used
|
|
1154
|
+
to reflect the app a second time now project from the snapshot instead, so the manifest, the check
|
|
1155
|
+
report, and introspection cannot disagree about what the app declares.
|
|
1156
|
+
|
|
1157
|
+
`createManifest` accepts the snapshot as `evidence` and skips its own reflection when given one. It
|
|
1158
|
+
refuses to emit a manifest whose route-assurance or capability evidence is failing, so a manifest is
|
|
1159
|
+
never a record of a project that does not pass its own gates. The previous `source` input still
|
|
1160
|
+
works; one of the two is required.
|
|
1161
|
+
|
|
1162
|
+
- b5f47c0: `__Secure-` and `__Host-` cookie name prefixes (RFC 6265bis) are now enforced, matched
|
|
1163
|
+
case-insensitively the way browsers match them. `serializeCookie` throws on a `Set-Cookie` that
|
|
1164
|
+
violates its name's prefix contract - `__Secure-` requires `Secure`; `__Host-` requires `Secure`
|
|
1165
|
+
and `Path=/` and forbids `Domain` - instead of emitting a cookie the user agent silently discards.
|
|
1166
|
+
`c.set.cookie`'s secure defaults already satisfy both contracts, so prefixed names work with zero
|
|
1167
|
+
configuration, and `c.set.deleteCookie` applies `Secure` to the deletion write for a prefixed name
|
|
1168
|
+
so the browser accepts the deletion (the failure mode behind Hono's CVE-2026-39410 class: a
|
|
1169
|
+
non-conforming deletion leaves the cookie alive after logout). The new `cookieNamePrefix(name)`
|
|
1170
|
+
export classifies a name as `"secure"`, `"host"`, or unprefixed. `@nifrajs/i18n`'s `localeDetector`
|
|
1171
|
+
applies `Secure` automatically when its persist cookie name carries a prefix.
|
|
1172
|
+
- fc33c0f: The server type records which copy of `@nifrajs/core` declared it. `Server` carries a type-only
|
|
1173
|
+
`__nifraCoreVersion` brand holding the package's feature version (`major.minor`), exported as
|
|
1174
|
+
`NifraFeatureVersion`, so a hover over `typeof app` - or an assertion in a test - says which core an
|
|
1175
|
+
app is built against. Nothing is emitted at runtime and no field is allocated per server.
|
|
1176
|
+
|
|
1177
|
+
Two copies of core in one build stay a compile error, as before; `nifra doctor` is what names the
|
|
1178
|
+
two install paths.
|
|
1179
|
+
|
|
1180
|
+
- c4e8bb0: New `errorLogDetail` server option controls how much of an unhandled error reaches the log for a 500:
|
|
1181
|
+
`"full"` (the default, unchanged - name, the error's own text as `detail`, and `stack`), `"message"`
|
|
1182
|
+
(no stack), or `"none"` (name only). None of it ever reaches the client; a 500 response is a bare
|
|
1183
|
+
`internal_error` either way. An error's text can quote the input that produced it, so an app whose log
|
|
1184
|
+
sink is outside its trust boundary can now narrow what is recorded - the sharper instrument stays the
|
|
1185
|
+
redacting logger (`jsonLogger({ valuePatterns: commonSecretPatterns })`), which scrubs tokens and
|
|
1186
|
+
emails out of `detail` and `stack` while keeping the diagnosis intact.
|
|
1187
|
+
- 11d1658: `assure(app, evidence)` from `@nifrajs/core/assurance` publishes enforcement evidence from outside
|
|
1188
|
+
the plugin chain. When the thing enforcing a control is not a nifra plugin - an edge gateway, a
|
|
1189
|
+
service mesh, an outer framework that owns the shell wrapping the app - the assurance policy had no
|
|
1190
|
+
way to see it, and the only way to keep the app green was switching the affected rules off. The
|
|
1191
|
+
evidence can now be attached at the mount site or immediately before `serve`, after every route is
|
|
1192
|
+
registered:
|
|
1193
|
+
|
|
1194
|
+
```ts
|
|
1195
|
+
import { assure, NIFRA_ASSURANCE } from "@nifrajs/core/assurance";
|
|
1196
|
+
|
|
1197
|
+
const app = buildApp();
|
|
1198
|
+
assure(app, { id: NIFRA_ASSURANCE.AUTHENTICATED, source: "edge-gateway" });
|
|
1199
|
+
serve(app);
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
`scope` defaults to `global`, so the evidence applies to every route already registered; narrow it
|
|
1203
|
+
with `methods` and `paths` (absolute globs), or pass `scope: "subsequent"` to cover only routes
|
|
1204
|
+
registered after the call. Invalid evidence ids and `scope: "plugin"` fail closed at the call.
|
|
1205
|
+
|
|
1206
|
+
Attached evidence always carries `declared` provenance - nifra did not install the enforcement and
|
|
1207
|
+
cannot observe it - so a rule with `requireProvenance: "runtime"` still rejects the route. Evidence
|
|
1208
|
+
declared through `withRouteAssurance` now carries the provenance it was given rather than always
|
|
1209
|
+
reporting `runtime`.
|
|
1210
|
+
|
|
1211
|
+
It lives on the `assurance` subpath and applies through the ordinary middleware seam, so an app that
|
|
1212
|
+
never calls it carries none of this: the bare server bundle is unchanged.
|
|
1213
|
+
|
|
1214
|
+
- 8847825: Add `app.mountFetch()` for mounting an arbitrary fetch handler below a path prefix, with optional prefix stripping and platform argument forwarding. Mounted handlers are explicitly outside typed response-contract enforcement.
|
|
1215
|
+
- 9a9346e: `merge()` now scopes a group's `onRequest` hooks to the group's own routes, following the same locality rule its `derive`/`beforeHandle` chains already obey: a `bodyLimit()` mounted on an uploads group no longer starts gating every route of the app that composes it in. The guard is a single route probe against a snapshot of the group's catalog, so requests outside the group pay one lookup and pass untouched; the group's Node-native hook twins are scoped the same way. Global assurance declared by a group's middleware follows the enforcement - it is folded onto exactly the merged routes, so `routes()` never claims a group's protection for parent routes its hooks do not see.
|
|
1216
|
+
|
|
1217
|
+
A group with hooks but no routes is a middleware bundle: its hooks can only mean app-wide intent, so they are still appended globally, unchanged. Apps that relied on a route-carrying group's hooks running app-wide should register that middleware on the parent server with `.use()`.
|
|
1218
|
+
|
|
1219
|
+
- 9a9346e: `app.use(plugin)` keeps the caller's server type. A plugin built with `definePlugin` whose input
|
|
1220
|
+
server type is not pinned used to widen the app to `Server<any, any>`, so every route declared
|
|
1221
|
+
before _and_ after the `use` lost its types and the typed client silently degraded to `any`. That
|
|
1222
|
+
case is now a compile error at the `use` call site, naming the definer to switch to; the plugin is
|
|
1223
|
+
unchanged at runtime.
|
|
1224
|
+
|
|
1225
|
+
Pick the definer that matches what the plugin does: `defineContextPlugin<D>` when it adds context
|
|
1226
|
+
via `derive`/`decorate` (the registry threads through and `D` is added to every downstream handler
|
|
1227
|
+
context), `defineRouterPlugin` when it mounts routes/hooks and adds no context (mount as a side
|
|
1228
|
+
effect, return the app). `definePlugin` still works when its input type is pinned - annotate the
|
|
1229
|
+
parameter (`(app: typeof api) => ...`) or pass explicit type arguments.
|
|
1230
|
+
|
|
1231
|
+
Every first-party plugin now threads: `jwt`, `tokenAuth`, `basicAuth`, `durableCommand`, `etag`,
|
|
1232
|
+
`compression`, `problemDetails`, `prettyJson`, `methodOverride`, `trailingSlash`, `cacheControl`,
|
|
1233
|
+
`devtools`, and `metrics` return an `IdentityPlugin`; `timing`, `language`, and `tracing` return a
|
|
1234
|
+
`ContextPlugin` of what they add (`{ timing }`, `{ language, languageMatch }`, and
|
|
1235
|
+
`{ trace, observation, causality }` respectively), so `c.timing` / `c.language` / `c.trace` are
|
|
1236
|
+
typed without a manual annotation. `combine(...)` is typed as an identity bundle and
|
|
1237
|
+
`namedCombine(name, ...)` is its deduped, named form.
|
|
1238
|
+
|
|
1239
|
+
A type-level test asserts the threading for each definer shape, so a regression fails `typecheck`
|
|
1240
|
+
rather than surfacing as `any` in a downstream app.
|
|
1241
|
+
|
|
1242
|
+
- b045f9e: JSON bodies are now guarded against prototype poisoning. A payload carrying an own `__proto__`
|
|
1243
|
+
key, or a `constructor` whose value carries a `prototype`, is rejected with the same flat `400`
|
|
1244
|
+
as malformed JSON - before validation and before the handler. The new `protoPoisoning` server
|
|
1245
|
+
option selects the policy: `"reject"` (default), `"strip"` (delete the keys in place and
|
|
1246
|
+
continue), or `"ignore"` (opt out). The check covers the schema body lane, `c.boundedJson`, the
|
|
1247
|
+
streaming no-length path, and `\u`-escaped spellings of the poisoned keys; the transport-codec
|
|
1248
|
+
lane enforces the same policy on its own decoder via a matching `protoPoisoning` plugin option.
|
|
1249
|
+
The common clean payload keeps the runtime's native fast path - the guard is a single iterative
|
|
1250
|
+
pass over the parsed value, allocation-free, and benchmarks within noise of the unguarded
|
|
1251
|
+
baseline.
|
|
1252
|
+
- 9a9346e: Routes and contract operations accept a `headers` schema, validated at the boundary alongside
|
|
1253
|
+
`body`, `query`, and `params`. Field names are materialized lower-case onto a null-prototype record
|
|
1254
|
+
before validation, so a hostile field name (`__proto__`, `constructor`) cannot reach
|
|
1255
|
+
`Object.prototype`, and repeated fields arrive already comma-joined by the platform. The validated
|
|
1256
|
+
result is available to the handler as typed data instead of ad-hoc `c.req.header(...)` reads, and a
|
|
1257
|
+
failure answers the same flat `400` as any other input-validation failure.
|
|
1258
|
+
|
|
1259
|
+
The section flows through the rest of the surface: contract diffs report `headers` as its own
|
|
1260
|
+
breaking-change section, and OpenAPI generation emits the schema as `in: header` parameters.
|
|
1261
|
+
|
|
1262
|
+
- 9a9346e: Per-route transport body caps. A route or contract operation may declare `bodyLimit` - a byte count,
|
|
1263
|
+
or the explicit string `"unlimited"` paired with a `bodyLimitReason` that records why the route is
|
|
1264
|
+
exempt. The route's cap overrides the server-wide `maxBodyBytes` for that route only, so a single
|
|
1265
|
+
upload endpoint no longer forces the whole app's ceiling upward, and an exemption is a reviewable
|
|
1266
|
+
declaration rather than a silent absence. An invalid value - a cap that is not a non-negative safe
|
|
1267
|
+
integer, `"unlimited"` without a non-empty reason, or a reason given without `"unlimited"` - is a
|
|
1268
|
+
`RouteConfigError` carrying the `INVALID_BODY_LIMIT` code at registration, not a surprise at request
|
|
1269
|
+
time.
|
|
1270
|
+
- dbc0b79: Signing-secret rotation. `signValue`/`unsignValue` (and the new `CookieSecret` type), session `secret`, and CSRF `secret` now also accept a rotation list: the first secret signs, any listed secret verifies, so keys rotate without invalidating live cookies, sessions, or CSRF tokens. Every listed secret must meet the 32-byte floor and an empty list throws; the single-secret path is unchanged.
|
|
1271
|
+
- a5d3f5b: Add stable diagnostic codes, application-supplied rule packs, fix recipes, assurance bundles, contract lock snapshots, hydration assurance hooks, replay metadata, security verification rules, and idempotency proofs.
|
|
1272
|
+
- 00819c5: `createToolHttpHandler` caps its JSON request body. The handler is standalone - it is not mounted on
|
|
1273
|
+
a server and so inherited no `maxBytes` - and used to read the body unbounded, documenting the limit
|
|
1274
|
+
as the mounting platform's job. It now defaults to 1 MiB (`DEFAULT_TOOL_MAX_BYTES`), configurable
|
|
1275
|
+
with `maxBytes`. An oversized body answers a flat `413` and a malformed `Content-Length` a flat
|
|
1276
|
+
`400`, both before the parse, so nothing oversized is ever materialized as a JS value.
|
|
1277
|
+
|
|
1278
|
+
Running uncapped stays possible but must be declared: `maxBytes: "unlimited"` requires a non-empty
|
|
1279
|
+
`maxBytesReason`, and a reason without `"unlimited"`, or a cap that is not a non-negative safe
|
|
1280
|
+
integer, throws when the handler is created rather than at request time.
|
|
1281
|
+
|
|
1282
|
+
**Breaking for bodies over 1 MiB.** A tool whose inputs legitimately exceed that must set `maxBytes`
|
|
1283
|
+
to its real ceiling, or opt out with a reason.
|
|
1284
|
+
|
|
1285
|
+
- e2d1939: Add typed tool contracts with shared fail-closed adapters, static verification work graphs, bounded provider-neutral agent turns, deterministic trajectory replay, and an explicit execution-policy seam with a non-isolating local process adapter.
|
|
1286
|
+
- e83e6eb: A capability provenance rule that matches nothing is now a finding. Seam specifiers in
|
|
1287
|
+
`provenance.imports` and `provenance.routeModules` are compared with the text the code imports, so a
|
|
1288
|
+
rule written as `src/db` when the module is imported as `./src/db.ts` silently governed zero
|
|
1289
|
+
modules - the policy looked satisfied because nothing was ever attributed to it. `nifra check` now
|
|
1290
|
+
reports `unmatched-provenance-seam` for every declared seam no scanned source matched, with the
|
|
1291
|
+
nearest specifiers that were actually seen ("did you mean ...?") and a fix that points at rewriting
|
|
1292
|
+
the rule to match the import, or deleting it.
|
|
1293
|
+
|
|
1294
|
+
`forbiddenImports` is deliberately excluded: zero matches there is the success state.
|
|
1295
|
+
|
|
1296
|
+
A rule that is genuinely absent in some projects sharing one policy can opt out with
|
|
1297
|
+
`optional: true`, which suppresses the finding for that seam only.
|
|
1298
|
+
|
|
1299
|
+
### Patch Changes
|
|
1300
|
+
|
|
1301
|
+
- cd1732c: The body-size cap now holds on the bytes actually delivered, not on what the request declared.
|
|
1302
|
+
The `Content-Length` fast path re-checks the real byte count after the read: a source that hands
|
|
1303
|
+
over more bytes than it declared - a lying client or an adapter that decodes/expands the body
|
|
1304
|
+
upstream - is rejected with the same flat `413`, even though its header passed the fast-reject
|
|
1305
|
+
hint. One integer comparison on a buffer already in hand; the streaming lane already counted real
|
|
1306
|
+
bytes and is unchanged.
|
|
1307
|
+
- 9a9346e: A contract operation's client-visible `body` and `query` types are now the schema's INPUT side, not
|
|
1308
|
+
its output side - matching what the inline registry already does. A schema that fills in defaults
|
|
1309
|
+
made the contract client demand the post-validation shape, so a caller had to send fields the schema
|
|
1310
|
+
exists to supply. Handler-facing context types are unchanged and still carry the validated output.
|
|
1311
|
+
- 5f71c23: Cut iterator overhead from the async lifecycle chain, the static-header pass, and cookie signing.
|
|
1312
|
+
|
|
1313
|
+
An array iterator declared inside an async function stays live across the `await` that follows it, so
|
|
1314
|
+
neither JSC nor V8 sinks it: the `beforeHandle`/`onError` chains paid 43ns per hook on Bun and ~9ns
|
|
1315
|
+
on Node for iteration alone. The static response-header pass paid it twice per header, once for the
|
|
1316
|
+
list and once for the pair destructuring (211ns per response on a 5-header set), and signed cookies
|
|
1317
|
+
paid it per digest byte (238ns per signature on Bun). All three now index directly.
|
|
1318
|
+
|
|
1319
|
+
- 3788b36: Walk arrays by index in the prototype-poisoning guard.
|
|
1320
|
+
|
|
1321
|
+
A JSON body that is mostly array data was walked through the array iterator protocol, which JSC does
|
|
1322
|
+
not elide: on Bun a 9KB array of numbers cost 27us to guard instead of 2.2us, and the gap scaled
|
|
1323
|
+
with the body cap. The guard now indexes the array directly, which measures identically on V8 and
|
|
1324
|
+
removes the amplification on JSC.
|
|
1325
|
+
|
|
1326
|
+
- ae5338f: Cut per-request overhead on the JSON body lane and the Node adapter.
|
|
1327
|
+
|
|
1328
|
+
The bounded JSON reader now returns the body read's own promise instead of running as an async
|
|
1329
|
+
function, so a framed JSON POST pays one microtask hop rather than several. The prototype-poisoning
|
|
1330
|
+
walk checks the suspect keys during the single enumeration it already performs instead of probing
|
|
1331
|
+
each node twice. The Node adapter memoizes Host-authority normalization, which repeats for every
|
|
1332
|
+
request of a deployment.
|
|
1333
|
+
|
|
1334
|
+
No behaviour change: the same bodies are accepted, the same poisoning shapes are rejected or
|
|
1335
|
+
stripped, and the same Host values normalize to the same authority.
|
|
1336
|
+
|
|
1337
|
+
- 5e4e31a: The Node-direct response path asks "are these header names already the lowercase wire spelling"
|
|
1338
|
+
once per request instead of three times. Core answers it where the answer is already in hand - the
|
|
1339
|
+
static-header fold derives each name's lowercase form anyway, and the native response walk was
|
|
1340
|
+
walking the same keys - and publishes it as a symbol-keyed mark the header view and `@nifrajs/node`'s
|
|
1341
|
+
direct writer read instead of re-scanning. An app that registers a raw `onNodeResponse` twin (one
|
|
1342
|
+
handed the record itself rather than the case-normalizing view) is never marked and keeps the
|
|
1343
|
+
per-reader scans, since such a twin writes after the point the mark would be set. Wire output is
|
|
1344
|
+
unchanged by construction: it is the same answer, from the same pass.
|
|
1345
|
+
|
|
1346
|
+
Header-normalization frames fall from 4.1% to 1.5% of self time on a middleware-heavy route and from
|
|
1347
|
+
1.6% to 0.6% on a hookless one (V8 CPU profile, `GET /users/:id`). `@nifrajs/middleware`'s Node
|
|
1348
|
+
response twins set one header at a time through a helper that keeps the record in V8's fast property
|
|
1349
|
+
mode rather than re-homing it into a null-prototype object, which demoted every later lookup on the
|
|
1350
|
+
response path to dictionary mode.
|
|
1351
|
+
|
|
1352
|
+
- bd5c624: Collapse the JSON body lane onto one guarded path and make the poisoning walk cheaper.
|
|
1353
|
+
|
|
1354
|
+
Framed JSON bodies now take the runtime's fused `json()` at every size instead of splitting at 1KB,
|
|
1355
|
+
and the guard walks the parsed value directly - the raw-text substring pre-scan is gone, since it
|
|
1356
|
+
cost more than the walk it was meant to avoid. The walk itself only stacks object nodes, so scalar
|
|
1357
|
+
keys and scalar array elements no longer make the round trip through it.
|
|
1358
|
+
|
|
1359
|
+
No behaviour change: the same payloads are accepted, and the same poisoning shapes - including ones
|
|
1360
|
+
spelled with `\uXXXX` escapes - are still rejected or stripped under `protoPoisoning`.
|
|
1361
|
+
|
|
1362
|
+
- e2bdd4a: `createToolHttpHandler` applies the prototype-poisoning policy to its JSON request body. A body
|
|
1363
|
+
carrying an own `__proto__` key, or an own `constructor` key whose value holds an own `prototype`,
|
|
1364
|
+
is rejected with the same `input_invalid` tool result as malformed JSON, so probing cannot
|
|
1365
|
+
distinguish a blocked payload from a syntax error. The handler is standalone (never mounted on a
|
|
1366
|
+
server), so it carries its own `protoPoisoning` option - `"reject"` (default), `"strip"`, or
|
|
1367
|
+
`"ignore"` - mirroring the server option. The body remains read unbounded; cap request size at the
|
|
1368
|
+
platform or server mounting the handler.
|
|
1369
|
+
- f8b0097: A `.ws()` route's `maxPayloadBytes` is now enforced on every runtime, not only the ones whose socket
|
|
1370
|
+
implementation happened to police it. The declared cap travels with the upgrade outcome, so the Node
|
|
1371
|
+
bridge hands it to `ws` as `maxPayload`, and the Deno and Workers/`attachWebSocket` message paths
|
|
1372
|
+
measure the frame and close with `1009 message too large` instead of delivering it. A route that
|
|
1373
|
+
declares no cap is untouched and pays nothing: sizing a text frame costs a UTF-8 encode, so the
|
|
1374
|
+
measurement only runs where a cap exists.
|
|
1375
|
+
|
|
1376
|
+
## 2.11.0
|
|
1377
|
+
|
|
1378
|
+
## 2.10.0
|
|
1379
|
+
|
|
1380
|
+
### Minor Changes
|
|
1381
|
+
|
|
1382
|
+
- 15bffdd: Serve `public/` with byte ranges. Static files now advertise `accept-ranges`, answer a single-range request with `206` and `content-range`, return `416` for an unsatisfiable range, and publish `last-modified` with `if-modified-since` and `if-range` handling. HEAD reports the same `content-type` and length metadata GET does. `parseByteRange` moves to `@nifrajs/core/range` so the static handler and `@nifrajs/middleware`'s `rangeResponse` share one parser; the middleware export is unchanged.
|
|
1383
|
+
- 15bffdd: Add request-bound data capability evidence, resumable bounded channel subscriptions, ISR tag
|
|
1384
|
+
invalidation for memory and KV stores, and dependency-free Open Graph image responses with an
|
|
1385
|
+
optional rasterizer seam.
|
|
1386
|
+
- 15bffdd: Add standards-shaped range, conditional, content-negotiation, and multipart response helpers, plus public token-only data contracts and typed in-memory channel seams.
|
|
1387
|
+
|
|
1388
|
+
## 2.9.1
|
|
1389
|
+
|
|
1390
|
+
### Patch Changes
|
|
1391
|
+
|
|
1392
|
+
- 01e36fb: HEAD requests now answer via the matching GET route with identical status and headers (RFC 9110), on both the Bun native-route lane and the portable dispatcher. Previously a HEAD to a GET-only route returned a 405 JSON error, so custom headers and the declared content-type were lost. An explicitly registered HEAD route still takes precedence, and 405 Allow lists now advertise the implicit HEAD support.
|
|
1393
|
+
|
|
1394
|
+
## 2.9.0
|
|
1395
|
+
|
|
1396
|
+
### Minor Changes
|
|
1397
|
+
|
|
1398
|
+
- e05e56d: Improve hot paths across runtimes and the browser: a validated-POST fused lane for Bun/Deno Web
|
|
1399
|
+
requests (measured +12.7% Deno, +3.5% Bun on `POST /users`) plus a registration-compiled body
|
|
1400
|
+
validation/handler continuation shared by Web and Node-direct (about 9.6% faster than the generic
|
|
1401
|
+
body lane in-process), client route matching indexed on the core router instead of a linear scan
|
|
1402
|
+
(measured ~18x faster on a 100-route app), search-param parsing in one pass instead of O(keys²), and
|
|
1403
|
+
allocation-free fast paths for static asset URLs and safe SSR script serialization.
|
|
1404
|
+
Bare fused-lane Web requests with no active timeout or deadline now run on the lazy request
|
|
1405
|
+
context (the one the Node direct path already uses), with the platform - `c.env`, `c.clientIp`,
|
|
1406
|
+
`c.waitUntil` - carried through and `c.signal`/`c.budget`/`c.query` resolving lazily to identical
|
|
1407
|
+
values, pinned by a regression test. Measured +4.5% on a bare `GET /users/:id` on Deno. Routing
|
|
1408
|
+
also stops allocating a `{ pathname, search }` pair per request on the portable path.
|
|
1409
|
+
Node serving now keeps synchronous Web request middleware on the direct renderer, adapts in-place Web
|
|
1410
|
+
response middleware back to direct buffered writes, and avoids redundant params/body lifecycle stages
|
|
1411
|
+
for common validated reads. Header-only built-ins (`cache-control`, `powered-by`, and related response
|
|
1412
|
+
mutators) no longer clone buffered responses on Node.
|
|
1413
|
+
New portable middleware hook: `onResponseBody(body, headers, req, status)` - the post-serialization
|
|
1414
|
+
payload tier. The hook receives the FINAL framework-serialized bytes plus the mutable header view,
|
|
1415
|
+
and may return replacement bytes. On the Node direct writer the bytes come straight off the outcome
|
|
1416
|
+
record; on the Web serving paths they ride the framework-built Response as an inert tag (attached
|
|
1417
|
+
only once a body hook is registered), so no body stream is ever drained on any runtime. A
|
|
1418
|
+
handler-returned raw `Response` (a proxied fetch, SSE, a streamed page) is skipped by contract, a
|
|
1419
|
+
structured return (`{ body, status }`) can drop the body or change the status (an ETag `304`), and
|
|
1420
|
+
transforming those remains `onResponse`'s job. A body-observing middleware written this way
|
|
1421
|
+
measures at ~92% of a raw `node:http` server on the realistic route, vs ~50% through the full
|
|
1422
|
+
`onResponse` contract.
|
|
1423
|
+
New middleware hook: `onResponseRaw(response, req)` - the raw-response tier. It runs ONLY for
|
|
1424
|
+
responses the payload tier skips (streams, proxied fetches, and framework-generated error
|
|
1425
|
+
responses); a framework-serialized JSON body stays on `onResponseBody` and, on Node, on the direct
|
|
1426
|
+
socket writer (the raw hook self-pairs with a no-op native twin, so registering one does not force
|
|
1427
|
+
the fallback path). Together the two tiers cover every response without double-processing any.
|
|
1428
|
+
Response-body tagging is now scoped per app instance instead of process-wide: one app registering a
|
|
1429
|
+
body hook no longer makes unrelated apps in the same process pay for tagging, `merge()` keeps the
|
|
1430
|
+
tag readable across merged apps, and a foreign Response carrying a look-alike marker is not treated
|
|
1431
|
+
as a framework-serialized body.
|
|
1432
|
+
Bodiless statuses are normalized on every render path: a handler returning a `204`/`205`/`304` (or
|
|
1433
|
+
a body hook converting to one, e.g. an ETag `304`) always ships with no body and no
|
|
1434
|
+
`content-length`, on the Web paths and the Node direct writer alike.
|
|
1435
|
+
Response-header records are built null-prototyped everywhere user-influenced names can land in
|
|
1436
|
+
them, so header names like `__proto__` stay data instead of touching the record's prototype; the
|
|
1437
|
+
header view over Node outcomes also resolves names via a one-time per-request index instead of
|
|
1438
|
+
scanning the record on every get/set (measured at roughly a fifth of the framework's own CPU on a
|
|
1439
|
+
realistic middleware-carrying route).
|
|
1440
|
+
Guarded response headers (a raw `fetch()`ed Response) are now detected with a reversible probe
|
|
1441
|
+
before any header hook runs, instead of catching the mutation `TypeError` and re-running the hook
|
|
1442
|
+
against a clone - a hook that itself throws `TypeError` no longer runs twice. Framework-constructed
|
|
1443
|
+
responses stamp their headers as known-mutable at construction, so the hot path answers that
|
|
1444
|
+
question with a single weak-set lookup and only a handler-returned foreign `Response` ever pays the
|
|
1445
|
+
probe, once per headers object.
|
|
1446
|
+
New portable middleware hook: `onResponseHeaders(headers, req, status)` - the recommended shape for
|
|
1447
|
+
response middleware that only reads or writes headers. One implementation runs on every runtime: on
|
|
1448
|
+
the Web serving paths it mutates the response's own `Headers` inside the normal response walk (no
|
|
1449
|
+
clone), and on Node it self-pairs as a native hook against the outcome record, so registering one
|
|
1450
|
+
never forces the Node adapter off its direct socket writer the way a full `onResponse(res:
|
|
1451
|
+
Response)` hook does.
|
|
1452
|
+
Native Node hook lanes now engage as a unit - the response-side native hooks run only when the
|
|
1453
|
+
request side is native too - which makes the native request context's identity stable across a
|
|
1454
|
+
request; the context also carries `url`, and both are documented so middleware twins can key
|
|
1455
|
+
per-request state on it. Building a Web `Request` from a Node request fills its header list once
|
|
1456
|
+
from a plain record instead of copying a prebuilt `Headers` a second time.
|
|
1457
|
+
Query and cookie parsing intern repeated key names on V8-based runtimes (Node, Deno) through a small
|
|
1458
|
+
bounded cache - V8 pays ~13x to store a freshly-sliced string key on the null-prototype records the
|
|
1459
|
+
parsers build, so handing back the first-seen key makes the store take the fast path. High-cardinality
|
|
1460
|
+
or oversized keys bypass the cache and behave exactly as before, and JSC (Bun) skips the scheme
|
|
1461
|
+
entirely (it has no such cost).
|
|
1462
|
+
When any route registers `onResponseBody`, every JSON response with caller-set headers (the common
|
|
1463
|
+
shape once a route has middleware) stopped pre-building a throwaway `Headers` instance just to
|
|
1464
|
+
check for an existing `content-type` - that instance was immediately handed to `new Response()`,
|
|
1465
|
+
which does its own header ingestion regardless, so the pre-build was pure waste. The check now runs
|
|
1466
|
+
against whatever shape the headers already are (a plain record gets a shallow copy only when
|
|
1467
|
+
`content-type` is absent; an already-built `Headers` is mutated in place, as before) and that result
|
|
1468
|
+
goes straight into the `Response` constructor. Deno/V8 charged far more for the discarded `Headers`
|
|
1469
|
+
instance than Bun/JSC did - measured previously as the entire gap between the payload tier's Deno
|
|
1470
|
+
row and its own raw ceiling on the realistic-shape benchmark; that row now leads every peer
|
|
1471
|
+
framework and sits within a few percent of raw `Deno.serve` again.
|
|
1472
|
+
On Deno, JSON responses that carry caller-set headers are now built as a bare `Response` whose
|
|
1473
|
+
headers are set individually afterwards, instead of handing the header record to the constructor -
|
|
1474
|
+
Deno charges far more to ingest a header-record init than to mutate a built response's `Headers`.
|
|
1475
|
+
The runtime's own `Response.json` content-type is probed once and reused, so the wire contract is
|
|
1476
|
+
exactly what `Response.json` ships on that runtime, and Bun keeps the constructor path it measures
|
|
1477
|
+
faster on. Measured +7% on the realistic middleware GET row and +12.7% on its body-hash variant;
|
|
1478
|
+
with this, the realistic Deno rows lead the closest peer framework on both GET and POST.
|
|
1479
|
+
The native header view's one-time name index is now authoritative for every operation, including
|
|
1480
|
+
writes of names not yet present: setting a new header no longer walks and lowercases the whole
|
|
1481
|
+
record on the way in, which had made each fresh `set()` cost grow with the headers already written
|
|
1482
|
+
(measured +2.2% end to end on the realistic middleware-carrying Node GET row). Case-insensitive
|
|
1483
|
+
reads still cover the record as first seen plus everything written through the view; a native twin
|
|
1484
|
+
writing the record directly uses lowercase names - the wire form the record documents.
|
|
1485
|
+
New response tier: `app.responseHeaders(record)` (and `responseHeaders` on a middleware bundle) for
|
|
1486
|
+
response headers with no per-request decision behind them. Declaring them registers no response hook,
|
|
1487
|
+
so the values fold into response construction - one prebuilt init for JSON renders, one record merge
|
|
1488
|
+
where the request set its own headers - and an app whose response middleware is only static keeps the
|
|
1489
|
+
lanes a hook closes: Bun's fused native routes, and the Node direct socket writer that a full
|
|
1490
|
+
`onResponse` gives up. They still apply to every response a hook would cover (success, error,
|
|
1491
|
+
404/405, timeout, short-circuit), byte-identically to registering the same names as an
|
|
1492
|
+
`onResponseHeaders` hook, on every runtime - pinned by parity suites over `app.fetch`, the Node
|
|
1493
|
+
adapter's own socket writes, and the Deno serve path. Declared headers are DEFAULTS: a value the
|
|
1494
|
+
request produced (`c.set.headers`, or a response hook) wins, whatever casing it used, and one name
|
|
1495
|
+
spelled two ways still ships as one header line. Names are lowercased once at wire-up; a non-string
|
|
1496
|
+
value, an invalid name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
|
|
1497
|
+
`transfer-encoding`, `set-cookie`) throws a `TypeError` there instead of surfacing on the wire.
|
|
1498
|
+
Declarations made before any response hook merge into one record; one made after a hook registers as
|
|
1499
|
+
an ordinary header hook so registration order is preserved.
|
|
1500
|
+
`securityHeaders()` and `poweredBy()` (in its default respect-existing configuration) now declare
|
|
1501
|
+
their headers instead of writing them from a hook: measured +11% on a bare Bun `GET` behind
|
|
1502
|
+
`securityHeaders()`, within noise on Node and Deno (where the per-response header writes, not the
|
|
1503
|
+
response walk, dominate). Middleware whose headers depend on the request keeps the hook - `cors`
|
|
1504
|
+
reflects an origin, and `cacheControl` gates on method and status.
|
|
1505
|
+
|
|
1506
|
+
## 2.8.2
|
|
1507
|
+
|
|
1508
|
+
### Patch Changes
|
|
1509
|
+
|
|
1510
|
+
- f7d68e8: Numeric limit options (body/payload byte caps, TTLs, cache sizes, concurrency, ISR revalidate windows) are now validated at construction and throw a `RangeError` on non-finite or out-of-range values instead of silently disabling the bound - a `NaN` cap previously made `size > max` comparisons fail open. JWT `requiredClaims` now checks own properties only, so inherited names like `toString` no longer satisfy a required claim. `@nifrajs/mcp-db` gates multi-statement input with a real tokenizer, bounds `run_query` materialization to `maxRows + 1` via a wrapping subquery, and skips SQLite planner pseudo-nodes when verifying the table allowlist. `nifra scaffold` refuses to write through symlinked route directories.
|
|
1511
|
+
|
|
1512
|
+
## 2.8.1
|
|
1513
|
+
|
|
1514
|
+
### Patch Changes
|
|
1515
|
+
|
|
1516
|
+
- 78d66a4: Node serving got measurably faster - ahead of Fastify on every workload in our benchmark, where it previously trailed by ~2%. Three changes, all behavior-preserving:
|
|
1517
|
+
|
|
1518
|
+
- Buffered responses (node-direct JSON and SSR body outcomes) now declare `Content-Length` instead of falling back to `Transfer-Encoding: chunked` - a known-length body never needed chunked framing, which cost extra wire bytes and client parsing on every response, and no other runtime chunked these.
|
|
1519
|
+
- The socket-peer platform object is built once per connection and reused across keep-alive requests (the peer address cannot change mid-socket).
|
|
1520
|
+
- Routing on Node now splits pathname/search straight from the origin-form request target; the absolute URL is only synthesized if something actually reads `c.req.url`. `RequestSource` gained an optional `urlParts` field for sources that already hold the split target.
|
|
1521
|
+
- An absent-header probe no longer falls through to the source's `Headers` object: a source-implemented `header()` returning `null` is authoritative. The fallthrough was materializing a full `Headers` on every POST (the body lane's `transfer-encoding` check), measured at ~4% of request CPU - the POST lane on Node is ~9% faster without it.
|
|
1522
|
+
|
|
1523
|
+
- 93fdc89: Fix the unhandled-request-error log so it carries the error's own message.
|
|
1524
|
+
|
|
1525
|
+
The record is built by spreading the caller's fields and then setting the framework's own keys, so
|
|
1526
|
+
`level`, `message`, and `time` always win. The error log passed the thrown error's text as `message`,
|
|
1527
|
+
which meant it was overwritten by the log message itself and never reached the sink:
|
|
1528
|
+
|
|
1529
|
+
```json
|
|
1530
|
+
{
|
|
1531
|
+
"method": "GET",
|
|
1532
|
+
"path": "/boom",
|
|
1533
|
+
"name": "Error",
|
|
1534
|
+
"message": "unhandled request error",
|
|
1535
|
+
"stack": "Error: kaboom\n at ..."
|
|
1536
|
+
}
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1539
|
+
The real text survived only incidentally inside `stack`, and was lost outright for a non-`Error`
|
|
1540
|
+
throw, which has no stack to hide in. It is now emitted as `detail`, matching the response-contract
|
|
1541
|
+
logs:
|
|
1542
|
+
|
|
1543
|
+
```json
|
|
1544
|
+
{
|
|
1545
|
+
"method": "GET",
|
|
1546
|
+
"path": "/boom",
|
|
1547
|
+
"name": "Error",
|
|
1548
|
+
"detail": "kaboom",
|
|
1549
|
+
"stack": "Error: kaboom\n at ..."
|
|
1550
|
+
}
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
This is a shape change for anything parsing these lines. A consumer reading `message` on an error
|
|
1554
|
+
record now gets the constant `"unhandled request error"` in every case rather than sometimes-there
|
|
1555
|
+
diagnostic text - it was already that constant, so nothing loses information, but a dashboard or
|
|
1556
|
+
alert grouping on that field should move to `detail`.
|
|
1557
|
+
|
|
1558
|
+
## 2.8.0
|
|
1559
|
+
|
|
1560
|
+
## 2.7.1
|
|
1561
|
+
|
|
1562
|
+
### Patch Changes
|
|
1563
|
+
|
|
1564
|
+
- 52c89e0: Query-validated routes now take the fused Web lane. A route whose only lifecycle step is a query schema (no body/params schema, no hooks, no idempotency/ledger, no validation-error recovery) compiles parse + validate + handler + respond into one closure: with a sync validator and handler there is no lifecycle promise at all. Semantics are unchanged - the 422 contract, thrown-Response control flow, repeated-key promotion, async validators, `c.set`, decorations, and `merge()` all behave exactly as the generic lane, and any recovery hook or wrapper keeps the route on that lane. This is the biggest win for validated GET endpoints on V8 runtimes (Node, Deno), and it speeds up Bun too.
|
|
1565
|
+
|
|
1566
|
+
## 2.7.0
|
|
1567
|
+
|
|
1568
|
+
## 2.6.1
|
|
1569
|
+
|
|
1570
|
+
### Patch Changes
|
|
1571
|
+
|
|
1572
|
+
- 5840c98: Two hot-path costs removed. On Bun, the socket peer address is now resolved lazily: `c.clientIp` keeps its documented raw-peer behavior, but the underlying `requestIP()` lookup (surprisingly expensive per request) only runs when something actually reads it - trust-mode routes still resolve it before the handler. On Node, buffered SSR responses no longer clone the response-header record (and every Set-Cookie array) before `writeHead` - the producer already hands over response-normalized names and values.
|
|
1573
|
+
|
|
1574
|
+
## 2.6.0
|
|
1575
|
+
|
|
1576
|
+
### Patch Changes
|
|
1577
|
+
|
|
1578
|
+
- e6349e5: Security hardening across input parsing and code generation. Every regex that runs on caller-influenced input (URL paths, route patterns, stylesheet and SVG sources, manifest text) is now linear - no polynomial backtracking on adversarial input. SVG preamble stripping and tag removal can no longer splice removed delimiters into new markers. Static file serving rejects `..` traversal in the request form outright and confines the resolved path with a `relative()` containment check. Generated code embeds strings through an escaper that neutralizes `</script>` breakout and the U+2028/U+2029 line separators, and HTML entity decoding resolves `&` last so double-encoded entities cannot double-unescape.
|
|
1579
|
+
|
|
1580
|
+
## 2.5.0
|
|
1581
|
+
|
|
1582
|
+
## 2.4.0
|
|
1583
|
+
|
|
1584
|
+
### Patch Changes
|
|
1585
|
+
|
|
1586
|
+
- 138bfba: Route precedence now has one home: a `sortRoutesBySpecificity` helper on `@nifrajs/core/pattern` orders compiled routes most-specific-first (a static segment beats a dynamic one). The web router, the mock server, and the editor plugin all order routes through it, so which file a path resolves to stays identical across runtime, client, and editor.
|
|
1587
|
+
|
|
1588
|
+
## 2.3.0
|
|
1589
|
+
|
|
1590
|
+
### Minor Changes
|
|
1591
|
+
|
|
1592
|
+
- 6f5b3ad: Assurance rules can match a CLASS of capability rather than a list of token names.
|
|
1593
|
+
|
|
1594
|
+
```ts
|
|
1595
|
+
{ name: "authenticated-write", match: { access: "write", zone: "domain" }, require: [AUTHENTICATED] }
|
|
1596
|
+
```
|
|
1597
|
+
|
|
1598
|
+
Naming exact tokens is precise but closed. A rule listing `db.write` does not cover `storage.write`, so
|
|
1599
|
+
every policy has to enumerate every write in the system, and a capability introduced next year escapes
|
|
1600
|
+
the rule until someone remembers to widen it. `access` and `zone` are read from the capability
|
|
1601
|
+
definitions, so the rule is keyed on what the capability IS - a new token is covered the day it is
|
|
1602
|
+
declared.
|
|
1603
|
+
|
|
1604
|
+
Both constraints must hold for the SAME capability: a route that reads business state and writes an
|
|
1605
|
+
audit log does not satisfy `{ access: "write", zone: "domain" }` by combining halves of two tokens.
|
|
1606
|
+
|
|
1607
|
+
The selectors resolve through the capability definitions, so `evaluateRouteAssurance` takes them via a
|
|
1608
|
+
new third argument (`{ definitions }`) and `defineAssuranceConfig` refuses a policy that uses them
|
|
1609
|
+
without a `capabilities` block. Without definitions such a rule could only ever match nothing - and a
|
|
1610
|
+
rule that matches nothing does not fail, it lets the route fall past to whatever laxer rule comes next.
|
|
1611
|
+
|
|
1612
|
+
- 85b354d: Assurance rules can match on a route's declared capabilities, and a misspelled selector key is now an error.
|
|
1613
|
+
|
|
1614
|
+
```ts
|
|
1615
|
+
{ name: "authenticated-write", match: { capabilities: ["db.write"] }, require: [NIFRA_ASSURANCE.AUTHENTICATED] }
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
A path glob is the wrong tool for "anything that writes must prove who asked": it breaks when a route
|
|
1619
|
+
moves, and it cannot see a route that acquires the capability later. The declared tokens already reach
|
|
1620
|
+
reflection, so a policy can be written against what a route DOES. Matches when the route declares any of
|
|
1621
|
+
the listed tokens.
|
|
1622
|
+
|
|
1623
|
+
Every `create-nifra` template ships this rule, which is what stops a server function - a public POST
|
|
1624
|
+
endpoint whose arguments the caller controls - from shipping unauthenticated.
|
|
1625
|
+
|
|
1626
|
+
The selector is rebuilt from an allowlist of known keys, so an unrecognised one used to be dropped
|
|
1627
|
+
silently. A selector that loses its only constraint matches EVERY route, so the rule swallows
|
|
1628
|
+
everything after it - in a policy whose first rule is the lenient one, a single typo disabled the rest
|
|
1629
|
+
of the file. Unknown selector keys are refused rather than ignored.
|
|
1630
|
+
|
|
1631
|
+
- 8514caa: A capability requiring durable idempotency has an adapter that satisfies it.
|
|
1632
|
+
|
|
1633
|
+
```ts
|
|
1634
|
+
import { createDurableEffectJournal } from "@nifrajs/core/durable-execution";
|
|
1635
|
+
import { durableCommand } from "@nifrajs/middleware";
|
|
1636
|
+
|
|
1637
|
+
const app = server()
|
|
1638
|
+
.use(durableCommand({ journal: createDurableEffectJournal({ store }) }))
|
|
1639
|
+
.post("/charge", { capabilities: ["billing.charge"] }, (c) =>
|
|
1640
|
+
executeCapability(c, "billing.charge", {}, () => gateway.charge(order))
|
|
1641
|
+
);
|
|
1642
|
+
```
|
|
1643
|
+
|
|
1644
|
+
A capability defined `idempotency: "durable"` requires `nifra.durable-command` evidence, and nothing
|
|
1645
|
+
shipped produced it. The tier was reachable, but only by writing `assurance: ["nifra.durable-command"]`
|
|
1646
|
+
on the route - an assertion with nothing behind it, and wrong in both directions: a route that
|
|
1647
|
+
genuinely journals its effects but omits the string fails `nifra check`, and a route that journals
|
|
1648
|
+
nothing but includes it passes. Every other assurance id has a shipped emitter; this one now does too.
|
|
1649
|
+
|
|
1650
|
+
The evidence is a by-product rather than a claim. Installing the adapter puts the journal on the
|
|
1651
|
+
request, so `executeCapability` records intent before an effect runs and exactly one terminal outcome
|
|
1652
|
+
after - which is what the tier is asking about. `executeCapability` resolves an explicitly passed
|
|
1653
|
+
`journal` first, so existing call sites are untouched, and a journal missing a transition is refused at
|
|
1654
|
+
wiring time rather than surfacing as a TypeError partway through a production effect.
|
|
1655
|
+
|
|
1656
|
+
`attachCapabilityJournal` and `capabilityJournalOf` are exported from `@nifrajs/core/capabilities` as
|
|
1657
|
+
the seam the adapter uses.
|
|
1658
|
+
|
|
1659
|
+
Response replay is still not durable-command evidence, and that is deliberate: if the process dies
|
|
1660
|
+
between the effect and storing its response there is nothing to replay, so replay cannot be what makes
|
|
1661
|
+
an effect exactly-once. The journal survives that; only the journal clears the tier.
|
|
1662
|
+
|
|
1663
|
+
- ea0a27f: Capability provenance says when it could not finish, instead of reporting a clean project.
|
|
1664
|
+
|
|
1665
|
+
The reachability walk stops at a module count and an import depth so a pathological graph cannot hang
|
|
1666
|
+
the check. Hitting either limit used to end the walk quietly, and the route came back covered - a
|
|
1667
|
+
passing report whose subject was partly unexamined, which is the shape of failure this whole gate
|
|
1668
|
+
exists to prevent.
|
|
1669
|
+
|
|
1670
|
+
Both limits now produce a `provenance-truncated` finding naming the route and the chain that reached
|
|
1671
|
+
it, the check fails, and the lockfile refuses to record a snapshot taken from a truncated walk.
|
|
1672
|
+
|
|
1673
|
+
- b271164: Add `responseContract()`, an opt-in plugin that makes a route's declared `response` schema hold at runtime.
|
|
1674
|
+
|
|
1675
|
+
A `response` schema is a lower bound: it says "at least these fields", never "only these". A handler
|
|
1676
|
+
that returns a database row satisfying it also ships every other column, and nothing points at it -
|
|
1677
|
+
TypeScript's excess-property check does not reach a handler's return position, and the client's type
|
|
1678
|
+
reports the contract rather than the bytes. The result can appear with no code change at all: add a
|
|
1679
|
+
column, and the next deploy ships it to browsers.
|
|
1680
|
+
|
|
1681
|
+
```ts
|
|
1682
|
+
import { responseContract } from "@nifrajs/core/response-contract";
|
|
1683
|
+
app.use(responseContract("enforce"));
|
|
1684
|
+
```
|
|
1685
|
+
|
|
1686
|
+
- not installed (default) - unchanged behaviour, and the lane is absent from the bundle entirely.
|
|
1687
|
+
- `"warn"` - checks each response, logs the undeclared fields by name, serves the payload unchanged.
|
|
1688
|
+
- `"enforce"` - serializes the validated value, so undeclared data cannot reach the wire.
|
|
1689
|
+
|
|
1690
|
+
Enforcement follows the schema's own semantics, since Standard Schema exposes `validate` and no way to
|
|
1691
|
+
enumerate declared keys: a stripping schema (Zod, Valibot) yields a cleaned value, while a strict one
|
|
1692
|
+
(`@nifrajs/schema`'s `t.object`) reports issues and the response becomes a 500 with the detail logged
|
|
1693
|
+
rather than returned. Routes with a `response` schema leave the fused and native fast paths while this
|
|
1694
|
+
is enabled, the same trade an idempotent route makes.
|
|
1695
|
+
|
|
1696
|
+
It is a plugin rather than a server option so that apps which do not use it do not carry it: as an
|
|
1697
|
+
option the check was statically imported by the kernel and cost every app ~0.5 KB gzip, which the
|
|
1698
|
+
bundle-size gate caught. Behind the `@nifrajs/core/response-contract` subpath the lane arrives only
|
|
1699
|
+
when installed, and the kernel keeps just the install seam (~0.2 KB).
|
|
1700
|
+
|
|
1701
|
+
- 5fe332a: A route can declare that it returns bytes, and the client types it as `Blob`.
|
|
1702
|
+
|
|
1703
|
+
```ts
|
|
1704
|
+
import { bytes } from "@nifrajs/core/binary";
|
|
1705
|
+
|
|
1706
|
+
app.get("/invoice.pdf", async (c) =>
|
|
1707
|
+
bytes(await render(c.params.id), {
|
|
1708
|
+
type: "application/pdf",
|
|
1709
|
+
filename: "invoice.pdf",
|
|
1710
|
+
})
|
|
1711
|
+
);
|
|
1712
|
+
```
|
|
1713
|
+
|
|
1714
|
+
Sending bytes was always possible - return a raw `Response` - but a raw `Response` is exactly what the
|
|
1715
|
+
typed client cannot describe. So a download route needed a `// nifra-expect raw-response` pragma to
|
|
1716
|
+
quiet the drift advisory, and its caller got no type at all. One category of endpoint sat outside the
|
|
1717
|
+
contract the framework is otherwise strict about.
|
|
1718
|
+
|
|
1719
|
+
`bytes()` closes that. The brand it carries is a phantom - nothing is added to the value at runtime -
|
|
1720
|
+
and it exists so the type can say a thing the value cannot: that these bytes are the payload rather
|
|
1721
|
+
than a serialization accident. A plain `Response` is unaffected and still types as it did.
|
|
1722
|
+
|
|
1723
|
+
`filename` handles anything a person can type. Characters that would end the header value early are
|
|
1724
|
+
stripped, and a name ASCII cannot carry is encoded per RFC 6266 (`filename*=UTF-8''...`) rather than
|
|
1725
|
+
throwing - setting a header containing `\u62a5\u544a.pdf` or an emoji raises, which on a download route
|
|
1726
|
+
would be a 500 for the ordinary act of naming a file, and the name is usually the user's own.
|
|
1727
|
+
|
|
1728
|
+
- d2840ac: A safe-method route that can reach a domain write is reported once, with the fix it actually has.
|
|
1729
|
+
|
|
1730
|
+
It used to draw two findings giving opposite advice: `undeclared-capability-evidence` (declare what you
|
|
1731
|
+
reach) and `safe-method-domain-write` (a safe method may not declare a domain write). Both are correct
|
|
1732
|
+
and together they are a dead end - the route cannot declare its way out, because the declaration was
|
|
1733
|
+
never the problem.
|
|
1734
|
+
|
|
1735
|
+
Reach is computed from the module that registers a route, so a read endpoint sitting beside a write
|
|
1736
|
+
seam has write powers in scope. The new `unconfined-write-reach` finding says that, and says to move
|
|
1737
|
+
the route or the effect. Still an error, and the report still fails; a GET that explicitly DECLARES a
|
|
1738
|
+
domain write is unchanged, because that one really is an HTTP semantics mistake.
|
|
1739
|
+
|
|
1740
|
+
### Patch Changes
|
|
1741
|
+
|
|
1742
|
+
- ea0a27f: A durable table prefix cannot collide after PostgreSQL truncates it.
|
|
1743
|
+
|
|
1744
|
+
**Breaking for prefixes longer than 45 characters**, which now fail at construction rather than later.
|
|
1745
|
+
PostgreSQL truncates identifiers to 63 bytes, and this adapter appends up to `_records_reconcile` (18)
|
|
1746
|
+
to the prefix. Two distinct prefixes long enough to be cut short became one table name, silently
|
|
1747
|
+
sharing state between what the caller believed were separate deployments. The accepted length now
|
|
1748
|
+
reserves the longest suffix, so an accepted prefix survives truncation intact.
|
|
1749
|
+
|
|
1750
|
+
Both adapters also assemble their statements through a tagged template that validates every
|
|
1751
|
+
substitution as an identifier at the boundary, so the check is at the seam rather than trusted from a
|
|
1752
|
+
caller several frames up.
|
|
1753
|
+
|
|
1754
|
+
- 8c77d47: The response size limit is reachable, applies to text as well as JSON, and never applies to a download.
|
|
1755
|
+
|
|
1756
|
+
```ts
|
|
1757
|
+
client<App>(url, { maxDecodedBytes: 64 * 1024 * 1024 });
|
|
1758
|
+
```
|
|
1759
|
+
|
|
1760
|
+
`maxBytes` lived under `transport`, whose `codec` is required - so raising your own response limit
|
|
1761
|
+
meant opting into a versioned transport representation you had not asked for, and the call did not
|
|
1762
|
+
compile without it. The 16 MB default protected everyone while the knob was reachable by nobody. It is
|
|
1763
|
+
a top-level option now, with a doc comment saying what it bounds.
|
|
1764
|
+
|
|
1765
|
+
It bounds text as well as JSON, because a 2 GB string costs what a 2 GB object costs and one number
|
|
1766
|
+
should answer for both. It deliberately does NOT bound a binary body: that is a download, and a size
|
|
1767
|
+
limit on a download is a bug rather than a defence.
|
|
1768
|
+
|
|
1769
|
+
Exceeding it is a result, not a throw: `{ ok: false, status: 0, error: { error: "response_too_large" } }`,
|
|
1770
|
+
the shape a timeout already takes. It used to throw a `TransportCodecError` straight out of the client,
|
|
1771
|
+
which meant the only safe way to use the option was the try/catch the client's contract exists to
|
|
1772
|
+
remove. The older `transport.maxBytes` spelling still works and still wins for the transport path.
|
|
1773
|
+
|
|
1774
|
+
- ea0a27f: The same-origin check works behind a TLS-terminating proxy, and both seams that use it now agree.
|
|
1775
|
+
|
|
1776
|
+
Nifra had two of these - the WebSocket handshake's cross-origin default and the server-function mount -
|
|
1777
|
+
and they answered differently for one request, so a browser that could open a socket was told its POST
|
|
1778
|
+
was cross-origin. `isSameOriginRequest` from `@nifrajs/core/server` is now the single owner.
|
|
1779
|
+
|
|
1780
|
+
The rule it encodes: the host must match, and the Origin's scheme may be equal or STRONGER, never
|
|
1781
|
+
weaker. A server behind Cloudflare, a tunnel or an ingress sees a plain HTTP socket, so `request.url`
|
|
1782
|
+
says `http:` while the browser correctly reports an `https:` page - the shape almost every deployment
|
|
1783
|
+
produces. Comparing full origins rejects it, which is not the stricter option but an outage: measured,
|
|
1784
|
+
every server-function POST returned 403 behind a terminating proxy, while the cross-origin caller the
|
|
1785
|
+
comparison aimed at had already been rejected on the host alone. The reverse - an `http:` page against
|
|
1786
|
+
an `https:` request URL - is a downgrade with no legitimate cause, and is refused, as is any Origin
|
|
1787
|
+
that is not an http(s) page origin at all.
|
|
1788
|
+
|
|
1789
|
+
`X-Forwarded-Proto` is still not read, and that is the point: a forwarded header is attacker-controlled
|
|
1790
|
+
unless something upstream is proven to overwrite it, so trusting one by default would hand every
|
|
1791
|
+
unproxied deployment a spoofable origin check. Ordering the two schemes reconciles them without it.
|
|
1792
|
+
|
|
1793
|
+
## 2.2.0
|
|
1794
|
+
|
|
1795
|
+
### Minor Changes
|
|
1796
|
+
|
|
1797
|
+
- a4645e2: Support path segments that are part literal, part parameter.
|
|
1798
|
+
|
|
1799
|
+
A route segment had to be wholly static, wholly a parameter, or wholly a wildcard. `/:key.txt`,
|
|
1800
|
+
`/post-[id].html` and `/[locale]-sitemap.xml` did not merely fail to match - they failed to
|
|
1801
|
+
**compile**. The trigger was an IndexNow key-verification file, which the protocol requires at
|
|
1802
|
+
`<origin>/<key>.txt` with the key coming from deploy-time config, and at the root, because a key
|
|
1803
|
+
served from a subdirectory only authorises URLs beneath it. The workaround was an exact-match check in
|
|
1804
|
+
the app's server entry, which moved a routing concern out of the router and never ran in dev.
|
|
1805
|
+
|
|
1806
|
+
Both spellings now work: `:key.txt` in a route pattern, and `[inKey].txt.tsx` as a file route. The
|
|
1807
|
+
parameter name is the longest identifier run after `:`; everything else in the segment is literal.
|
|
1808
|
+
Precedence is static > mixed > param > wildcard, decided by shape rather than registration order, so
|
|
1809
|
+
`/robots.txt` still beats `/:key.txt` and `/jobs/:id.txt` beats `/jobs/:id`.
|
|
1810
|
+
|
|
1811
|
+
Inside a mixed segment, `[[optional]]` and `[...catchAll]` are **rejected** at build time rather than
|
|
1812
|
+
given a meaning: there is no sensible absent form for `/[[locale]]-feed.xml`, and a catch-all captures
|
|
1813
|
+
the rest of the path, which a trailing literal can never follow.
|
|
1814
|
+
|
|
1815
|
+
**Literal colons keep their meaning.** A `:` that follows an identifier character and runs to the end
|
|
1816
|
+
of its segment is literal, so the established RPC-style action shape - `/v1/things:batchGet` - still
|
|
1817
|
+
routes as written rather than capturing `batchGet` into a parameter named after the verb. Mixed
|
|
1818
|
+
parameters remain available everywhere they are unambiguous: at the start of a segment (`/:key.txt`),
|
|
1819
|
+
after punctuation (`/post-:id`), or with a literal suffix (`/v:major.json`). A `:` not followed by a
|
|
1820
|
+
valid identifier start (`/ratio:2`) is literal as before.
|
|
1821
|
+
|
|
1822
|
+
Mixed siblings are ordered by ONE total comparator shared between the server's trie router and the
|
|
1823
|
+
browser's matcher. Ordering by literal weight alone left ties broken differently on each side, so
|
|
1824
|
+
`/bar.:value` and `/:value.foo` could resolve to different routes for the same URL - visible only as a
|
|
1825
|
+
soft navigation rendering the wrong page.
|
|
1826
|
+
|
|
1827
|
+
Adding a mixed pattern can also make a previously unambiguous path ambiguous: with both `/jobs/:id` and
|
|
1828
|
+
`/jobs/:id.txt` registered, `/jobs/a.txt` now matches the mixed route with `id="a"` where before it
|
|
1829
|
+
could only match the bare param with `id="a.txt"`. Deterministic, and only for apps that opt in by
|
|
1830
|
+
registering a mixed pattern.
|
|
1831
|
+
|
|
1832
|
+
An app that registers no mixed segment allocates nothing for this and pays one `undefined` check on
|
|
1833
|
+
the match path. The rejected-parameter hint added in the previous release is removed - `:id.json` was
|
|
1834
|
+
the shape it explained, and `:id.json` now compiles.
|
|
1835
|
+
|
|
1836
|
+
### Patch Changes
|
|
1837
|
+
|
|
1838
|
+
- 5f460db: Fix `nifra init-agents`, and explain rejected route parameters.
|
|
1839
|
+
|
|
1840
|
+
`nifra init-agents` failed for every installed user with `Cannot find module 'create-nifra/agent-files'`.
|
|
1841
|
+
The `./agent-files` subpath resolves through the `bun` condition to `src/agent-files.ts`, which the
|
|
1842
|
+
published tarball did not contain - the package shipped `dist` and the templates only. It now ships
|
|
1843
|
+
that source file, so the subpath resolves from a real install. Reproduced from a packed 2.1.0 tarball
|
|
1844
|
+
before and after.
|
|
1845
|
+
|
|
1846
|
+
An invalid route parameter now says why. Route grammar is per-segment - a segment is wholly static or
|
|
1847
|
+
wholly a parameter - so everything after the colon is the name, and `/v/:id.json` asks for a parameter
|
|
1848
|
+
literally called `id.json`. The previous `invalid parameter ":id.json"` read as a typo rather than a
|
|
1849
|
+
rule; the message now names the limitation and gives both ways out (`/v/:id/json`, or capture the whole
|
|
1850
|
+
segment and split it in the handler). Reserved names, an empty name, and a name that is invalid for
|
|
1851
|
+
some other reason each get their own explanation instead of sharing one.
|
|
1852
|
+
|
|
1853
|
+
Note for anyone who has hit this: a segment that merely _contains_ a colon without starting with one,
|
|
1854
|
+
such as `/a/pre-:id`, is a literal static segment and captures nothing. That is deliberate - a colon is
|
|
1855
|
+
legal inside a URL path segment (`/v1/things:batchGet`) - and is now covered by a test that documents it.
|
|
1856
|
+
|
|
1857
|
+
- e713cab: Let a route loader answer 404 and 410.
|
|
1858
|
+
|
|
1859
|
+
A matched route whose loader finds nothing had no supported way to set its page's status, so the path
|
|
1860
|
+
of least resistance was to return empty data and render "not found" inside a **200**. That is a soft
|
|
1861
|
+
404: search engines penalise it and keep the dead URL indexed, and because the page looks correct in a
|
|
1862
|
+
browser it ships and stays shipped. It is the most common page shape there is - a detail route whose
|
|
1863
|
+
record may not exist.
|
|
1864
|
+
|
|
1865
|
+
`notFound()`, `gone()`, and `statusPage(status)` are thrown from a loader, the way `redirect()` already
|
|
1866
|
+
is. They render the `_404` page - or `_410.tsx` / `_<status>.tsx` if the app authored one - inside the
|
|
1867
|
+
normal layout chain, hydrated, at the right status. A `headers` option carries the cache policy each
|
|
1868
|
+
status wants: a 404 may be racing publication and wants a short TTL, while a 410 is a promise that the
|
|
1869
|
+
URL is permanently gone. Typed `never`, so a loader narrows without a redundant `return`.
|
|
1870
|
+
|
|
1871
|
+
410 is not a pedantic 404: it tells a crawler to drop the URL instead of re-fetching it for weeks.
|
|
1872
|
+
|
|
1873
|
+
Existing behaviour is unchanged by construction. The signal is a branded `Response` and the brand is
|
|
1874
|
+
checked before the verbatim pass-through, so `throw redirect(...)`, `throw new Response(...)`, and a
|
|
1875
|
+
real `Error` reaching `_error` all behave exactly as before. Client-side navigation and prerendering
|
|
1876
|
+
already handle a non-ok render correctly and now have tests pinning that: a soft-nav falls back to a
|
|
1877
|
+
full navigation and lands on the same page, and a prerendered path whose loader signals is omitted
|
|
1878
|
+
from the build rather than baked as a static 200 shell.
|
|
1879
|
+
|
|
1880
|
+
`renderPageResult` gains a `headers` option. `content-type` and the ISR freshness header stay
|
|
1881
|
+
framework-owned and cannot be overridden through it.
|
|
1882
|
+
|
|
1883
|
+
Also trims the router's rejected-parameter message added in the previous release. The explanation cost
|
|
1884
|
+
~0.3 KB gzip in every bundle; it now states the grammar rule and the two ways out without building an
|
|
1885
|
+
example path, which is a third smaller and keeps the base bundle inside its budget.
|
|
1886
|
+
|
|
1887
|
+
- 6aa0aac: Add `previewEndpoint` for draft/preview mode, and make transport codec decode errors uniform.
|
|
1888
|
+
|
|
1889
|
+
`previewEndpoint({ secret, draftSecret })` is a `fetch` handler for the link your CMS's "Preview"
|
|
1890
|
+
button points at: it checks the preview token in constant time, turns draft mode on with the signed
|
|
1891
|
+
`__nifra_draft` cookie, and redirects the editor to the requested page. It is the link-borne sibling
|
|
1892
|
+
of `revalidateEndpoint`, and it exists because gating the route by hand means writing two checks that
|
|
1893
|
+
are easy to get subtly wrong and that fail silently when you do - the token compare must not exit
|
|
1894
|
+
early on the first wrong character, and the `?to=` destination must not be allowed off-site
|
|
1895
|
+
(`//evil.com` and `/\evil.com` both start with a slash yet navigate away). Wrong or missing token
|
|
1896
|
+
gives `401`, an off-site destination `400`, and success a `302` carrying `Cache-Control: no-store`
|
|
1897
|
+
so no shared cache can replay one editor's draft session to a visitor. Param names, the fallback
|
|
1898
|
+
destination, and cookie lifetime/path/`Secure` are all configurable.
|
|
1899
|
+
|
|
1900
|
+
`decodeTransportFrame` and `decodeTransportResponse` now raise `TransportCodecError` for a malformed
|
|
1901
|
+
payload instead of letting the underlying `SyntaxError` through, with the original kept as `cause`.
|
|
1902
|
+
Every other failure in that module was already a `TransportCodecError`, so a malformed payload - the
|
|
1903
|
+
likeliest hostile input - was the one case that slipped past callers catching the documented error
|
|
1904
|
+
type. `TransportCodecError` accepts an `ErrorOptions` second argument to carry that cause. Bytes that
|
|
1905
|
+
are not valid UTF-8 take the same path: the `TypeError` from the strict decoder used to escape ahead
|
|
1906
|
+
of any codec, so the one input that never reached a codec at all was also the one that reported
|
|
1907
|
+
differently from every other decode failure.
|
|
1908
|
+
|
|
1909
|
+
## 2.1.0
|
|
1910
|
+
|
|
1911
|
+
### Minor Changes
|
|
1912
|
+
|
|
1913
|
+
- bd294bb: Add `executeCapability()` as a correlated, policy-aware effect boundary.
|
|
1914
|
+
|
|
1915
|
+
- Correlate intent and terminal evidence with a random `effectId`, record committed/failed outcomes
|
|
1916
|
+
automatically, and combine request cancellation with bounded async `aroundCapability()` admission
|
|
1917
|
+
policies while preserving the synchronous `useCapability()` path.
|
|
1918
|
+
- Retain idempotency results for every completed response, including non-2xx outcomes, so a retry
|
|
1919
|
+
cannot repeat an effect that succeeded before a later handler failure.
|
|
1920
|
+
- Add durable approval, effect journal, saga/compensation, and reconciliation primitives behind the
|
|
1921
|
+
`durable-execution` subpath, plus token-only OpenTelemetry effect spans from `@nifrajs/otel/effects`.
|
|
1922
|
+
Reconciliation supports bounded cursor pages, approval resume tokens stay out of ordinary error
|
|
1923
|
+
serialization, durable terminal states are monotonic, crash ambiguity has an effect-ID-bound operator
|
|
1924
|
+
resolution API, and unmatched effect spans have bounded retention.
|
|
1925
|
+
- Add one shared owned-effect scope across capabilities, saga execution, compensation, idempotency
|
|
1926
|
+
evidence, durable transitions, and telemetry. An explicit `markIdempotencySafeToRetry()` outcome
|
|
1927
|
+
releases a resolved 5xx only while the scope proves no effect began.
|
|
1928
|
+
- Add negotiated, versioned transport codecs with bounded plain-JSON and rich-wire adapters for HTTP,
|
|
1929
|
+
the typed client, loader NDJSON, and WebSocket frames.
|
|
1930
|
+
- Add Postgres, SQLite, and Durable Object durable-execution adapters with one reusable conformance
|
|
1931
|
+
suite, plus leased reconciliation workers with bounded pages/concurrency, durable cursors, filters,
|
|
1932
|
+
cancellation, backpressure, and token-only metrics.
|
|
1933
|
+
|
|
1934
|
+
- d3aac63: Add `@nifrajs/core/wire`: a rich-type JSON codec (`encode` / `decode` / `stringify` / `parse`) for RPC
|
|
1935
|
+
bodies, loader payloads, and WebSocket frames.
|
|
1936
|
+
|
|
1937
|
+
Plain `JSON` drops `undefined`, stringifies `Date`, nulls `NaN`/`Infinity`, loses `-0`, throws on
|
|
1938
|
+
`BigInt`, and has no notion of `Map`, `Set`, `RegExp`, `URL`, `ArrayBuffer`, or typed arrays - so a
|
|
1939
|
+
typed client can receive a runtime value whose shape diverges from the type it inferred from the server.
|
|
1940
|
+
The codec round-trips all of those exactly, preserves shared-reference identity, and encodes cycles as
|
|
1941
|
+
back-references instead of throwing. Malformed input decodes to a typed `WireDecodeError`; functions and
|
|
1942
|
+
symbols are rejected on encode rather than silently dropped. Decode is secure by default: object keys
|
|
1943
|
+
cannot mutate prototypes, every node shape is validated, and configurable node/depth/collection/byte
|
|
1944
|
+
budgets reject resource-exhaustion payloads.
|
|
1945
|
+
|
|
1946
|
+
## 2.0.0
|
|
1947
|
+
|
|
1948
|
+
### Major Changes
|
|
1949
|
+
|
|
1950
|
+
- 82676e0: Optional server systems are now opt-in `.use()` plugins installed from dedicated subpaths - never server options, side-effect imports, or process-global registries.
|
|
1951
|
+
|
|
1952
|
+
- Enable request idempotency with `.use(idempotency())` from `@nifrajs/core/idempotency-plugin` - pass `{ store }` for a durable app-wide default. The `idempotencyStore` server option is removed.
|
|
1953
|
+
- Enable the per-request effect ledger with `.use(effectLedger({ sink }))` from `@nifrajs/core/effect-ledger`. The `effectLedger` server option is removed.
|
|
1954
|
+
- Enable MCP declarations (`.tool()`, `.resource()`, `.prompt()`) with `.use(mcp())` from `@nifrajs/core/mcp`. The package root does not activate them implicitly.
|
|
1955
|
+
- Enable typed SSE routes (`.sse()`) with `.use(streaming())` from `@nifrajs/core/sse`.
|
|
1956
|
+
- Enable WebSocket routes (`.ws()`) with `.use(websocket())` from `@nifrajs/core/ws`. The old `import "@nifrajs/core/ws"` side-effect no longer installs the runtime.
|
|
1957
|
+
- A route that declares one of these without its plugin installed fails loudly at registration, so a gate can never be silently dropped by a forgotten plugin.
|
|
1958
|
+
|
|
1959
|
+
Each plugin installs its runtime on that server instance only - two servers in one process never share opt-in state. Merging a configured sub-app with `.use(subApp)` carries its installed runtimes across.
|
|
1960
|
+
|
|
1961
|
+
A `server()` that uses none of these pulls none of their code into its bundle, so the minimal server footprint is smaller.
|
|
1962
|
+
|
|
1963
|
+
Migration:
|
|
1964
|
+
|
|
1965
|
+
```ts
|
|
1966
|
+
// before
|
|
1967
|
+
server({ idempotencyStore, effectLedger: { sink } });
|
|
1968
|
+
|
|
1969
|
+
// after
|
|
1970
|
+
import { effectLedger } from "@nifrajs/core/effect-ledger";
|
|
1971
|
+
import { idempotency } from "@nifrajs/core/idempotency-plugin";
|
|
1972
|
+
import { mcp } from "@nifrajs/core/mcp";
|
|
1973
|
+
import { streaming } from "@nifrajs/core/sse";
|
|
1974
|
+
|
|
1975
|
+
server()
|
|
1976
|
+
.use(idempotency({ store: idempotencyStore }))
|
|
1977
|
+
.use(effectLedger({ sink }))
|
|
1978
|
+
.use(mcp()) // if the app declares tools/resources/prompts
|
|
1979
|
+
.use(streaming()); // if the app declares .sse() routes
|
|
1980
|
+
|
|
1981
|
+
// WebSocket apps:
|
|
1982
|
+
// before: import "@nifrajs/core/ws"; server().ws(...)
|
|
1983
|
+
// after: import { websocket } from "@nifrajs/core/ws"; server().use(websocket()).ws(...)
|
|
1984
|
+
```
|
|
1985
|
+
|
|
1986
|
+
Standalone callers of `app.resolveNode()` opt in with `.use(nodeDirect())` from `@nifrajs/core/node-direct`. The `@nifrajs/node` adapter installs it on `serve(app)` automatically, so normal Node deployments need no change and keep the direct JSON fast path.
|
|
1987
|
+
|
|
1988
|
+
- a7b1d60: WebSocket routes are now enabled with `.use(websocket())` from `@nifrajs/core/ws`, matching every other opt-in system. The old `import "@nifrajs/core/ws"` side-effect no longer installs the runtime.
|
|
1989
|
+
|
|
1990
|
+
The runtime installs on that server instance only (no process-global), so `app.ws()` without the plugin fails loudly at registration. Adapters and `@nifrajs/workers` still import `attachWebSocket` / `TopicRegistry` from the same subpath.
|
|
1991
|
+
|
|
1992
|
+
```ts
|
|
1993
|
+
// before
|
|
1994
|
+
import "@nifrajs/core/ws";
|
|
1995
|
+
const app = server().ws("/chat", handler);
|
|
1996
|
+
|
|
1997
|
+
// after
|
|
1998
|
+
import { websocket } from "@nifrajs/core/ws";
|
|
1999
|
+
const app = server().use(websocket()).ws("/chat", handler);
|
|
2000
|
+
```
|
|
2001
|
+
|
|
2002
|
+
### Minor Changes
|
|
2003
|
+
|
|
2004
|
+
- a7b1d60: Add `c.clientIp` - the caller's IP, derived correctly and vendor-neutrally.
|
|
2005
|
+
|
|
2006
|
+
By default it is the raw socket peer the serving adapter observed (`listen()`, `@nifrajs/node`, `@nifrajs/deno` supply it; any caller can pass it via `app.fetch(req, { clientIp })`), the one address a client cannot forge - and never a forwarded header. Behind a reverse proxy or CDN, set the `clientIp` server option to derive the real caller from the forwarding chain as far as you trust it:
|
|
2007
|
+
|
|
2008
|
+
- `server({ clientIp: { trustedHops: n } })` reads `X-Forwarded-For` past `n` proxies you operate (a short header fails closed to `undefined`);
|
|
2009
|
+
- `server({ clientIp: { header: "x-real-ip" } })` trusts one edge-set header's first value.
|
|
2010
|
+
|
|
2011
|
+
Declaring trust the app can't enforce would let clients forge their IP, so it stays unset by default. `c.clientIp` is safe to key rate limits and audit logs on, and is resolved once before handlers, `derive`, and hooks run.
|
|
2012
|
+
|
|
2013
|
+
- eaac3d7: Route assurance reaches two places it couldn't before: in-handler guards and dynamic route families.
|
|
2014
|
+
|
|
2015
|
+
- **Inline `schema.assurance`.** A route (or contract op) can declare the enforcement evidence it carries adjacent to the handler - `{ assurance: [NIFRA_ASSURANCE.AUTHENTICATED] }` - and each id reflects as route-scoped `declared` evidence. A route whose guard runs inside the handler body (invisible to reflection) can now satisfy a policy `require:` clause without being rewritten into a `withRouteAssurance`-marked middleware. Invalid evidence ids fail closed at registration.
|
|
2016
|
+
- **`flagClassifiedWithoutEvidence` policy option.** Opt-in. When set, a route matched by a pure-classification rule (no `require`, no `forbid`) that carries no evidence is reported as `classified-no-evidence` - making the "a classification-only policy silently degrades proof to a label" gap visible instead of green. Off by default (a genuinely public route legitimately carries no evidence).
|
|
2017
|
+
- **`schema.family` dynamic route families.** A runtime-resolved template (`/api/:slug/:resource` over tenant-defined tables, a catch-all dispatcher) can be marked `{ family: true }`. It surfaces as `family` in reflection, so the assurance gate and tooling read the one templated route as a deliberate family whose evidence covers every runtime-resolved resource, rather than a single forgotten route. Purely declarative - it does not change dispatch.
|
|
2018
|
+
|
|
2019
|
+
- ade0c7a: Add a curated `@nifrajs/core/server` entry for the common HTTP runtime and dedicated subpaths for
|
|
2020
|
+
contracts, classification, cookies, logging, routing, Standard Schema, SEO, SSE, and webhooks. The
|
|
2021
|
+
package root remains backwards compatible, while new scaffolds and first-party runtime packages avoid
|
|
2022
|
+
eagerly parsing opt-in causality, invariant, manifest, reflection, capability, and assurance tooling.
|
|
2023
|
+
- 1522d06: Path params can now be validated + coerced at the boundary, and query scalars have a coercing constructor - closing the two input slots that lagged behind `body`.
|
|
2024
|
+
|
|
2025
|
+
- **`params` schema slot.** A route (or contract op) can declare `params: t.object({ id: t.string({ format: "uuid" }) })`; a malformed `:id` is now a `422` before the handler runs, exactly like `body`/`query`, instead of an in-handler hand-check. The validated value lands on `c.params` with the schema's output type (a `params` schema can also coerce - use `t.query({ id: t.integer() })` for a numeric path param, and `c.params.id` is a real `number`). Routes without a `params` schema are unchanged: `c.params` stays the path-inferred `Record<name, string>`. The `onValidationError` hook's `kind` gains `"params"`, and params validate first (before body/query). The client's param-call signature is unchanged - a URL segment is still passed as a string.
|
|
2026
|
+
- **`t.query(shape)`.** The query-slot analogue of `t.object`, with string->scalar coercion on. Query values always arrive as strings (`?limit=20` -> `"20"`), so a plain `t.object({ limit: t.integer() })` in a `query` slot never validates; `t.query` makes `t.integer()`/`t.number()`/`t.boolean()` fields real numbers/booleans in `c.query`. Open by default (unknown fields such as tracking params are accepted); pass `{ additionalProperties: false }` to enforce a strict allowlist. `t.object` stays the body-slot constructor (a JSON body is already typed - no coercion).
|
|
2027
|
+
|
|
2028
|
+
- a7b1d60: WebSocket routes join the end-to-end type chain, and client failures discriminate by status.
|
|
2029
|
+
|
|
2030
|
+
- `app.ws()` now enters the type-level registry (pseudo-method `"WS"`). The typed client grows a
|
|
2031
|
+
`.ws()` handle per WS route: `send()` accepts the route's `messageSchema` input type, received
|
|
2032
|
+
frames are typed from the new `sendSchema` option (an outbound, type-level contract), and both
|
|
2033
|
+
fall back to `unknown` when undeclared. The handle queues sends until open, exposes
|
|
2034
|
+
`messages()` (async iteration), `onMessage()`, `opened`, `close()`, and `raw`. Params, path
|
|
2035
|
+
literals, and `client<App>` inference work exactly like HTTP routes. Calling `.ws()` on the
|
|
2036
|
+
in-process client throws with an explanation (an in-process app has no socket to upgrade).
|
|
2037
|
+
- The client's `Result` failure union is now DISCRIMINATED BY STATUS when a route declares an
|
|
2038
|
+
`errors` record: `res.status === 404` narrows `res.data` to the declared 404 body. Undeclared
|
|
2039
|
+
statuses (and `0` for transport errors) fall into a fallback arm whose `data` is `unknown`;
|
|
2040
|
+
routes with no `errors` contract keep the single `unknown` failure arm. Contract operations'
|
|
2041
|
+
non-2xx `responses` discriminate the same way. Breaking for type-level consumers only: code that
|
|
2042
|
+
read the failure `data` after checking just `ok` must also narrow on `status` (the runtime shape
|
|
2043
|
+
is unchanged).
|
|
2044
|
+
- `testClient(app, { validateResponses: true })` asserts every JSON response against the route's
|
|
2045
|
+
declared contract - `response` for 2xx, `errors[status]` for declared failures - and throws a
|
|
2046
|
+
`ResponseContractViolation` on mismatch, so a handler whose real output drifts from its schema
|
|
2047
|
+
fails the test instead of passing silently. Off by default; statuses with no declared schema,
|
|
2048
|
+
non-JSON bodies, and 204/205/HEAD pass through unchecked.
|
|
2049
|
+
|
|
2050
|
+
## 1.13.0
|
|
2051
|
+
|
|
2052
|
+
### Minor Changes
|
|
2053
|
+
|
|
2054
|
+
- aae8614: `implement(contract, handlers, app)` accepts a pre-configured app, so a contract-first backend can run
|
|
2055
|
+
middleware. A route captures the server's `derive`/`decorate`/assurance chain at registration, so the
|
|
2056
|
+
chain has to be on the app before its routes exist:
|
|
2057
|
+
|
|
2058
|
+
```ts
|
|
2059
|
+
const app = implement(
|
|
2060
|
+
contract,
|
|
2061
|
+
handlers,
|
|
2062
|
+
server().use(auth).derive(sessionOf)
|
|
2063
|
+
);
|
|
2064
|
+
```
|
|
2065
|
+
|
|
2066
|
+
Handlers now receive `Context & Ctx` - the same shape an inline handler gets, so one graduates either
|
|
2067
|
+
way unchanged - and any routes already on the app stay in the returned server's registry. This is also
|
|
2068
|
+
what lets `nifra assure` prove a contract-first app rather than only classify it: the plugin that
|
|
2069
|
+
installs the enforcement is what declares the evidence, and only a plugin installed before
|
|
2070
|
+
registration is captured. The two-argument call is unchanged.
|
|
2071
|
+
|
|
2072
|
+
- 5b6127a: Make route batches atomic, seal server configuration after `listen()`, encode array query values as
|
|
2073
|
+
repeated keys, and align web route matching with the server.
|
|
2074
|
+
|
|
2075
|
+
Three behavior changes to know about:
|
|
2076
|
+
|
|
2077
|
+
- **Configuring a server after `listen()` now throws** instead of reaching some traffic and not the
|
|
2078
|
+
rest. Bun's native route table is compiled when you listen, so a hook added afterwards applied to
|
|
2079
|
+
`app.fetch()` but not to real HTTP requests: an `onRequest` guard installed late was silently
|
|
2080
|
+
skipped on the wire. Register routes, hooks, plugins, and context before listening.
|
|
2081
|
+
- **Array query values serialize as repeated keys** (`?tag=a&tag=b`), not `?tag=a%2Cb`, so a route
|
|
2082
|
+
whose `query` schema declares an array now receives one.
|
|
2083
|
+
- **The web matcher applies the server's trailing-slash rule.** `/users/7/` no longer matches
|
|
2084
|
+
`/users/:id` in the browser, matching the 404 the server already returns, and a malformed percent
|
|
2085
|
+
encoding reports no route instead of throwing.
|
|
2086
|
+
|
|
2087
|
+
A route batch from `implement()` or `merge()` commits only once every route in it validates, so a
|
|
2088
|
+
collision partway through leaves matching and reflection untouched instead of stranding the routes
|
|
2089
|
+
registered before it.
|
|
2090
|
+
|
|
2091
|
+
Each route now owns one immutable compiled execution plan shared by portable, Node-direct, and
|
|
2092
|
+
Bun-native dispatch. This also fixes validation recovery being skipped when a derive moved a route
|
|
2093
|
+
from a specialized lane to the generic lifecycle.
|
|
2094
|
+
|
|
2095
|
+
Core, browser navigation, Bun-native parameter metadata, and mock routing now consume the same
|
|
2096
|
+
compiled pattern kernel. Static routes beat parameters and parameters beat wildcards regardless of
|
|
2097
|
+
manifest order, with one grammar, trailing-slash policy, and malformed-encoding rule.
|
|
2098
|
+
|
|
2099
|
+
## 1.12.0
|
|
2100
|
+
|
|
2101
|
+
### Minor Changes
|
|
2102
|
+
|
|
2103
|
+
- 63d3845: Add bounded execution-causality contracts and propagation, OpenTelemetry causal links, event-envelope lineage, and a deterministic durable failure laboratory. `nifra levels` L4 now uses the deep adversarial contract engine through its explicitly isolated executor. Also add hash-verifiable adapter certification profiles and duplicate physical Nifra/React install detection in `nifra doctor`/`nifra check`.
|
|
2104
|
+
- 246f498: `app.listen(port, { hostname })` selects the bind address. It defaults to every interface, as before;
|
|
2105
|
+
pass `"127.0.0.1"` to bind loopback only for an admin surface, a sidecar, or any app that must not be
|
|
2106
|
+
reachable off the box.
|
|
2107
|
+
|
|
2108
|
+
## 1.11.0
|
|
2109
|
+
|
|
2110
|
+
### Minor Changes
|
|
2111
|
+
|
|
2112
|
+
- 2dde7e5: Add the effect ledger, sandboxed contract-generated invariant tests, and the verification ladder.
|
|
2113
|
+
|
|
2114
|
+
**Effect ledger** - a per-request, append-only, ordered record of side-effect intents and outcomes.
|
|
2115
|
+
Routes that declare `schema.capabilities` get a bounded, token-only ledger when the server enables
|
|
2116
|
+
`server({ effectLedger })`; each `useCapability(c, id, { target, cost, digest })` beacon
|
|
2117
|
+
records an intent, `recordCapabilityOutcome` records its terminal result without double-debiting
|
|
2118
|
+
admission, and the sink receives the sealed ledger when the response settles - on success and
|
|
2119
|
+
error responses alike, so partial work is audited. Entries carry capability ids, phases, adapter
|
|
2120
|
+
tokens, dimensionless cost counters, an optional keyed-HMAC payload digest, and bounded error codes;
|
|
2121
|
+
the entry type has no payload field, and the sealed ledger names the route _pattern_ plus the declared
|
|
2122
|
+
capability set, never the concrete URL - redaction holds by construction. Includes an optional
|
|
2123
|
+
tamper-evident hash chain, a bounded in-memory sink,
|
|
2124
|
+
and `computeEffectDigest` (keyed HMAC-SHA-256, so low-entropy data cannot be brute-forced from a
|
|
2125
|
+
stored digest). The hash chain binds route identity, declarations, timestamps, and entries. Sink
|
|
2126
|
+
failures are logged without their potentially-sensitive message and do not turn a successful effect
|
|
2127
|
+
into a retryable 500; transactional audit belongs in the effect's owning transaction. Routes without
|
|
2128
|
+
capability declarations keep the existing fast path unchanged.
|
|
2129
|
+
|
|
2130
|
+
**Contract-generated invariant tests** - `runContractInvariants(app, { executor })` fuzzes each route from its
|
|
2131
|
+
declared JSON Schema with a deterministic seeded generator and verifies what the contract promises:
|
|
2132
|
+
valid inputs never crash, 2xx responses conform to the declared response schema, schema-violating
|
|
2133
|
+
bodies are rejected (never accepted, never a crash), and a route-level classification never
|
|
2134
|
+
understates its field-level tags. Findings carry the case seed for exact reproduction; ungeneratable
|
|
2135
|
+
routes are reported as skipped, never silently dropped.
|
|
2136
|
+
Dynamic execution requires an explicit `invariants.executor` backed by a disposable app/sandbox;
|
|
2137
|
+
verification never invokes a live app implicitly, and any skipped route prevents L4.
|
|
2138
|
+
|
|
2139
|
+
**Verification ladder** - `nifra levels` computes L0 typed contract → L1 route assurance → L2
|
|
2140
|
+
capability lockfile → L3 route manifest → L4 invariant-tested from the existing gates. Levels are
|
|
2141
|
+
cumulative and computed, never self-declared; `--min <n>` gates CI on a required floor.
|
|
2142
|
+
|
|
2143
|
+
- 279f80c: Harden the idempotency primitive and add field-level response classification.
|
|
2144
|
+
|
|
2145
|
+
Idempotency now requires a server-resolved namespace (a static string for explicitly shared/public
|
|
2146
|
+
responses or a `(request, platform) => string` principal resolver - never a raw client identity).
|
|
2147
|
+
Routes carrying authenticated assurance must use the resolver form, so the same client key cannot
|
|
2148
|
+
collide across principals. Stored and legacy responses cannot replay `Set-Cookie`, authentication
|
|
2149
|
+
state, or hop-by-hop headers. `begin` returns an opaque reservation token that `complete`/`abandon` must
|
|
2150
|
+
present, so an expired-and-re-reserved key can no longer be overwritten by an older in-flight request.
|
|
2151
|
+
Stored responses are captured under a byte bound (`maxResponseBytes`, throwing
|
|
2152
|
+
`IdempotencyResponseTooLargeError`), fingerprints canonicalize JSON bodies and bind the content type,
|
|
2153
|
+
and a store advertises an honest `durability` marker - a route declaring `scope: "durable"` is rejected
|
|
2154
|
+
at registration unless its store is durable. SSE routes cannot be idempotent.
|
|
2155
|
+
|
|
2156
|
+
`classified(schema, tag)` attaches field-level sensitivity that survives composition through nested
|
|
2157
|
+
objects, arrays, and unions; reflection exposes both the JSON-pointer field tags and the maximum
|
|
2158
|
+
(`public` | `pii` | `secret`). Route-level `schema.classification` remains the fallback, and the
|
|
2159
|
+
capability lockfile continues to record the maximum.
|
|
2160
|
+
|
|
2161
|
+
- 5638ada: Add an explicit symbol-keyed in-process backend mount interface. `inProcessClient` implements the
|
|
2162
|
+
interface and `createWebApp` forwards the outer request's platform context through it, so an
|
|
2163
|
+
auto-mounted backend receives the same Workers `env` bindings and `waitUntil` lifetime as the web app.
|
|
2164
|
+
|
|
2165
|
+
The released `.fetch(url, init)` duck-typed mount remains as a compatibility fallback for custom
|
|
2166
|
+
bridges. `Server.onRequest` now receives the optional platform object as its second argument.
|
|
2167
|
+
|
|
2168
|
+
- 279f80c: Add a deterministic versioned Nifra manifest that joins route schemas, assurance evidence,
|
|
2169
|
+
capabilities, and field-level response classification in one hash-verified artifact. Manifests can be
|
|
2170
|
+
signed through an operator-provided Ed25519 KMS/HSM callback; Nifra never handles private keys.
|
|
2171
|
+
|
|
2172
|
+
`nifra manifest emit` refuses failing assurance and writes byte-stable output, while
|
|
2173
|
+
`nifra manifest diff <before> <after>` hash-verifies both artifacts and fails deployment promotion on
|
|
2174
|
+
breaking contract, lost assurance, expanded effects, or increased data sensitivity.
|
|
2175
|
+
|
|
2176
|
+
## 1.10.0
|
|
2177
|
+
|
|
2178
|
+
### Minor Changes
|
|
2179
|
+
|
|
2180
|
+
- 92181be: Add hardened effect and capability assurance: reflected route declarations, fail-closed runtime
|
|
2181
|
+
beacons, static effect-provenance analysis, deterministic capability lockfiles, HTTP safe-method
|
|
2182
|
+
guards, and effect-specific request or durable idempotency requirements.
|
|
2183
|
+
|
|
2184
|
+
Add `nifra capabilities snapshot` and `nifra capabilities check` so capability drift and raw
|
|
2185
|
+
provider bypasses can be enforced in CI without adding work to the default request path.
|
|
2186
|
+
|
|
2187
|
+
- 3773f0a: Add a request idempotency primitive and response data-classification tags.
|
|
2188
|
+
|
|
2189
|
+
`schema.idempotency` dedupes retries of a mutating route on an `Idempotency-Key` header: the first
|
|
2190
|
+
request runs and its response is stored, a retry with the same key replays that response without
|
|
2191
|
+
re-running the handler, a key reused with a different body is rejected (409), a missing key fails
|
|
2192
|
+
closed (400), and only successful responses are cached (an error releases the key so a retry can
|
|
2193
|
+
proceed). Ships an in-memory `IdempotencyStore` with an injectable clock; a durable store implements
|
|
2194
|
+
the same interface. Declaring idempotency also satisfies the capability-assurance idempotency
|
|
2195
|
+
requirement for a write capability (`durable` scope additionally clears the durable-command
|
|
2196
|
+
requirement). Routes without it keep the existing hot path unchanged.
|
|
2197
|
+
|
|
2198
|
+
`schema.classification` declares the highest data-sensitivity a route's response carries
|
|
2199
|
+
(`public` | `pii` | `secret`) - a declarative, compile-time + introspection fact, never enforced at
|
|
2200
|
+
runtime. It is reflected for tooling and recorded in the capability lockfile, so a route that starts
|
|
2201
|
+
returning PII becomes a reviewable change.
|
|
2202
|
+
|
|
2203
|
+
### Patch Changes
|
|
2204
|
+
|
|
2205
|
+
- 92181be: Move request-deadline mechanics to the dependency-free `@nifrajs/core/budget` subpath while keeping
|
|
2206
|
+
`@nifrajs/budget` as a compatible re-export. Harden adaptive admission across ESM runtimes, reserved
|
|
2207
|
+
capacity, disconnected queued requests, and invalid capacity evidence.
|
|
2208
|
+
|
|
2209
|
+
## 1.9.1
|
|
2210
|
+
|
|
2211
|
+
### Patch Changes
|
|
2212
|
+
|
|
2213
|
+
- @nifrajs/budget@1.9.1
|
|
2214
|
+
|
|
2215
|
+
## 1.9.0
|
|
2216
|
+
|
|
2217
|
+
### Minor Changes
|
|
2218
|
+
|
|
2219
|
+
- 03cd76f: Add portable absolute request deadlines with monotonic remaining time, child reserves, strict wire
|
|
2220
|
+
parsing, and local-policy admission. Nifra handlers now receive the admitted budget as `c.budget`; it
|
|
2221
|
+
shares the existing `c.signal`, clamps hostile far-future deadlines, and distinguishes malformed,
|
|
2222
|
+
expired, and exhausted inherited deadlines.
|
|
2223
|
+
|
|
2224
|
+
### Patch Changes
|
|
2225
|
+
|
|
2226
|
+
- 03cd76f: Compile eligible Nifra routes into Bun's native route table while preserving the existing lifecycle
|
|
2227
|
+
and portable-router fallback. Reuse unbounded request state, avoid wall-clock admission work when no
|
|
2228
|
+
deadline exists, lazily parse native-route queries, and inspect only captured parameter values.
|
|
2229
|
+
Inbound wire deadlines are now an explicit trust-boundary opt-in, keeping ordinary public routes on
|
|
2230
|
+
the zero-admission fast path while preserving clamped, fail-closed propagation for participating
|
|
2231
|
+
services.
|
|
2232
|
+
- Updated dependencies [03cd76f]
|
|
2233
|
+
- Updated dependencies [03cd76f]
|
|
2234
|
+
- @nifrajs/budget@1.9.0
|
|
2235
|
+
|
|
2236
|
+
## 1.8.0
|
|
2237
|
+
|
|
2238
|
+
### Minor Changes
|
|
2239
|
+
|
|
2240
|
+
- e47c4c5: Add reflection-time route assurance: middleware and plugins can publish lifecycle-accurate enforcement
|
|
2241
|
+
evidence, ordered policies fail closed on unclassified/missing/forbidden evidence, official hardening
|
|
2242
|
+
middleware emits canonical evidence, and `nifra assure` exposes a human/JSON CI gate.
|
|
2243
|
+
|
|
2244
|
+
## 1.7.0
|
|
2245
|
+
|
|
2246
|
+
### Minor Changes
|
|
2247
|
+
|
|
2248
|
+
- bd95181: `app.merge(group)` - domain-group composition for large apps, and the documented answer to the ~95-route TS2589 ceiling. A single fluent chain accumulates one type-alias level per route and TypeScript resolves that stack in one recursion, so one chain hits the compiler's instantiation-depth limit at ~95-100 routes (measured; eager-flattening variants all fail - see registry.ts). Build each domain as its own `server()` (its registry resolves independently) and merge: `app.merge(listings).merge(agents)` - each merge adds one level regardless of group size; 120+ routes typecheck with full param/schema fidelity (pinned in many-routes.test-d.ts). Merged routes keep the chains captured where they were defined (the group's own derive/validation/hooks apply exactly as standalone); the group's request-level hooks append to the parent; collisions and WebSocket groups fail closed at merge time. Contract-first `implement()` remains the other supported path - its registry is a single object type with no accumulation at all.
|
|
2249
|
+
|
|
2250
|
+
## 1.6.0
|
|
2251
|
+
|
|
2252
|
+
## 1.5.0
|
|
2253
|
+
|
|
2254
|
+
### Minor Changes
|
|
2255
|
+
|
|
2256
|
+
- 1ac2fde: API breaking-change gate: `snapshotRoutes` + `diffRouteSnapshots` in `@nifrajs/core/diff` (direction-aware - a new required request field or a removed response field breaks; widening a request enum or adding a response field doesn't; fails closed on anything unprovable), and `nifra snapshot` / `nifra diff <baseline>` CLI commands that exit non-zero on breaking changes for CI.
|
|
2257
|
+
- bd3433f: Security + correctness hardening: `FileStorage` refuses paths that cross symbolic links (component-wise `lstat` walk + `O_NOFOLLOW` writes; `list()` skips symlinks) so a planted symlink can no longer redirect reads/writes outside the storage root. OTel spans no longer copy raw `Error.message` into exported attributes (exception text routinely carries credentials/URLs); spans record `error.recorded: true` instead. New `onResponseFinalized` terminal observer on the server (`Middleware.onResponseFinalized` / `ResponseFinalization`) runs after every transforming `onResponse` hook and is fail-open - tracing now records the true final status even when a later hook rewrites or throws. OpenAPI generation sanitizes URI-style `$id` values into valid component names/`$ref` pointers (hex-derived, collision-suffixed) and is immune to `__proto__` key pollution.
|
|
2258
|
+
- 70aa836: End-to-end typed SSE subscriptions. `app.sse(path, { sse: t.object(...) }, (c, stream) => ...)` declares a typed event-stream route: the handler's `stream.send(event)` is compile-time-checked against the schema (JSON-serialized into the SSE `data:` field), the schema flows into the type-level contract and reflection, and query/body validation works exactly as on any route. The typed client grows `.subscribe(onEvent, options?)` on those routes - the event payload is inferred from the backend contract, transport is fetch-based (works over the network client, `inProcessClient`, and `testClient` alike) with EventSource semantics where they matter: auto-reconnect with backoff + jitter honoring the server's `retry:` hint, `Last-Event-ID` resumption, `reconnect: false` for finite streams, `onError`/`onClose` hooks, and an `AbortSignal`. Ordinary routes do not grow a `subscribe` key (type-level tested).
|
|
2259
|
+
|
|
2260
|
+
## 1.4.0
|
|
2261
|
+
|
|
2262
|
+
### Minor Changes
|
|
2263
|
+
|
|
2264
|
+
- 4d25970: Add one fail-open request-observation lifecycle shared by tracing, agent telemetry, and DevTools; secured development tooling; contract-based mock responses; validator-neutral schema/route reflection; executable render and storage adapter conformance modules; optional storage pagination/signing/copy capabilities; and metadata-preserving local file storage.
|
|
2265
|
+
|
|
2266
|
+
## 1.3.1
|
|
2267
|
+
|
|
2268
|
+
## 1.3.0
|
|
2269
|
+
|
|
2270
|
+
### Minor Changes
|
|
2271
|
+
|
|
2272
|
+
- 4a4b1c4: feat(core): app-wide default `onValidationError` + `kind` argument
|
|
2273
|
+
|
|
2274
|
+
`server({ onValidationError })` now sets an app-wide fallback that fires when a route **without its own**
|
|
2275
|
+
`onValidationError` fails body/query validation - one place to define your error envelope instead of repeating
|
|
2276
|
+
it per route (like tRPC's `errorFormatter` / Fastify's `setErrorHandler`), while a route's own hook still
|
|
2277
|
+
takes precedence. A route can fall through to the plain `422` by returning `undefined`.
|
|
2278
|
+
|
|
2279
|
+
The hook (route-level and app-level) now also receives a third argument, `kind: "body" | "query"`, telling it
|
|
2280
|
+
which input failed - backward-compatible (existing 2-arg hooks are unaffected). The healed-value re-validation
|
|
2281
|
+
contract is unchanged: an app-level default that returns a repaired value is re-validated against the route's
|
|
2282
|
+
schema before the handler runs.
|
|
2283
|
+
|
|
2284
|
+
- 4a4b1c4: feat: `server().resource()` / `.prompt()` - app-declared MCP resources & prompts
|
|
2285
|
+
|
|
2286
|
+
Completing the MCP trio alongside `.tool()`: an app can now expose its own MCP **resources**
|
|
2287
|
+
(`.resource(uri, { name, description?, mimeType? }, read)`) and **prompts** (`.prompt(name, { description,
|
|
2288
|
+
arguments? }, handler)`). `nifra mcp` surfaces them in `resources/list` + `resources/read` and `prompts/list`
|
|
2289
|
+
|
|
2290
|
+
- `prompts/get` (namespaced per app in a monorepo). The `read`/`handler` closures run in the app process, so
|
|
2291
|
+
they capture whatever app state they need - no HTTP round-trip.
|
|
2292
|
+
|
|
2293
|
+
- 4a4b1c4: feat: MCP tool annotations on `server().tool()`
|
|
2294
|
+
|
|
2295
|
+
`.tool()` config now accepts `annotations` - the MCP spec's per-tool safety hints (`title`, `readOnlyHint`,
|
|
2296
|
+
`destructiveHint`, `idempotentHint`, `openWorldHint`) - surfaced in `tools/list` and `tools/describe`. An
|
|
2297
|
+
agent can now tell a read-only tool from a destructive one and decide whether to auto-invoke or confirm
|
|
2298
|
+
first, instead of treating every exposed tool as equally risky.
|
|
2299
|
+
|
|
2300
|
+
- 4a4b1c4: feat: `errors` response contract on routes + typed client error bodies
|
|
2301
|
+
|
|
2302
|
+
A route's `RouteSchema` may now declare `errors` - a `{ status → Standard Schema }` map of its failure modes.
|
|
2303
|
+
Like `response`, it's a compile-time + introspection contract (not validated at runtime, zero hot-path cost):
|
|
2304
|
+
the declared error bodies flow into OpenAPI as non-2xx `responses` and into the `/llms.txt` context, so
|
|
2305
|
+
tooling and coding agents can read the _whole_ contract, not just the happy path.
|
|
2306
|
+
|
|
2307
|
+
The **typed client** now surfaces them: on a failure `Result`, `data` is the parsed error body typed from the
|
|
2308
|
+
route's `errors` (a union across declared statuses; `unknown` when none declared), discriminated by `ok`.
|
|
2309
|
+
`error` remains the normalized `{ error, issues }` summary. The **decoupled contract client**
|
|
2310
|
+
(`client(contract, url)`) gets the same treatment - its failure `data` is typed from the op's non-2xx
|
|
2311
|
+
`responses` schemas.
|
|
2312
|
+
|
|
2313
|
+
**Behavior change:** on failure, `data` is now the parsed error response body (previously always `null`) - so
|
|
2314
|
+
`const { ok, data } = await api.orders.post(...)` gives you the typed error body in the `!ok` branch. `data`
|
|
2315
|
+
is still `null` only on a transport error (status `0`, no response).
|
|
2316
|
+
|
|
2317
|
+
- 4a4b1c4: feat(core): `onValidationError` route hook + `server().tool()`
|
|
2318
|
+
|
|
2319
|
+
Two additions for building agent-facing endpoints on Nifra:
|
|
2320
|
+
|
|
2321
|
+
- **`onValidationError` route hook**: a `RouteSchema` callback that runs when a request fails schema
|
|
2322
|
+
validation. It receives the Standard Schema issues and the request context, and may return a `Response`
|
|
2323
|
+
(short-circuit the route), a repaired payload (re-validated before dispatch - still invalid → the original
|
|
2324
|
+
`422` stands), or `undefined` (keep the `422`). Makes validation recovery pluggable instead of every
|
|
2325
|
+
handler re-checking by hand.
|
|
2326
|
+
|
|
2327
|
+
- **`server().tool(name, config, handler)`**: register a `.tool()` route (`POST /_nifra/tool/:name`) with
|
|
2328
|
+
typed `input`/`output` Standard Schemas. The handler's `input` is inferred from the input schema; the
|
|
2329
|
+
descriptor is tagged as an MCP tool so `nifra mcp` exposes it in `tools/list`.
|
|
2330
|
+
|
|
2331
|
+
## 1.2.2
|
|
2332
|
+
|
|
2333
|
+
## 1.2.1
|
|
2334
|
+
|
|
2335
|
+
## 1.2.0
|
|
2336
|
+
|
|
2337
|
+
### Minor Changes
|
|
2338
|
+
|
|
2339
|
+
- 0ac2182: feat(core): validation failures now return **422**, plus a params-decode fast path
|
|
2340
|
+
|
|
2341
|
+
**Behavior change:** a request that fails a route's `body`/`query` schema validation is now rejected
|
|
2342
|
+
with **`422 Unprocessable Entity`** (previously `400`). The response body shape is unchanged
|
|
2343
|
+
(`{ ok: false, error: "validation", issues }`). If your client branches on `status === 400` for
|
|
2344
|
+
validation failures, switch it to `422`. Genuinely malformed requests keep their existing codes -
|
|
2345
|
+
invalid JSON via `boundedJson` and an undecodable path are still `400`.
|
|
2346
|
+
|
|
2347
|
+
Also: route params skip the `decodeURIComponent` pass entirely when the pathname contains no `%`
|
|
2348
|
+
(the overwhelmingly common case) - same behavior, less per-request work, on both the HTTP and
|
|
2349
|
+
WebSocket-upgrade paths.
|
|
2350
|
+
|
|
2351
|
+
## 1.1.0
|
|
2352
|
+
|
|
2353
|
+
## 1.0.0
|
|
2354
|
+
|
|
2355
|
+
### Minor Changes
|
|
2356
|
+
|
|
2357
|
+
- f1f0e18: Context ergonomics, from beta feedback building on Nifra.
|
|
2358
|
+
|
|
2359
|
+
- **`c.json(body, status?)` / `c.text(body, status?)`** - build a `Response` in one line; the second arg is a status number or a full `ResponseInit`, and it works whether you `return` or `throw` it. Ideal for an auth / rate-limit short-circuit from a `derive`/`beforeHandle`: `throw c.json({ error: "unauthorized" }, 401)` instead of `new Response(JSON.stringify(…), { status: 401, headers: … })`. (In a route's happy path keep returning a plain object so the typed client stays in sync.) Added as prototype methods - no per-request allocation.
|
|
2360
|
+
- **One name for the request across routes and loaders.** A route handler's `c.req` is now also `c.request`, and a page loader/action's `ctx.request` is now also `ctx.req` - fixing the `c.req`-vs-`ctx.request` mismatch that was easy to trip over.
|
|
2361
|
+
|
|
2362
|
+
Docs: the API page documents `c.json`/`c.text` + the request alias; a new troubleshooting entry covers a `never` typed client (raw-`Response` return, or a non-identity plugin → `defineIdentityPlugin`).
|
|
2363
|
+
|
|
2364
|
+
- 3efb7cd: Sharper types + names for two footguns hit building on Nifra.
|
|
2365
|
+
|
|
2366
|
+
- **`defineRouterPlugin`** - a clearer-named alias of `defineIdentityPlugin` for a plugin that mounts routes/hooks but adds **no context type** (an auth router, an audit logger). `definePlugin`'s docs now loudly warn that using it for such a plugin silently collapses the typed client to `any` (no type error, no runtime error). The plugins guide leads with `defineRouterPlugin` and shows the side-effect-then-`return app` mount pattern.
|
|
2367
|
+
- **Better error when a route has no `query` schema.** Passing `query` to such a route via the typed client now fails with a message that reads out the fix - `add a \`query\` schema to this route - { query: z.object({ … }) } - so the typed client can accept query params here`- instead of the opaque`not assignable to type 'never'`. The error surfaces at the call site; the fix is at the route. Non-breaking: passing query to a schema-less route was already rejected, just unhelpfully.
|
|
2368
|
+
|
|
2369
|
+
- de9675b: Pre-1.0 security hardening pass. A framework-wide audit found no critical/high issues; these close the medium/low items it surfaced.
|
|
2370
|
+
|
|
2371
|
+
- **`cache()` - no cross-user leak by default.** A `200` to a request bearing `Authorization`/`Cookie` is no longer stored (and replayed to other users) unless the response is explicitly `Cache-Control: public`/`s-maxage` (RFC 9111 §3.5). Opt back in per cache with `cacheAuthenticated: true` for a route that's identical for every caller.
|
|
2372
|
+
- **`idempotency()` - route-scoped keys + a `key` hook.** The default store key is now scoped by method+path, so the same `Idempotency-Key` on a different endpoint can't collide and replay another resource's response. Added a `key(req, header)` option to scope by principal (e.g. user id). Method matching normalized to upper-case.
|
|
2373
|
+
- **`etag()` - a `304` no longer carries the `200`'s `Content-Length`/`Content-Type`.**
|
|
2374
|
+
- **`@nifrajs/core` - inbound WebSocket frames are capped** when serving on Bun (`listen()`): frames over `wsMaxPayloadBytes` (default `maxBodyBytes`, 1 MB) are rejected by the runtime before reaching a handler, so a huge frame can't be buffered/parsed into memory. New `ServerOptions.wsMaxPayloadBytes`.
|
|
2375
|
+
- **`@nifrajs/core` - WebSocket routes are same-origin by default (CSWSH).** A `ws()` route with no `allowedOrigins` now rejects a **cross-origin browser** handshake (an `Origin` whose host differs from the request's) with `403` - closing cross-site WebSocket hijacking, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser clients (no `Origin`) and same-origin browsers are unaffected. **Breaking** for a route that served a cross-origin browser without declaring `allowedOrigins`: set `allowedOrigins` to the permitted origins (or `() => true` for a genuinely public socket).
|
|
2376
|
+
- **`@nifrajs/node` - static file handler** now adds `X-Content-Type-Options: nosniff` and re-checks the real path (symlink containment) before streaming, matching the image server.
|
|
2377
|
+
- **`@nifrajs/mcp` - widget bridge** now rejects `postMessage` events whose source isn't the parent window (including null-source synthetic events), closing a spoofing gap the previous guard left open.
|
|
2378
|
+
- **`@nifrajs/cli` - the MCP `nifra_run`/`nifra_ws` `entry` arg** is kept inside the project root, so a crafted `entry` can't import/execute a module outside the project.
|
|
2379
|
+
|
|
2380
|
+
## 1.0.0-beta.4
|
|
2381
|
+
|
|
2382
|
+
## 1.0.0-beta.3
|
|
2383
|
+
|
|
2384
|
+
## 0.1.0-beta.2
|