@nifrajs/core 2.12.1 → 2.14.1
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/dist/binary.d.ts +32 -0
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +17 -0
- package/dist/binary.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +21 -50
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts.map +1 -1
- package/dist/durable-execution.js +49 -69
- package/dist/durable-execution.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/durable-approval-protocol.d.ts +13 -0
- package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
- package/dist/internal/durable-approval-protocol.js +66 -0
- package/dist/internal/durable-approval-protocol.js.map +1 -0
- package/dist/internal/durable-effect-protocol.d.ts +14 -0
- package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
- package/dist/internal/durable-effect-protocol.js +16 -0
- package/dist/internal/durable-effect-protocol.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts +8 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
- package/dist/internal/durable-saga-protocol.js +21 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -0
- package/dist/internal/route-catalog.d.ts +3 -0
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +5 -0
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/range.d.ts.map +1 -1
- package/dist/range.js +4 -2
- package/dist/range.js.map +1 -1
- package/dist/server/body.d.ts +5 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +26 -9
- package/dist/server/body.js.map +1 -1
- package/dist/server/plugin.d.ts +1 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/registry.d.ts +11 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +12 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +132 -20
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +1 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/server-types.d.ts +15 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +43 -9
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +131 -6
- package/dist/server/server.js.map +1 -1
- package/package.json +1 -1
- package/src/binary.ts +35 -0
- package/src/durable-adapters.ts +26 -51
- package/src/durable-execution.ts +46 -88
- package/src/index.ts +1 -1
- package/src/internal/durable-approval-protocol.ts +92 -0
- package/src/internal/durable-effect-protocol.ts +37 -0
- package/src/internal/durable-saga-protocol.ts +55 -0
- package/src/internal/route-catalog.ts +6 -0
- package/src/range.ts +3 -1
- package/src/server/body.ts +25 -8
- package/src/server/plugin.ts +1 -1
- package/src/server/registry.ts +19 -4
- package/src/server/request-context.ts +138 -22
- package/src/server/respond.ts +1 -1
- package/src/server/server-types.ts +15 -0
- package/src/server/server.ts +182 -10
package/src/server/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BinaryResponse, RawResponse } from "../binary.ts"
|
|
1
2
|
import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
|
|
2
3
|
import type { Params, RouteSchema } from "./context.ts"
|
|
3
4
|
|
|
@@ -79,12 +80,26 @@ export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
|
|
|
79
80
|
readonly sse: RegistrySse<S>
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The client-visible output of a handler: its awaited return, with an UNbranded raw `Response` removed
|
|
85
|
+
* (a route returning a bare `Response` has no describable body). A response branded by `bytes()`
|
|
86
|
+
* ({@link BinaryResponse}) or `raw()` ({@link RawResponse}) is KEPT, so the brand reaches the client's
|
|
87
|
+
* `Jsonify` and types `data` as `Blob` / `Jsonify<T>` rather than collapsing to `never`.
|
|
88
|
+
*/
|
|
89
|
+
export type OutputOf<H extends (...args: never[]) => unknown> = KeepBrandedResponse<
|
|
90
|
+
Awaited<ReturnType<H>>
|
|
86
91
|
>
|
|
87
92
|
|
|
93
|
+
/** Preserve a `bytes()`/`raw()`-branded response; drop only a bare `Response`. Distributes over a
|
|
94
|
+
* union return, so `Foo | BinaryResponse` keeps both arms. */
|
|
95
|
+
type KeepBrandedResponse<O> = O extends BinaryResponse
|
|
96
|
+
? O
|
|
97
|
+
: O extends RawResponse<unknown>
|
|
98
|
+
? O
|
|
99
|
+
: O extends Response
|
|
100
|
+
? never
|
|
101
|
+
: O
|
|
102
|
+
|
|
88
103
|
/**
|
|
89
104
|
* The registry entry for a WebSocket route (stored under the pseudo-method key `"WS"`, so WS routes
|
|
90
105
|
* ride the same path → method map as HTTP routes - no extra Server generic). `in` is the client →
|
|
@@ -19,6 +19,7 @@ import { headerObjectOf } from "./headers.ts"
|
|
|
19
19
|
import { jsonError } from "./http.ts"
|
|
20
20
|
import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
|
|
21
21
|
import { searchOf } from "./query.ts"
|
|
22
|
+
import { responseJsonContentType } from "./respond.ts"
|
|
22
23
|
import {
|
|
23
24
|
CONTEXT_SEARCH,
|
|
24
25
|
CONTEXT_SET,
|
|
@@ -88,6 +89,80 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
|
|
|
88
89
|
return typeof init === "number" ? { status: init } : init
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Whether a body built here should be deferred instead of turned into a `Response` on the spot.
|
|
94
|
+
*
|
|
95
|
+
* Only the Node bridge can use a deferred body: it writes status + headers + bytes straight to the
|
|
96
|
+
* socket, so the undici `Response` those two helpers used to build (~2us, about a quarter of the
|
|
97
|
+
* request budget on a small text response) was pure loss. Bun and Deno hand the `Response` to their
|
|
98
|
+
* native server, where nothing reads the deferral. Probed once at module load, not per response.
|
|
99
|
+
*/
|
|
100
|
+
const DEFERS_RESPONSE =
|
|
101
|
+
typeof (globalThis as { Deno?: unknown }).Deno === "undefined" &&
|
|
102
|
+
typeof (globalThis as { Bun?: unknown }).Bun === "undefined" &&
|
|
103
|
+
typeof (globalThis as { process?: { versions?: { node?: unknown } } }).process?.versions?.node ===
|
|
104
|
+
"string"
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The Node adapter's deferred-response factory, received over the shared-symbol seam rather than a
|
|
108
|
+
* package import - `@nifrajs/node` stays dependency-free from core's runtime and talks to it only
|
|
109
|
+
* through `Symbol.for` marks, so the stand-in it builds (and the ~0.4 KB gzip the class costs) never
|
|
110
|
+
* ships in a Bun/Deno bundle that will never run this branch. `c.json`/`c.text` route the Node fast
|
|
111
|
+
* lane through it: given a status, an owned header record, and the bytes, it returns a `Response`-shaped
|
|
112
|
+
* value the bridge writes straight to the socket (no undici `Response` built, no body stream drained).
|
|
113
|
+
* Absent - no adapter loaded - the two helpers fall back to a real `Response`. Cached on first read;
|
|
114
|
+
* the adapter registers at its own module load, before any request reaches a handler.
|
|
115
|
+
*/
|
|
116
|
+
const DEFERRED_RESPONDER_KEY = Symbol.for("nifra.deferred.responder")
|
|
117
|
+
type DeferredResponder = (body: string, status: number, headers: Record<string, string>) => Response
|
|
118
|
+
let deferredResponder: DeferredResponder | undefined
|
|
119
|
+
function deferredResponderFor(): DeferredResponder | undefined {
|
|
120
|
+
if (deferredResponder === undefined) {
|
|
121
|
+
deferredResponder = (globalThis as unknown as Record<symbol, DeferredResponder | undefined>)[
|
|
122
|
+
DEFERRED_RESPONDER_KEY
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
return deferredResponder
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A header record the deferred response can own outright - a fresh object with lowercase names, since
|
|
130
|
+
* the Node writer mutates it in place to declare `content-length`. `undefined` means the caller's
|
|
131
|
+
* shape (a `Headers`, an entries array) is not worth walking here: those take the `Response` path.
|
|
132
|
+
*/
|
|
133
|
+
function ownHeaderRecord(
|
|
134
|
+
// `unknown`: the lib's `ResponseInit.headers` is a runtime-specific `HeadersInit` (Bun's vs
|
|
135
|
+
// undici's differ), and this only needs to recognize the plain-record case anyway.
|
|
136
|
+
headers: unknown,
|
|
137
|
+
contentType: string,
|
|
138
|
+
): Record<string, string> | undefined {
|
|
139
|
+
if (headers === undefined) return { "content-type": contentType }
|
|
140
|
+
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return undefined
|
|
141
|
+
if (headers instanceof Headers) return undefined
|
|
142
|
+
const record: Record<string, string> = {}
|
|
143
|
+
let hasContentType = false
|
|
144
|
+
for (const name of Object.keys(headers)) {
|
|
145
|
+
const lower = name.toLowerCase()
|
|
146
|
+
if (lower === "content-type") hasContentType = true
|
|
147
|
+
record[lower] = (headers as Record<string, string>)[name] as string
|
|
148
|
+
}
|
|
149
|
+
if (!hasContentType) record["content-type"] = contentType
|
|
150
|
+
return record
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The content-type the runtime's own `Response` puts on a string body, read off it once. A deferred
|
|
154
|
+
* text response fills it in itself, so it must fill in exactly what the `Response` would have. */
|
|
155
|
+
let stringBodyContentType: string | undefined
|
|
156
|
+
function responseTextContentType(): string {
|
|
157
|
+
stringBodyContentType ??=
|
|
158
|
+
new Response("").headers.get("content-type") ?? "text/plain;charset=UTF-8"
|
|
159
|
+
return stringBodyContentType
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `c.text`'s own default, unchanged: the explicit one nifra has always declared when the caller
|
|
163
|
+
* passes no headers of their own. */
|
|
164
|
+
const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
165
|
+
|
|
91
166
|
export class RequestContext implements RawContext {
|
|
92
167
|
// `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
|
|
93
168
|
// constructor initializes only the eager request state, while lazy fields remain absent until used.
|
|
@@ -234,15 +309,53 @@ export class RequestContext implements RawContext {
|
|
|
234
309
|
this.headersValue = value
|
|
235
310
|
}
|
|
236
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Return a JSON body. Prefer this (and {@link text}) over a hand-rolled `return new Response(...)`:
|
|
314
|
+
* on Node these helpers hand the adapter a status, a header record, and the bytes directly, so the
|
|
315
|
+
* Web `Response` is deferred and the reply lands on the fastest write lane. A raw `new Response` is
|
|
316
|
+
* fully supported and returns identical bytes, but it is the last-resort lane - the adapter has to
|
|
317
|
+
* drain its body stream, which no direct-write helper pays.
|
|
318
|
+
*/
|
|
237
319
|
json(body: unknown, init?: ResponseInit | number): Response {
|
|
238
|
-
|
|
320
|
+
const i = statusInit(init)
|
|
321
|
+
if (!DEFERS_RESPONSE) return Response.json(body, i)
|
|
322
|
+
const defer = deferredResponderFor()
|
|
323
|
+
if (defer === undefined) return Response.json(body, i)
|
|
324
|
+
// The same bytes `Response.json` would produce, kept one step short of it so the direct writer
|
|
325
|
+
// can have them. `JSON.stringify` returns `undefined` for a value with no JSON form (`undefined`,
|
|
326
|
+
// a function, a symbol) - exactly the case where `Response.json` throws, so hand it back the
|
|
327
|
+
// throw rather than inventing a body. The content-type is the runtime's own, read off
|
|
328
|
+
// `Response.json` once, so a deferred response carries the same one byte for byte.
|
|
329
|
+
const text = JSON.stringify(body) as string | undefined
|
|
330
|
+
if (text === undefined) return Response.json(body, i)
|
|
331
|
+
const headers = ownHeaderRecord(i?.headers, responseJsonContentType())
|
|
332
|
+
if (headers === undefined) return Response.json(body, i)
|
|
333
|
+
return defer(text, i?.status ?? 200, headers)
|
|
239
334
|
}
|
|
240
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Return a text body. The fast lane on Node - see {@link json}. Reach for a raw `return new
|
|
338
|
+
* Response(body)` only when you need a Response shape these helpers do not build; it costs the
|
|
339
|
+
* adapter a body-stream drain that this path skips.
|
|
340
|
+
*/
|
|
241
341
|
text(body: string, init?: ResponseInit | number): Response {
|
|
242
342
|
const i = statusInit(init)
|
|
343
|
+
if (DEFERS_RESPONSE) {
|
|
344
|
+
const defer = deferredResponderFor()
|
|
345
|
+
if (defer !== undefined) {
|
|
346
|
+
// The content-type each shape would have ended up with: nifra's declared default when the
|
|
347
|
+
// caller brought no headers, the runtime's own string-body default when they did (there the
|
|
348
|
+
// caller owns the content-type, and only fills in for one they did not set).
|
|
349
|
+
const headers = ownHeaderRecord(
|
|
350
|
+
i?.headers,
|
|
351
|
+
i?.headers === undefined ? TEXT_CONTENT_TYPE : responseTextContentType(),
|
|
352
|
+
)
|
|
353
|
+
if (headers !== undefined) return defer(body, i?.status ?? 200, headers)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
243
356
|
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
244
357
|
if (i?.headers !== undefined) return new Response(body, i)
|
|
245
|
-
return new Response(body, { ...i, headers: { "content-type":
|
|
358
|
+
return new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } })
|
|
246
359
|
}
|
|
247
360
|
|
|
248
361
|
get query(): unknown {
|
|
@@ -386,31 +499,34 @@ export function readBoundedJsonSource<T>(
|
|
|
386
499
|
return onResult === undefined
|
|
387
500
|
? Promise.resolve(jsonError(413, "payload_too_large"))
|
|
388
501
|
: Promise.resolve(jsonError(413, "payload_too_large")).then(onResult)
|
|
502
|
+
// Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
|
|
503
|
+
// delimited the bytes, so the declared length IS the transport frame and chunked routing is
|
|
504
|
+
// moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
|
|
505
|
+
// decode+parse straight away, before the transfer-encoding probe that only exists to route
|
|
506
|
+
// *untrusted* Node/adapter sources to the streaming lane. Skipping that probe drops one
|
|
507
|
+
// `Headers.get()` from every Bun/Deno JSON POST; Node/adapter sources are never marked and fall
|
|
508
|
+
// through below. The parsed-value poisoning walk still applies.
|
|
509
|
+
if (hasTrustedBodyFraming(req)) {
|
|
510
|
+
return raw.json().then(
|
|
511
|
+
(parsed) => {
|
|
512
|
+
let guarded: unknown
|
|
513
|
+
try {
|
|
514
|
+
guarded = guardParsedValue(parsed, protoPoisoning)
|
|
515
|
+
} catch {
|
|
516
|
+
return onResult === undefined
|
|
517
|
+
? jsonError(400, "invalid_json")
|
|
518
|
+
: onResult(jsonError(400, "invalid_json"))
|
|
519
|
+
}
|
|
520
|
+
return onResult === undefined ? guarded : onResult(guarded)
|
|
521
|
+
},
|
|
522
|
+
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
523
|
+
)
|
|
524
|
+
}
|
|
389
525
|
const chunked =
|
|
390
526
|
(sourceHeader === undefined
|
|
391
527
|
? req.headers.get("transfer-encoding")
|
|
392
528
|
: sourceHeader.call(req, "transfer-encoding")) !== null
|
|
393
529
|
if (!chunked) {
|
|
394
|
-
// Bun's compiled native route table receives a Request whose bytes were already delimited by
|
|
395
|
-
// Bun's HTTP parser. Keep the exact-byte path for every portable/adapted source, but preserve
|
|
396
|
-
// Bun's fused decode+parse on this trusted ingress; the declared length is the transport frame
|
|
397
|
-
// there, not an attacker-controlled hint. The parsed-value poisoning walk still applies.
|
|
398
|
-
if (hasTrustedBodyFraming(req)) {
|
|
399
|
-
return raw.json().then(
|
|
400
|
-
(parsed) => {
|
|
401
|
-
let guarded: unknown
|
|
402
|
-
try {
|
|
403
|
-
guarded = guardParsedValue(parsed, protoPoisoning)
|
|
404
|
-
} catch {
|
|
405
|
-
return onResult === undefined
|
|
406
|
-
? jsonError(400, "invalid_json")
|
|
407
|
-
: onResult(jsonError(400, "invalid_json"))
|
|
408
|
-
}
|
|
409
|
-
return onResult === undefined ? guarded : onResult(guarded)
|
|
410
|
-
},
|
|
411
|
-
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
412
|
-
)
|
|
413
|
-
}
|
|
414
530
|
const jsonWithByteLength = raw.jsonWithByteLength
|
|
415
531
|
if (jsonWithByteLength !== undefined) {
|
|
416
532
|
return jsonWithByteLength.call(raw).then(
|
package/src/server/respond.ts
CHANGED
|
@@ -150,7 +150,7 @@ function withStaticHeaderInit(
|
|
|
150
150
|
const IS_DENO = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
151
151
|
let denoJsonContentType: string | undefined
|
|
152
152
|
|
|
153
|
-
function responseJsonContentType(): string {
|
|
153
|
+
export function responseJsonContentType(): string {
|
|
154
154
|
if (denoJsonContentType === undefined) {
|
|
155
155
|
denoJsonContentType = Response.json(0).headers.get("content-type") ?? "application/json"
|
|
156
156
|
}
|
|
@@ -144,6 +144,21 @@ export interface ServerOptions {
|
|
|
144
144
|
* Fastify's `setErrorHandler`) instead of repeating a formatter per route.
|
|
145
145
|
*/
|
|
146
146
|
readonly onValidationError?: RouteSchema["onValidationError"]
|
|
147
|
+
/**
|
|
148
|
+
* What to do when an order-scoped hook (`derive`/`beforeHandle`/`around`/`aroundCapability`/
|
|
149
|
+
* `afterHandle`/`onError`/`decorate`) covers no route because it was added after the last route was
|
|
150
|
+
* registered. `register()` snapshots the current chain into each route as it is declared, so a hook
|
|
151
|
+
* appended after every route reaches nothing and is silently dead. `"warn"` (default) logs once at
|
|
152
|
+
* seal (first `listen()`/`fetch`/`resolveNode`); `"error"` throws a `FrameworkError`
|
|
153
|
+
* (`UNUSED_SCOPED_HOOKS`); `"off"` skips the bookkeeping entirely, including the one-frame stack
|
|
154
|
+
* capture. App-global hooks (`onRequest`/`onResponse`/...) are unaffected in every mode.
|
|
155
|
+
*
|
|
156
|
+
* This is a development-time diagnostic: the whole audit is guarded by `process.env.NODE_ENV !==
|
|
157
|
+
* "production"`, so any bundler that defines `NODE_ENV` (vite/esbuild/webpack/next, `bun build
|
|
158
|
+
* --production`) dead-code-eliminates it, leaving zero bytes and zero per-request cost in the shipped
|
|
159
|
+
* bundle. It fires in development, test, and CI - where you catch the mistake - not in production.
|
|
160
|
+
*/
|
|
161
|
+
readonly unusedScopedHooks?: "warn" | "error" | "off"
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
/** A fetch-compatible handler used by {@link Server.mountFetch}. */
|
package/src/server/server.ts
CHANGED
|
@@ -49,7 +49,12 @@ import type {
|
|
|
49
49
|
StandardResult,
|
|
50
50
|
StandardSchemaV1,
|
|
51
51
|
} from "../schema/standard.ts"
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
assertByteLimit,
|
|
54
|
+
markTransportCap,
|
|
55
|
+
markTrustedBodyFraming,
|
|
56
|
+
type RawBodyReaders,
|
|
57
|
+
} from "./body.ts"
|
|
53
58
|
import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
|
|
54
59
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
55
60
|
import {
|
|
@@ -223,7 +228,12 @@ import {
|
|
|
223
228
|
import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
|
|
224
229
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
225
230
|
import type { McpRuntime } from "./mcp-hook.ts"
|
|
226
|
-
import type {
|
|
231
|
+
import type {
|
|
232
|
+
ContextPlugin,
|
|
233
|
+
IdentityPlugin,
|
|
234
|
+
PluginTypeCollapsed,
|
|
235
|
+
ServerTypeUnpinned,
|
|
236
|
+
} from "./plugin.ts"
|
|
227
237
|
import type {
|
|
228
238
|
AddRoute,
|
|
229
239
|
EmptyRegistry,
|
|
@@ -285,6 +295,11 @@ export interface RequestSource {
|
|
|
285
295
|
/** Present only when materializing a `Request` is non-trivial (the Node lazy source); for a real
|
|
286
296
|
* `Request` passed as the source it's absent and {@link requestOf} returns the source itself. */
|
|
287
297
|
readonly request?: Request
|
|
298
|
+
/** Optional pre-`Request` reader surface for the transport cap. A source that reads its own
|
|
299
|
+
* transport bytes (the Node lazy source reads the socket) exposes them here so a capped direct
|
|
300
|
+
* read (`c.req.json()`) buffers off the transport instead of routing through the deferred
|
|
301
|
+
* `Request`. `body` must stay the live stream - the cap still guards a chunked body mid-flight. */
|
|
302
|
+
rawBodyReaders?(): RawBodyReaders
|
|
288
303
|
}
|
|
289
304
|
|
|
290
305
|
/** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
|
|
@@ -566,6 +581,104 @@ export type NifraFeatureVersion = FeatureVersionOf<Version>
|
|
|
566
581
|
const IDENTITY_RESPONSE = (response: Response): Response => response
|
|
567
582
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
568
583
|
|
|
584
|
+
// The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
|
|
585
|
+
// diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
|
|
586
|
+
// branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
|
|
587
|
+
// define only dead-code-eliminates the audit when the literal appears at the `if`; Bun does not
|
|
588
|
+
// propagate a module const's value into the branch. Every real production bundler (vite/esbuild/webpack/
|
|
589
|
+
// next, `bun build --production`) injects that define, turning each guard into `hookAuditRuntime &&
|
|
590
|
+
// false`, which folds away - dropping the whole audit and restoring the bare kernel size.
|
|
591
|
+
//
|
|
592
|
+
// `hookAuditRuntime` is the one shared flag: `typeof globalThis.Deno === "undefined"`. Deno ships a
|
|
593
|
+
// node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
|
|
594
|
+
// throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
|
|
595
|
+
// off) while a production define still folds `<flag> && false` to `false` on every other runtime.
|
|
596
|
+
const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
|
|
597
|
+
|
|
598
|
+
// This module's own path, captured once from a sentinel stack. Every internal frame of an
|
|
599
|
+
// order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
|
|
600
|
+
// so `captureCallerSite` skips anything starting with it and lands on the user's calling frame.
|
|
601
|
+
const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
|
|
602
|
+
const frame = new Error().stack?.split("\n")[1]
|
|
603
|
+
return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
|
|
604
|
+
})()
|
|
605
|
+
|
|
606
|
+
/** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
|
|
607
|
+
* report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
|
|
608
|
+
* non-trivial cost) is never constructed when the check is disabled. */
|
|
609
|
+
const captureCallerSite = (): string | undefined => {
|
|
610
|
+
const stack = new Error().stack
|
|
611
|
+
if (stack === undefined) return undefined
|
|
612
|
+
for (const line of stack.split("\n").slice(1)) {
|
|
613
|
+
const frame = line.match(/\(?([^()\s]+:\d+:\d+)\)?\s*$/)?.[1]
|
|
614
|
+
if (frame === undefined) continue
|
|
615
|
+
if (SERVER_MODULE_PATH !== undefined && frame.startsWith(`${SERVER_MODULE_PATH}:`)) continue
|
|
616
|
+
return frame
|
|
617
|
+
}
|
|
618
|
+
return undefined
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Per-server bookkeeping for the unused-order-scoped-hook audit. Deliberately NOT `Server` fields or
|
|
622
|
+
* methods: those emit class members (a field declaration, a method shell) that a bundler cannot remove
|
|
623
|
+
* even after their bodies fold away. Held here in a module WeakMap and reached only through the
|
|
624
|
+
* functions below, every call guarded by `process.env.NODE_ENV !== "production"`, so a production define
|
|
625
|
+
* folds the guards away, leaves this map and these functions unreferenced, and the bundler eliminates
|
|
626
|
+
* the whole audit - restoring the bare kernel size. */
|
|
627
|
+
interface HookAuditState {
|
|
628
|
+
readonly policy: "warn" | "error" | "off"
|
|
629
|
+
readonly hasCustomLogger: boolean
|
|
630
|
+
readonly sites: {
|
|
631
|
+
readonly kind: string
|
|
632
|
+
readonly routesAtPush: number
|
|
633
|
+
readonly site: string | undefined
|
|
634
|
+
}[]
|
|
635
|
+
checked: boolean
|
|
636
|
+
}
|
|
637
|
+
const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
|
|
638
|
+
|
|
639
|
+
/** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
|
|
640
|
+
const beginHookAudit = (
|
|
641
|
+
key: object,
|
|
642
|
+
policy: "warn" | "error" | "off",
|
|
643
|
+
hasCustomLogger: boolean,
|
|
644
|
+
): void => {
|
|
645
|
+
hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false })
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
|
|
649
|
+
* seal means no route was declared after it) and the caller's frame. */
|
|
650
|
+
const recordScopedHook = (key: object, routesAtPush: number, kind: string): void => {
|
|
651
|
+
const state = hookAudit.get(key)
|
|
652
|
+
if (state === undefined || state.policy === "off") return
|
|
653
|
+
state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
|
|
657
|
+
* had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
|
|
658
|
+
* only later routes) always leaves at least one such route. Zero-route apps are exempt (different
|
|
659
|
+
* mistake, and the warning would be noise). */
|
|
660
|
+
const sealHookAudit = (
|
|
661
|
+
key: object,
|
|
662
|
+
total: number,
|
|
663
|
+
logger: { warn(message: string): void },
|
|
664
|
+
): void => {
|
|
665
|
+
const state = hookAudit.get(key)
|
|
666
|
+
if (state === undefined || state.checked) return
|
|
667
|
+
state.checked = true
|
|
668
|
+
if (state.policy === "off" || total === 0) return
|
|
669
|
+
const dead = state.sites.filter((hook) => hook.routesAtPush === total)
|
|
670
|
+
if (dead.length === 0) return
|
|
671
|
+
const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
|
|
672
|
+
const message =
|
|
673
|
+
`[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
|
|
674
|
+
`last route was registered, and register() snapshots the chain into each route as it is ` +
|
|
675
|
+
`declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
|
|
676
|
+
`(onRequest/onResponse) are not order-scoped and can be added at any time.`
|
|
677
|
+
if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
|
|
678
|
+
if (state.hasCustomLogger) logger.warn(message)
|
|
679
|
+
else console.warn(message)
|
|
680
|
+
}
|
|
681
|
+
|
|
569
682
|
/**
|
|
570
683
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
571
684
|
* `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
|
|
@@ -734,6 +847,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
734
847
|
this.defaultOnValidationError = options.onValidationError
|
|
735
848
|
this.bunServer = undefined
|
|
736
849
|
this.sealed = false
|
|
850
|
+
// Guarded so a production define strips the call, leaving `beginHookAudit`/`hookAudit` unreferenced
|
|
851
|
+
// for the bundler to eliminate; a non-bundled production run skips it the same way.
|
|
852
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
853
|
+
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
|
|
854
|
+
}
|
|
737
855
|
this.derives = []
|
|
738
856
|
this.decorations = {}
|
|
739
857
|
this.beforeHandleHooks = []
|
|
@@ -802,17 +920,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
802
920
|
return this
|
|
803
921
|
}
|
|
804
922
|
|
|
805
|
-
/**
|
|
923
|
+
/**
|
|
924
|
+
* Add a per-request, computed context extension. Order-scoped: it is snapshotted into every route
|
|
925
|
+
* declared *after* this call, and reaches none declared before. One added after the last route
|
|
926
|
+
* (e.g. on the app returned by a route-registering factory) covers nothing - see
|
|
927
|
+
* {@link ServerOptions.unusedScopedHooks}.
|
|
928
|
+
*/
|
|
806
929
|
derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
|
|
807
930
|
this.assertConfigurable("derive()")
|
|
808
931
|
this.derives.push(fn as unknown as RawDerive)
|
|
932
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
933
|
+
recordScopedHook(this, this.catalog.size, "derive")
|
|
809
934
|
return this as unknown as Server<R, Ctx & D>
|
|
810
935
|
}
|
|
811
936
|
|
|
812
|
-
/**
|
|
937
|
+
/**
|
|
938
|
+
* Add a static context value. Order-scoped: captured by every route declared *after* this call, and
|
|
939
|
+
* by none before; one added after the last route reaches nothing
|
|
940
|
+
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
941
|
+
*/
|
|
813
942
|
decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
|
|
814
943
|
this.assertConfigurable("decorate()")
|
|
815
944
|
this.decorations[key] = value
|
|
945
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
946
|
+
recordScopedHook(this, this.catalog.size, "decorate")
|
|
816
947
|
return this as unknown as Server<R, Ctx & Record<K, V>>
|
|
817
948
|
}
|
|
818
949
|
|
|
@@ -830,10 +961,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
830
961
|
return this
|
|
831
962
|
}
|
|
832
963
|
|
|
833
|
-
/**
|
|
964
|
+
/**
|
|
965
|
+
* Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped:
|
|
966
|
+
* it is snapshotted into every route declared *after* this call and covers none declared before, so
|
|
967
|
+
* one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
|
|
968
|
+
*/
|
|
834
969
|
beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
835
970
|
this.assertConfigurable("beforeHandle()")
|
|
836
971
|
this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
|
|
972
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
973
|
+
recordScopedHook(this, this.catalog.size, "beforeHandle")
|
|
837
974
|
return this
|
|
838
975
|
}
|
|
839
976
|
|
|
@@ -841,10 +978,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
841
978
|
* Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
|
|
842
979
|
* route output, so wrappers like async context storage do not force Node's direct JSON path through
|
|
843
980
|
* a Web `Response`. The first registered wrapper is outermost.
|
|
981
|
+
*
|
|
982
|
+
* Order-scoped: captured by every route declared *after* this call, none before; one added after the
|
|
983
|
+
* last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
|
|
844
984
|
*/
|
|
845
985
|
around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
|
|
846
986
|
this.assertConfigurable("around()")
|
|
847
987
|
this.aroundHooks.push(fn as unknown as RawAround)
|
|
988
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
989
|
+
recordScopedHook(this, this.catalog.size, "around")
|
|
848
990
|
return this
|
|
849
991
|
}
|
|
850
992
|
|
|
@@ -869,6 +1011,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
869
1011
|
this.capabilityInterceptors.push(
|
|
870
1012
|
Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
|
|
871
1013
|
)
|
|
1014
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1015
|
+
recordScopedHook(this, this.catalog.size, "aroundCapability")
|
|
872
1016
|
return this
|
|
873
1017
|
}
|
|
874
1018
|
|
|
@@ -882,17 +1026,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
882
1026
|
return this
|
|
883
1027
|
}
|
|
884
1028
|
|
|
885
|
-
/** Transform the handler's result before it is serialized. Order-scoped
|
|
1029
|
+
/** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
|
|
1030
|
+
* *after* this call, none before; one added after the last route reaches nothing (see
|
|
1031
|
+
* {@link ServerOptions.unusedScopedHooks}). */
|
|
886
1032
|
afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
887
1033
|
this.assertConfigurable("afterHandle()")
|
|
888
1034
|
this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
|
|
1035
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1036
|
+
recordScopedHook(this, this.catalog.size, "afterHandle")
|
|
889
1037
|
return this
|
|
890
1038
|
}
|
|
891
1039
|
|
|
892
|
-
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
|
|
1040
|
+
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
|
|
1041
|
+
* Order-scoped: captured by routes declared *after* this call, none before; one added after the last
|
|
1042
|
+
* route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
|
|
893
1043
|
onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
894
1044
|
this.assertConfigurable("onError()")
|
|
895
1045
|
this.onErrorHooks.push(fn as unknown as RawErrorHandler)
|
|
1046
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1047
|
+
recordScopedHook(this, this.catalog.size, "onError")
|
|
896
1048
|
return this
|
|
897
1049
|
}
|
|
898
1050
|
|
|
@@ -1049,13 +1201,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1049
1201
|
* called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
|
|
1050
1202
|
* the added context to handlers defined after `use` (the overload is generic over the concrete
|
|
1051
1203
|
* `this`). A named plugin already applied is skipped (idempotent dedupe).
|
|
1204
|
+
*
|
|
1205
|
+
* If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
|
|
1206
|
+
* `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
|
|
1207
|
+
* returns the non-callable {@link PluginTypeCollapsed} instead of silently widening the whole typed
|
|
1208
|
+
* client to `any`. `.get()`/`.post()` then fail right here rather than surfacing as `any` hundreds of
|
|
1209
|
+
* lines away. Fix at the plugin: build it with {@link defineIdentityPlugin}/{@link defineContextPlugin},
|
|
1210
|
+
* or pin its input server type. This mirrors the guard {@link definePlugin} already applies.
|
|
1052
1211
|
*/
|
|
1053
|
-
use<Out extends AnyServer>(
|
|
1212
|
+
use<Out extends AnyServer>(
|
|
1213
|
+
plugin: (app: this) => Out,
|
|
1214
|
+
): [ServerTypeUnpinned<Out>] extends [true] ? PluginTypeCollapsed : Out
|
|
1054
1215
|
/**
|
|
1055
1216
|
* Apply a {@link Middleware} bundle - wire each hook it provides to its lifecycle point. Returns
|
|
1056
1217
|
* `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
|
|
1057
|
-
* should cover (those are order-scoped; `onRequest`/`onResponse` are global). A
|
|
1058
|
-
*
|
|
1218
|
+
* should cover (those are order-scoped; `onRequest`/`onResponse` are global). A bundle applied after
|
|
1219
|
+
* the last route reaches nothing - {@link ServerOptions.unusedScopedHooks} logs that at seal. A named
|
|
1220
|
+
* bundle already applied is skipped (idempotent).
|
|
1059
1221
|
*/
|
|
1060
1222
|
use(mw: Middleware): this
|
|
1061
1223
|
use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
|
|
@@ -2017,6 +2179,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2017
2179
|
source: RequestSource,
|
|
2018
2180
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2019
2181
|
): MaybePromise<Response> {
|
|
2182
|
+
// Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
|
|
2183
|
+
// After that it is a single boolean read - the per-request cost the option promises to keep at zero.
|
|
2184
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2185
|
+
sealHookAudit(this, this.catalog.size, this.logger)
|
|
2020
2186
|
// Off path (default): straight through - one property check, no closure, no promise.
|
|
2021
2187
|
if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
|
|
2022
2188
|
return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
|
|
@@ -2141,6 +2307,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2141
2307
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2142
2308
|
suppliedRuntime?: NodeOutcomeRuntime,
|
|
2143
2309
|
): MaybePromise<NodeServeOutcome> {
|
|
2310
|
+
// Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
|
|
2311
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2312
|
+
sealHookAudit(this, this.catalog.size, this.logger)
|
|
2144
2313
|
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
2145
2314
|
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
2146
2315
|
// be materialized with a direct-write marker and return to the socket path when the hook mutates
|
|
@@ -4863,6 +5032,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4863
5032
|
"listen() uses Bun.serve and runs only on Bun. Serve on Node with @nifrajs/node or on Deno with @nifrajs/deno, or hand app.fetch to any fetch-compatible runtime (Workers, etc.).",
|
|
4864
5033
|
)
|
|
4865
5034
|
}
|
|
5035
|
+
// Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
|
|
5036
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
5037
|
+
sealHookAudit(this, this.catalog.size, this.logger)
|
|
4866
5038
|
// Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
|
|
4867
5039
|
// subset so the public types don't depend on the ambient `Bun` global. The cast
|
|
4868
5040
|
// bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
|