@nifrajs/core 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/brand.d.ts +11 -0
- package/dist/internal/brand.d.ts.map +1 -0
- package/dist/internal/brand.js +10 -0
- package/dist/internal/brand.js.map +1 -0
- package/dist/internal/secret.d.ts +9 -0
- package/dist/internal/secret.d.ts.map +1 -0
- package/dist/internal/secret.js +19 -0
- package/dist/internal/secret.js.map +1 -0
- package/dist/router/router.d.ts +61 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +324 -0
- package/dist/router/router.js.map +1 -0
- package/dist/schema/standard.d.ts +46 -0
- package/dist/schema/standard.d.ts.map +1 -0
- package/dist/schema/standard.js +20 -0
- package/dist/schema/standard.js.map +1 -0
- package/dist/seo.d.ts +48 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +109 -0
- package/dist/seo.js.map +1 -0
- package/dist/server/body.d.ts +39 -0
- package/dist/server/body.d.ts.map +1 -0
- package/dist/server/body.js +85 -0
- package/dist/server/body.js.map +1 -0
- package/dist/server/context.d.ts +111 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +2 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/contract.d.ts +142 -0
- package/dist/server/contract.d.ts.map +1 -0
- package/dist/server/contract.js +59 -0
- package/dist/server/contract.js.map +1 -0
- package/dist/server/cookies.d.ts +41 -0
- package/dist/server/cookies.d.ts.map +1 -0
- package/dist/server/cookies.js +181 -0
- package/dist/server/cookies.js.map +1 -0
- package/dist/server/logger.d.ts +49 -0
- package/dist/server/logger.d.ts.map +1 -0
- package/dist/server/logger.js +125 -0
- package/dist/server/logger.js.map +1 -0
- package/dist/server/registry.d.ts +46 -0
- package/dist/server/registry.d.ts.map +1 -0
- package/dist/server/registry.js +2 -0
- package/dist/server/registry.js.map +1 -0
- package/dist/server/server.d.ts +528 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +1660 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/sse.d.ts +54 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +126 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/webhook.d.ts +42 -0
- package/dist/server/webhook.d.ts.map +1 -0
- package/dist/server/webhook.js +149 -0
- package/dist/server/webhook.js.map +1 -0
- package/dist/server/websocket.d.ts +152 -0
- package/dist/server/websocket.d.ts.map +1 -0
- package/dist/server/websocket.js +178 -0
- package/dist/server/websocket.js.map +1 -0
- package/dist/server/ws-bun.d.ts +27 -0
- package/dist/server/ws-bun.d.ts.map +1 -0
- package/dist/server/ws-bun.js +82 -0
- package/dist/server/ws-bun.js.map +1 -0
- package/dist/server/ws-hook.d.ts +36 -0
- package/dist/server/ws-hook.d.ts.map +1 -0
- package/dist/server/ws-hook.js +18 -0
- package/dist/server/ws-hook.js.map +1 -0
- package/dist/ws.d.ts +19 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +26 -0
- package/dist/ws.js.map +1 -0
- package/package.json +63 -0
- package/src/errors.ts +41 -0
- package/src/index.ts +115 -0
- package/src/internal/brand.ts +11 -0
- package/src/internal/secret.ts +24 -0
- package/src/router/router.ts +429 -0
- package/src/schema/standard.ts +76 -0
- package/src/seo.ts +155 -0
- package/src/server/body.ts +89 -0
- package/src/server/context.ts +124 -0
- package/src/server/contract.ts +209 -0
- package/src/server/cookies.ts +198 -0
- package/src/server/logger.ts +167 -0
- package/src/server/registry.ts +63 -0
- package/src/server/server.ts +2479 -0
- package/src/server/sse.ts +161 -0
- package/src/server/webhook.ts +207 -0
- package/src/server/websocket.ts +308 -0
- package/src/server/ws-bun.ts +106 -0
- package/src/server/ws-hook.ts +53 -0
- package/src/ws.ts +40 -0
|
@@ -0,0 +1,2479 @@
|
|
|
1
|
+
import { FrameworkError } from "../errors.ts"
|
|
2
|
+
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
3
|
+
import type {
|
|
4
|
+
InferOutput,
|
|
5
|
+
StandardIssue,
|
|
6
|
+
StandardResult,
|
|
7
|
+
StandardSchemaV1,
|
|
8
|
+
} from "../schema/standard.ts"
|
|
9
|
+
import { drainCapped, parseContentLength, readBoundedBytes } from "./body.ts"
|
|
10
|
+
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
11
|
+
import { type CookieOptions, parseCookies, serializeCookie } from "./cookies.ts"
|
|
12
|
+
import { jsonLogger, type Logger } from "./logger.ts"
|
|
13
|
+
import type { AddRoute, EmptyRegistry, OutputOf, Registry, RouteInfoFor } from "./registry.ts"
|
|
14
|
+
import type {
|
|
15
|
+
StandardWebSocket,
|
|
16
|
+
TopicRegistry,
|
|
17
|
+
WebSocketContext,
|
|
18
|
+
WebSocketHandler,
|
|
19
|
+
WebSocketUpgradeOutcome,
|
|
20
|
+
} from "./websocket.ts"
|
|
21
|
+
import type { BunWsData } from "./ws-bun.ts"
|
|
22
|
+
import { getWsRuntime, type WsRuntime } from "./ws-hook.ts"
|
|
23
|
+
|
|
24
|
+
type MaybePromise<T> = T | Promise<T>
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
|
|
28
|
+
* their `Request` **directly** (zero wrapper allocation on the hot path — `request` is simply absent and
|
|
29
|
+
* {@link requestOf} returns the source itself). Node's adapter passes a *lazy* source whose `request`
|
|
30
|
+
* getter builds an undici `Request` only when user code reads `c.req`, an onRequest/onResponse hook
|
|
31
|
+
* needs it, or a body helper consumes it — so the common Node request never pays for a `Request` build.
|
|
32
|
+
*/
|
|
33
|
+
export interface RequestSource {
|
|
34
|
+
readonly method: string
|
|
35
|
+
readonly url: string
|
|
36
|
+
readonly headers: Headers
|
|
37
|
+
header?(name: string): string | null
|
|
38
|
+
readonly body: ReadableStream<Uint8Array> | null
|
|
39
|
+
arrayBuffer(): Promise<ArrayBuffer>
|
|
40
|
+
json(): Promise<unknown>
|
|
41
|
+
/** Present only when materializing a `Request` is non-trivial (the Node lazy source); for a real
|
|
42
|
+
* `Request` passed as the source it's absent and {@link requestOf} returns the source itself. */
|
|
43
|
+
readonly request?: Request
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The concrete `Request` for a source — itself when a real `Request` was passed (the Web path), or the
|
|
47
|
+
* lazily-built one (the Node adapter). A real `Request` IS a `RequestSource`, so no wrapper is allocated
|
|
48
|
+
* on the Web hot path. */
|
|
49
|
+
function requestOf(source: RequestSource): Request {
|
|
50
|
+
return source.request ?? (source as unknown as Request)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function headerOf(source: RequestSource, name: string): string | null {
|
|
54
|
+
return source.header?.(name) ?? source.headers.get(name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
|
|
58
|
+
type EmptyContext = NonNullable<unknown>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extracts the app's platform `Env` from its context `Ctx`. `server<Env>()` seeds `Ctx` with
|
|
62
|
+
* `{ env: Env }`, so this pulls that back out to type `fetch`/`toFetchHandler`'s `env` argument
|
|
63
|
+
* against the app's declared bindings. Defaults to `unknown` when no env was declared.
|
|
64
|
+
*/
|
|
65
|
+
type EnvOf<Ctx> = Ctx extends { readonly env: infer E } ? E : unknown
|
|
66
|
+
|
|
67
|
+
/** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
|
|
68
|
+
type HandlerResult = Response | unknown
|
|
69
|
+
type ContextlessHandler = () => MaybePromise<HandlerResult>
|
|
70
|
+
|
|
71
|
+
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
72
|
+
const CONTEXT_SET = Symbol("nifra.context.set")
|
|
73
|
+
const CONTEXT_SEARCH = Symbol("nifra.context.search")
|
|
74
|
+
const functionToString = Function.prototype.toString
|
|
75
|
+
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/
|
|
76
|
+
|
|
77
|
+
interface ResponseResult {
|
|
78
|
+
readonly [RESPONSE_RESULT]: true
|
|
79
|
+
toResponse(): Response
|
|
80
|
+
toNodeBody?(): {
|
|
81
|
+
readonly status: number
|
|
82
|
+
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
83
|
+
readonly body: string | Uint8Array
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isResponseResult(value: unknown): value is ResponseResult {
|
|
88
|
+
return (
|
|
89
|
+
typeof value === "object" &&
|
|
90
|
+
value !== null &&
|
|
91
|
+
(value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
|
|
92
|
+
typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Internal, path-erased runtime context. The typed `Context<Path, S>` is a structural view of this. */
|
|
97
|
+
interface RawContext {
|
|
98
|
+
readonly req: Request
|
|
99
|
+
readonly params: Record<string, string>
|
|
100
|
+
query: unknown
|
|
101
|
+
readonly cookies: Readonly<Record<string, string>>
|
|
102
|
+
body: unknown
|
|
103
|
+
readonly set: ResponseControls
|
|
104
|
+
readonly [CONTEXT_SET]: () => CtxSet | undefined
|
|
105
|
+
readonly [CONTEXT_SEARCH]: string
|
|
106
|
+
readonly signal: AbortSignal
|
|
107
|
+
readonly env: unknown
|
|
108
|
+
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
109
|
+
readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
|
|
110
|
+
readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
|
|
114
|
+
* rejection. Edge runtimes pass their own (Workers `ctx.waitUntil`) via the platform arg. */
|
|
115
|
+
const fallbackWaitUntil = (promise: Promise<unknown>): void => {
|
|
116
|
+
void promise.catch(() => {})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
|
|
120
|
+
|
|
121
|
+
/** Broad shape so the implementation signature is compatible with both typed overloads. */
|
|
122
|
+
type ErasedHandler = (ctx: never) => MaybePromise<HandlerResult>
|
|
123
|
+
|
|
124
|
+
/** A `derive` computes per-request context extensions; stored path-erased. */
|
|
125
|
+
type RawDerive = (ctx: RawContext) => MaybePromise<object>
|
|
126
|
+
type RawBeforeHandle = (ctx: RawContext) => MaybePromise<unknown>
|
|
127
|
+
type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
128
|
+
type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
129
|
+
type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
130
|
+
export type OnRequestResult = Response | Request | undefined
|
|
131
|
+
type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
|
|
132
|
+
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
133
|
+
|
|
134
|
+
interface RouteEntry {
|
|
135
|
+
readonly handler: InternalHandler
|
|
136
|
+
readonly schema: RouteSchema | undefined
|
|
137
|
+
/** Per-request context extensions captured at registration (order-scoped). */
|
|
138
|
+
readonly derives: ReadonlyArray<RawDerive>
|
|
139
|
+
/** Static context extensions captured at registration. */
|
|
140
|
+
readonly decorations: Record<string, unknown>
|
|
141
|
+
/** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
|
|
142
|
+
* `Object.assign` on the (common) no-decoration route. */
|
|
143
|
+
readonly hasDecorations: boolean
|
|
144
|
+
/** Lifecycle hooks captured at registration (order-scoped). */
|
|
145
|
+
readonly beforeHandle: ReadonlyArray<RawBeforeHandle>
|
|
146
|
+
readonly afterHandle: ReadonlyArray<RawAfterHandle>
|
|
147
|
+
readonly onError: ReadonlyArray<RawErrorHandler>
|
|
148
|
+
/** Wraps the matched route lifecycle. Empty for the common no-around path. */
|
|
149
|
+
readonly around: ReadonlyArray<RawAround>
|
|
150
|
+
/**
|
|
151
|
+
* Precomputed: the route has no body/query schema and no derive/beforeHandle/afterHandle/onError
|
|
152
|
+
* hooks — so its lifecycle reduces to `finalize(handler(ctx), set)` with simple error handling, no
|
|
153
|
+
* `await` unless the handler itself is async. Such routes take the **synchronous fast path**
|
|
154
|
+
* ({@link Server.runBare}), which skips the `async` machinery of the full {@link Server.runLifecycle}
|
|
155
|
+
* (≈2 promise frames/req — the per-request tax codegen routers avoid; nifra avoids it here without
|
|
156
|
+
* `eval`, so it stays edge-safe). Static decorations are still applied (they're a sync `Object.assign`).
|
|
157
|
+
*/
|
|
158
|
+
readonly bare: boolean
|
|
159
|
+
/**
|
|
160
|
+
* A narrower bare-route path for syntactic zero-parameter arrow handlers (`() => ...`). Unlike
|
|
161
|
+
* `handler.length === 0`, this intentionally excludes `function () { arguments }`, rest params,
|
|
162
|
+
* default params, bound/native functions, and anything else that can observe the passed context.
|
|
163
|
+
*/
|
|
164
|
+
readonly contextlessBare: boolean
|
|
165
|
+
/** Precomputed body-schema-only path: JSON body validation + handler, no other lifecycle hooks. */
|
|
166
|
+
readonly bodyOnly: boolean
|
|
167
|
+
/** Precomputed query-schema-only path: query validation + handler, no other lifecycle hooks. */
|
|
168
|
+
readonly queryOnly: boolean
|
|
169
|
+
/**
|
|
170
|
+
* Registration-time-fused Web lane for a {@link bare} route with no `around` hooks: context (when
|
|
171
|
+
* the handler can observe one), handler, and the JSON respond collapsed into ONE monomorphic
|
|
172
|
+
* closure — no per-request branch ladder, no `responseSet` lookup, no codegen (`new Function`
|
|
173
|
+
* measured equivalent to closures on JSC). Only the Web finalizer
|
|
174
|
+
* (`fetch`) uses it — the node-direct path keeps the generic lifecycle.
|
|
175
|
+
*/
|
|
176
|
+
readonly fusedWeb: FusedWebRunner | undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
|
|
180
|
+
type FusedWebRunner = (
|
|
181
|
+
source: RequestSource,
|
|
182
|
+
params: Record<string, string>,
|
|
183
|
+
search: string,
|
|
184
|
+
signal: AbortSignal,
|
|
185
|
+
platform: Platform | undefined,
|
|
186
|
+
) => MaybePromise<Response>
|
|
187
|
+
|
|
188
|
+
/** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
|
|
189
|
+
interface WsEntry {
|
|
190
|
+
readonly handler: WebSocketHandler
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Structural view of the Bun `Server` `upgrade` the `fetch` 2nd arg exposes. */
|
|
194
|
+
interface BunUpgradeServer {
|
|
195
|
+
upgrade(request: Request, options?: { data?: BunWsData }): boolean
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
199
|
+
|
|
200
|
+
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
201
|
+
function requireWsRuntime(): WsRuntime {
|
|
202
|
+
const runtime = getWsRuntime()
|
|
203
|
+
if (runtime === undefined) {
|
|
204
|
+
throw new FrameworkError(
|
|
205
|
+
"WS_RUNTIME_MISSING",
|
|
206
|
+
'app.ws() needs the WebSocket runtime, which ships as a subpath so no-WebSocket apps stay lean. Add `import "@nifrajs/core/ws"` at your server entry.',
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
return runtime
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The handler's permitted return type. When the route declares a `response` schema, the return is
|
|
213
|
+
* constrained to the contract's type (or a raw `Response`) — so the implementation can't drift from the
|
|
214
|
+
* declared contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
|
|
215
|
+
type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
|
|
216
|
+
? InferOutput<R> | Response
|
|
217
|
+
: HandlerResult
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Public handler shape: context typed from the path, the (optional) schema, and
|
|
221
|
+
* any accumulated middleware context `Ctx` (from `derive`/`decorate`).
|
|
222
|
+
*/
|
|
223
|
+
export type Handler<
|
|
224
|
+
Path extends string,
|
|
225
|
+
S extends RouteSchema = RouteSchema,
|
|
226
|
+
Ctx = EmptyContext,
|
|
227
|
+
> = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
|
|
228
|
+
|
|
229
|
+
export interface ServerOptions {
|
|
230
|
+
/**
|
|
231
|
+
* Max request body size (bytes), enforced **only when a route declares a body schema** — the cap
|
|
232
|
+
* lives in the schema-validated read path. Default 1_000_000.
|
|
233
|
+
*
|
|
234
|
+
* A route WITHOUT a body schema (raw body, file upload, BYO-validation) that reads `c.req` directly
|
|
235
|
+
* is not auto-bounded — use **`c.boundedBody(maxBytes?)`** / **`c.boundedJson(maxBytes?)`**, which
|
|
236
|
+
* apply this same cap (override per route by passing `maxBytes` — larger for an upload endpoint,
|
|
237
|
+
* smaller to tighten one).
|
|
238
|
+
*/
|
|
239
|
+
readonly maxBodyBytes?: number
|
|
240
|
+
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
241
|
+
readonly requestTimeoutMs?: number
|
|
242
|
+
/** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
|
|
243
|
+
readonly gracefulSignals?: boolean
|
|
244
|
+
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
245
|
+
readonly logger?: Logger
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A registered route's public descriptor — method, path, and input schemas. The
|
|
250
|
+
* router trie discards the original patterns, so this flat list is what lets tools
|
|
251
|
+
* (e.g. `toOpenAPI`) enumerate routes after registration.
|
|
252
|
+
*/
|
|
253
|
+
export interface RouteDescriptor {
|
|
254
|
+
readonly method: Method
|
|
255
|
+
readonly path: string
|
|
256
|
+
readonly schema: RouteSchema | undefined
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The handle `listen()` returns — the slice of Bun's server nifra holds and exposes.
|
|
261
|
+
* Declared explicitly (rather than `ReturnType<typeof Bun.serve>`) so the public type
|
|
262
|
+
* surface doesn't leak the ambient `Bun` global into consumers' `.d.ts` resolution.
|
|
263
|
+
*/
|
|
264
|
+
export interface RunningServer {
|
|
265
|
+
readonly port: number
|
|
266
|
+
readonly hostname: string
|
|
267
|
+
readonly pendingRequests: number
|
|
268
|
+
stop(closeActiveConnections?: boolean): void
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A bundle of lifecycle hooks applied together via {@link Server.use} — the unit
|
|
273
|
+
* `@nifrajs/middleware` ships (cors, security headers, rate-limit). Every hook is
|
|
274
|
+
* optional and wired to its lifecycle point. Middleware is context-agnostic (sees
|
|
275
|
+
* the base `Context`); `use` does no context-type merging — the full type-merging
|
|
276
|
+
* plugin system is deferred, and `.use` is reserved as its future entry point.
|
|
277
|
+
*/
|
|
278
|
+
export interface Middleware {
|
|
279
|
+
readonly name?: string
|
|
280
|
+
readonly onRequest?: (req: Request) => MaybePromise<OnRequestResult>
|
|
281
|
+
readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
282
|
+
readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
|
|
283
|
+
readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
|
|
284
|
+
readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
|
|
285
|
+
readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
|
|
289
|
+
// (the precise threading happens at the `use` call site, which is generic over the *concrete* `this`).
|
|
290
|
+
// biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
|
|
291
|
+
export type AnyServer = Server<any, any>
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A nifra **plugin**: a function that augments an app — calling `use`/`derive`/`decorate` and/or
|
|
295
|
+
* registering routes — and returns it. Because `derive`/`decorate` are type-threaded, an **inline**
|
|
296
|
+
* `app.use((a) => a.derive(...).decorate(...))` carries the added context to handlers defined after
|
|
297
|
+
* it (the `use` overload is generic over the concrete `this`). Wrap with {@link definePlugin} to
|
|
298
|
+
* attach a name for idempotent dedupe (applying the same named plugin twice — e.g. transitively — is
|
|
299
|
+
* a no-op).
|
|
300
|
+
*/
|
|
301
|
+
export type NifraPlugin<In extends AnyServer = AnyServer, Out extends AnyServer = In> = ((
|
|
302
|
+
app: In,
|
|
303
|
+
) => Out) & { readonly pluginName?: string }
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A named type-identity plugin built with {@link defineIdentityPlugin}. It returns the same concrete
|
|
307
|
+
* server type it receives, preserving the caller's typed registry and context across `.use()` while
|
|
308
|
+
* still allowing the plugin to register runtime hooks or handlers.
|
|
309
|
+
*/
|
|
310
|
+
export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
|
|
311
|
+
readonly pluginName?: string
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Name + ergonomics for a plugin. `app.use(myPlugin)` applies it once; a second `use` of the same
|
|
316
|
+
* name is skipped (idempotent), so plugins can depend on each other without double-registering hooks.
|
|
317
|
+
*
|
|
318
|
+
* ```ts
|
|
319
|
+
* export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
|
|
320
|
+
* app.use(requestId) // downstream handlers see c.requestId
|
|
321
|
+
* ```
|
|
322
|
+
*/
|
|
323
|
+
export function definePlugin<In extends AnyServer, Out extends AnyServer>(
|
|
324
|
+
name: string,
|
|
325
|
+
apply: (app: In) => Out,
|
|
326
|
+
): NifraPlugin<In, Out> {
|
|
327
|
+
return Object.assign(apply, { pluginName: name }) as NifraPlugin<In, Out>
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Define a type-**identity** plugin: it registers routes/hooks as a side effect but returns the app with
|
|
332
|
+
* its `Registry` + `Context` UNCHANGED. Use this (not {@link definePlugin}) for any plugin that doesn't
|
|
333
|
+
* add context types — e.g. one mounting an auth handler. It threads the caller's *concrete* server type
|
|
334
|
+
* through `use`, so routes declared after `app.use(plugin)` keep their types.
|
|
335
|
+
*
|
|
336
|
+
* Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
|
|
337
|
+
* `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
|
|
338
|
+
* (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
|
|
339
|
+
*
|
|
340
|
+
* ```ts
|
|
341
|
+
* export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
|
|
342
|
+
* const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
export function defineIdentityPlugin(
|
|
346
|
+
name: string,
|
|
347
|
+
apply: <S extends AnyServer>(app: S) => S,
|
|
348
|
+
): IdentityPlugin {
|
|
349
|
+
return Object.assign(apply, { pluginName: name }) as IdentityPlugin
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
353
|
+
const DEFAULT_DRAIN_MS = 10_000
|
|
354
|
+
const DRAIN_POLL_MS = 10
|
|
355
|
+
const TEXT_DECODER = new TextDecoder()
|
|
356
|
+
/**
|
|
357
|
+
* Shared never-aborting signal for `ctx.signal` when no timeout is armed — created lazily and
|
|
358
|
+
* cached. NOT a module-scope `new AbortController()`: edge runtimes (Cloudflare workerd) forbid
|
|
359
|
+
* constructing one in global scope; the first request builds it inside the handler, then it's
|
|
360
|
+
* reused at zero per-request cost.
|
|
361
|
+
*/
|
|
362
|
+
let neverAbortSignal: AbortSignal | undefined
|
|
363
|
+
const getNeverAbortSignal = (): AbortSignal => {
|
|
364
|
+
neverAbortSignal ??= new AbortController().signal
|
|
365
|
+
return neverAbortSignal
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
|
|
369
|
+
return Response.json(
|
|
370
|
+
{ ok: false, error },
|
|
371
|
+
headers !== undefined ? { status, headers } : { status },
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function validationError(issues: ReadonlyArray<StandardIssue>): Response {
|
|
376
|
+
const serialized = issues.map((issue) => {
|
|
377
|
+
const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
|
|
378
|
+
return path !== undefined ? { message: issue.message, path } : { message: issue.message }
|
|
379
|
+
})
|
|
380
|
+
return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 400 })
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
interface UrlParts {
|
|
384
|
+
readonly pathname: string
|
|
385
|
+
readonly search: string
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
|
|
389
|
+
// `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
|
|
390
|
+
// the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
|
|
391
|
+
// string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
|
|
392
|
+
export function urlPartsOf(url: string): UrlParts {
|
|
393
|
+
const schemeEnd = url.indexOf("://")
|
|
394
|
+
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
|
|
395
|
+
if (start === -1) return { pathname: "/", search: "" }
|
|
396
|
+
|
|
397
|
+
let pathEnd = url.length
|
|
398
|
+
let searchStart = -1
|
|
399
|
+
let searchEnd = url.length
|
|
400
|
+
for (let i = start; i < url.length; i++) {
|
|
401
|
+
const c = url.charCodeAt(i)
|
|
402
|
+
if (c === 63 /* ? */ && searchStart === -1) {
|
|
403
|
+
pathEnd = i
|
|
404
|
+
searchStart = i
|
|
405
|
+
} else if (c === 35 /* # */) {
|
|
406
|
+
if (searchStart === -1) pathEnd = i
|
|
407
|
+
searchEnd = i
|
|
408
|
+
break
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
pathname: url.slice(start, pathEnd),
|
|
414
|
+
search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
|
|
419
|
+
// for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
|
|
420
|
+
export function pathnameOf(url: string): string {
|
|
421
|
+
return urlPartsOf(url).pathname
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The query string ("?a=1", or "" when absent) — for lazily building `c.query` only when read.
|
|
425
|
+
// The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
|
|
426
|
+
// is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
|
|
427
|
+
// for provable equivalence with `new URL(req.url).search`.
|
|
428
|
+
export function searchOf(url: string): string {
|
|
429
|
+
return urlPartsOf(url).search
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A query value: a single occurrence is a string; a repeated key promotes to a string[] so an
|
|
433
|
+
* array query schema (`t.array(t.string())`) can validate `?tag=a&tag=b` — last-wins silently
|
|
434
|
+
* dropped values before (audit 2026-06). Single-occurrence keys stay plain strings, so existing
|
|
435
|
+
* `t.string()` schemas are untouched; a repeated key against a string schema now FAILS validation
|
|
436
|
+
* (an explicit 400 beats silently picking one). */
|
|
437
|
+
export type QueryValue = string | string[]
|
|
438
|
+
|
|
439
|
+
/** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
|
|
440
|
+
* `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key — the
|
|
441
|
+
* promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
|
|
442
|
+
* case (direct assignment on a null-proto object creates an own property). */
|
|
443
|
+
function setQueryValue(out: Record<string, QueryValue>, key: string, value: string): void {
|
|
444
|
+
const existing = out[key]
|
|
445
|
+
if (existing === undefined) {
|
|
446
|
+
out[key] = value
|
|
447
|
+
} else if (typeof existing === "string") {
|
|
448
|
+
out[key] = [existing, value]
|
|
449
|
+
} else {
|
|
450
|
+
existing.push(value)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function queryObjectFallback(search: string): Record<string, QueryValue> {
|
|
455
|
+
// Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
|
|
456
|
+
// (fromEntries is last-wins), and __proto__ needs the same own-property guard.
|
|
457
|
+
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
458
|
+
for (const [key, value] of new URLSearchParams(search)) {
|
|
459
|
+
setQueryValue(out, key, value)
|
|
460
|
+
}
|
|
461
|
+
return out
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
|
|
466
|
+
* `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
|
|
467
|
+
* percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
|
|
468
|
+
* to `string[]` on BOTH paths (see {@link setQueryValue}).
|
|
469
|
+
*/
|
|
470
|
+
// Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
|
|
471
|
+
// populated path), allocated once instead of per request.
|
|
472
|
+
const EMPTY_QUERY = Object.freeze(Object.create(null)) as Record<string, QueryValue>
|
|
473
|
+
|
|
474
|
+
export function queryObjectOf(search: string): Record<string, QueryValue> {
|
|
475
|
+
const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0
|
|
476
|
+
if (start >= search.length) return EMPTY_QUERY
|
|
477
|
+
|
|
478
|
+
for (let i = start; i < search.length; i++) {
|
|
479
|
+
const c = search.charCodeAt(i)
|
|
480
|
+
if (c === 37 /* % */ || c === 43 /* + */) return queryObjectFallback(search)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
484
|
+
let pos = start
|
|
485
|
+
while (pos <= search.length) {
|
|
486
|
+
const amp = search.indexOf("&", pos)
|
|
487
|
+
const end = amp === -1 ? search.length : amp
|
|
488
|
+
if (end > pos) {
|
|
489
|
+
const eq = search.indexOf("=", pos)
|
|
490
|
+
const split = eq !== -1 && eq < end ? eq : end
|
|
491
|
+
const key = search.slice(pos, split)
|
|
492
|
+
const value = split === end ? "" : search.slice(split + 1, end)
|
|
493
|
+
setQueryValue(out, key, value)
|
|
494
|
+
}
|
|
495
|
+
if (amp === -1) break
|
|
496
|
+
pos = amp + 1
|
|
497
|
+
}
|
|
498
|
+
return out
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `application/x-www-form-urlencoded`, with or without a charset suffix. */
|
|
502
|
+
function isUrlEncodedForm(contentType: string): boolean {
|
|
503
|
+
return (
|
|
504
|
+
contentType === "application/x-www-form-urlencoded" ||
|
|
505
|
+
contentType.startsWith("application/x-www-form-urlencoded;")
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const FORM_DECODER = new TextDecoder()
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Read an HTML-form body (urlencoded) into the plain object a body schema validates — same byte
|
|
513
|
+
* cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
|
|
514
|
+
* repeated-key → `string[]` promotion as query parsing, same `__proto__` guard.
|
|
515
|
+
*/
|
|
516
|
+
async function readBoundedForm(
|
|
517
|
+
req: RequestSource,
|
|
518
|
+
maxBytes: number,
|
|
519
|
+
): Promise<Record<string, QueryValue> | Response> {
|
|
520
|
+
const read = await readBoundedBytes(req, maxBytes)
|
|
521
|
+
if (!read.ok) {
|
|
522
|
+
return read.status === 413
|
|
523
|
+
? jsonError(413, "payload_too_large")
|
|
524
|
+
: jsonError(400, "invalid_content_length")
|
|
525
|
+
}
|
|
526
|
+
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
527
|
+
// URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
|
|
528
|
+
// it never throws on junk input, so no try/catch is needed here.
|
|
529
|
+
for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
|
|
530
|
+
setQueryValue(out, key, value)
|
|
531
|
+
}
|
|
532
|
+
return out
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function decodeParams(raw: Record<string, string>): Record<string, string> | null {
|
|
536
|
+
let out: Record<string, string> | undefined
|
|
537
|
+
for (const key in raw) {
|
|
538
|
+
const value = raw[key]!
|
|
539
|
+
// `decodeURIComponent` only changes strings containing `%`; for the common
|
|
540
|
+
// unencoded param (`/users/123`) skip the call and its throw-check entirely —
|
|
541
|
+
// semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
|
|
542
|
+
// param is already plain, return the router's per-request object directly and skip a clone.
|
|
543
|
+
if (!value.includes("%")) {
|
|
544
|
+
continue
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
out ??= { ...raw }
|
|
548
|
+
out[key] = decodeURIComponent(value)
|
|
549
|
+
} catch {
|
|
550
|
+
return null
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return out ?? raw
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
557
|
+
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
558
|
+
type CtxSet = ResponseControls & {
|
|
559
|
+
_headers?: Record<string, string>
|
|
560
|
+
/** Accumulated `Set-Cookie` values — a list, since a `Record` would collapse multiple cookies. */
|
|
561
|
+
_cookies?: string[]
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
class LazyResponseControls implements CtxSet {
|
|
565
|
+
status?: number
|
|
566
|
+
_headers?: Record<string, string>
|
|
567
|
+
_cookies?: string[]
|
|
568
|
+
|
|
569
|
+
get headers(): Record<string, string> {
|
|
570
|
+
this._headers ??= {}
|
|
571
|
+
return this._headers
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
cookie(name: string, value: string, options?: CookieOptions): void {
|
|
575
|
+
// Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
|
|
576
|
+
const merged: CookieOptions = {
|
|
577
|
+
httpOnly: true,
|
|
578
|
+
secure: true,
|
|
579
|
+
sameSite: "lax",
|
|
580
|
+
path: "/",
|
|
581
|
+
...options,
|
|
582
|
+
}
|
|
583
|
+
this._cookies ??= []
|
|
584
|
+
this._cookies.push(serializeCookie(name, value, merged))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
588
|
+
// Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
|
|
589
|
+
this._cookies ??= []
|
|
590
|
+
this._cookies.push(
|
|
591
|
+
serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Finalization only reads `status`, `_headers`, and `_cookies`. The user-visible mutator methods
|
|
597
|
+
// live on `LazyResponseControls`, created by the `c.set` getter only when user code touches it.
|
|
598
|
+
const EMPTY_RESPONSE_CONTROLS = Object.freeze({}) as CtxSet
|
|
599
|
+
|
|
600
|
+
function responseSet(ctx: RawContext): CtxSet {
|
|
601
|
+
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
|
|
605
|
+
if (handler.length !== 0) return false
|
|
606
|
+
try {
|
|
607
|
+
return CONTEXTLESS_ARROW.test(functionToString.call(handler))
|
|
608
|
+
} catch {
|
|
609
|
+
return false
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
class RequestContext implements RawContext {
|
|
614
|
+
readonly params: Record<string, string>
|
|
615
|
+
readonly signal: AbortSignal
|
|
616
|
+
readonly env: unknown
|
|
617
|
+
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
618
|
+
body: unknown
|
|
619
|
+
readonly [CONTEXT_SEARCH]: string
|
|
620
|
+
|
|
621
|
+
private setValue: CtxSet | undefined
|
|
622
|
+
private queryValue: unknown
|
|
623
|
+
private queryReady: boolean
|
|
624
|
+
private cookiesValue: Readonly<Record<string, string>> | undefined
|
|
625
|
+
private readonly source: RequestSource
|
|
626
|
+
private readonly maxBodyBytes: number
|
|
627
|
+
|
|
628
|
+
constructor(
|
|
629
|
+
source: RequestSource,
|
|
630
|
+
params: Record<string, string>,
|
|
631
|
+
search: string,
|
|
632
|
+
signal: AbortSignal,
|
|
633
|
+
platform: Platform | undefined,
|
|
634
|
+
maxBodyBytes: number,
|
|
635
|
+
) {
|
|
636
|
+
this.source = source
|
|
637
|
+
this.params = params
|
|
638
|
+
this[CONTEXT_SEARCH] = search
|
|
639
|
+
this.signal = signal
|
|
640
|
+
this.env = platform?.env
|
|
641
|
+
this.waitUntil = platform?.waitUntil ?? fallbackWaitUntil
|
|
642
|
+
this.maxBodyBytes = maxBodyBytes
|
|
643
|
+
this.setValue = undefined
|
|
644
|
+
this.body = undefined
|
|
645
|
+
this.queryValue = undefined
|
|
646
|
+
this.queryReady = false
|
|
647
|
+
this.cookiesValue = undefined
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
[CONTEXT_SET](): CtxSet | undefined {
|
|
651
|
+
return this.setValue
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
get set(): CtxSet {
|
|
655
|
+
this.setValue ??= new LazyResponseControls()
|
|
656
|
+
return this.setValue
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
get req(): Request {
|
|
660
|
+
return requestOf(this.source)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
get query(): unknown {
|
|
664
|
+
if (!this.queryReady) {
|
|
665
|
+
this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH])
|
|
666
|
+
this.queryReady = true
|
|
667
|
+
}
|
|
668
|
+
return this.queryValue
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
set query(v: unknown) {
|
|
672
|
+
this.queryValue = v
|
|
673
|
+
this.queryReady = true
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
get cookies(): Readonly<Record<string, string>> {
|
|
677
|
+
this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"))
|
|
678
|
+
return this.cookiesValue
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
boundedBody(maxBytes?: number): Promise<Uint8Array> {
|
|
682
|
+
return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
|
|
686
|
+
return readBoundedJsonBodyOrThrow<T>(this.source, this.maxBodyBytes, maxBytes)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
|
|
691
|
+
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
692
|
+
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
693
|
+
async function readBoundedBodyOrThrow(
|
|
694
|
+
req: RequestSource,
|
|
695
|
+
maxBodyBytes: number,
|
|
696
|
+
maxBytes?: number,
|
|
697
|
+
): Promise<Uint8Array> {
|
|
698
|
+
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
|
|
699
|
+
if (r.ok) return r.bytes
|
|
700
|
+
throw r.status === 413
|
|
701
|
+
? jsonError(413, "payload_too_large")
|
|
702
|
+
: jsonError(400, "invalid_content_length")
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
|
|
706
|
+
async function readBoundedJsonBodyOrThrow<T>(
|
|
707
|
+
req: RequestSource,
|
|
708
|
+
maxBodyBytes: number,
|
|
709
|
+
maxBytes?: number,
|
|
710
|
+
): Promise<T> {
|
|
711
|
+
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
|
|
712
|
+
if (parsed instanceof Response) throw parsed
|
|
713
|
+
return parsed as T
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
|
|
718
|
+
* Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
|
|
719
|
+
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
720
|
+
*/
|
|
721
|
+
async function readBoundedJsonSource(
|
|
722
|
+
req: RequestSource,
|
|
723
|
+
maxBytes: number,
|
|
724
|
+
): Promise<unknown | Response> {
|
|
725
|
+
const declared = headerOf(req, "content-length")
|
|
726
|
+
if (declared !== null) {
|
|
727
|
+
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
728
|
+
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
729
|
+
// "abc"→NaN, "-5", "1.5", "1e3", "0x10") is malformed → 400, rather than silently falling
|
|
730
|
+
// through to the streaming guard — which is an UPPER-bound cap only, so a lying SMALLER length
|
|
731
|
+
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
732
|
+
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
733
|
+
const length = parseContentLength(declared)
|
|
734
|
+
if (length === undefined) return jsonError(400, "invalid_content_length")
|
|
735
|
+
if (length > maxBytes) return jsonError(413, "payload_too_large")
|
|
736
|
+
const chunked = headerOf(req, "transfer-encoding") !== null
|
|
737
|
+
if (!chunked) {
|
|
738
|
+
try {
|
|
739
|
+
return await req.json()
|
|
740
|
+
} catch {
|
|
741
|
+
return jsonError(400, "invalid_json")
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const body = req.body
|
|
746
|
+
if (body === null) return jsonError(400, "invalid_json")
|
|
747
|
+
const drained = await drainCapped(body, maxBytes)
|
|
748
|
+
if (!drained.ok) return jsonError(413, "payload_too_large")
|
|
749
|
+
try {
|
|
750
|
+
return JSON.parse(TEXT_DECODER.decode(drained.bytes))
|
|
751
|
+
} catch {
|
|
752
|
+
return jsonError(400, "invalid_json")
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch — deterministic, unlike an
|
|
757
|
+
* argless `new Date()`. */
|
|
758
|
+
const EPOCH = new Date(0)
|
|
759
|
+
|
|
760
|
+
/** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
|
|
761
|
+
* gets no `headers` at all. Cookies force a `Headers` object — multiple `Set-Cookie`s can't live in a
|
|
762
|
+
* `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
|
|
763
|
+
function headersInit(set: CtxSet): Record<string, string> | Headers | undefined {
|
|
764
|
+
const cookies = set._cookies
|
|
765
|
+
if (cookies === undefined || cookies.length === 0) return set._headers
|
|
766
|
+
const headers = new Headers(set._headers)
|
|
767
|
+
for (const cookie of cookies) headers.append("set-cookie", cookie)
|
|
768
|
+
return headers
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
772
|
+
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
773
|
+
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
774
|
+
const JSON_CT_HEADERS = new Headers({
|
|
775
|
+
"content-type": "application/json;charset=utf-8",
|
|
776
|
+
})
|
|
777
|
+
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
778
|
+
|
|
779
|
+
/** Fused-lane respond when the handler ran with NO context (so `c.set` can't exist): the fast
|
|
780
|
+
* JSON respond directly, with `toResponse` + the empty controls as the exact-semantics fallback. */
|
|
781
|
+
function fusedRespondNoSet(result: unknown): Response {
|
|
782
|
+
if (
|
|
783
|
+
result !== undefined &&
|
|
784
|
+
!(result instanceof Response) &&
|
|
785
|
+
typeof result === "object" &&
|
|
786
|
+
result !== null &&
|
|
787
|
+
!isResponseResult(result)
|
|
788
|
+
) {
|
|
789
|
+
const body = JSON.stringify(result) as string | undefined
|
|
790
|
+
if (body !== undefined) return new Response(body, JSON_INIT_200)
|
|
791
|
+
}
|
|
792
|
+
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) → the fast
|
|
796
|
+
* JSON respond; touched → the generic `toResponse` with those controls (statuses, headers, cookies). */
|
|
797
|
+
function fusedRespond(result: unknown, ctx: RawContext): Response {
|
|
798
|
+
const set = ctx[CONTEXT_SET]()
|
|
799
|
+
if (set === undefined) return fusedRespondNoSet(result)
|
|
800
|
+
return toResponse(result as HandlerResult, set)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function toResponse(result: HandlerResult, set: CtxSet): Response {
|
|
804
|
+
if (isResponseResult(result)) {
|
|
805
|
+
return appendCookiesToResponse(result.toResponse(), set)
|
|
806
|
+
}
|
|
807
|
+
if (result instanceof Response) {
|
|
808
|
+
return appendCookiesToResponse(result, set)
|
|
809
|
+
}
|
|
810
|
+
const headers = headersInit(set)
|
|
811
|
+
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
812
|
+
if (headers === undefined && result !== undefined) {
|
|
813
|
+
// Fast respond (profiled ≈ −50 ns/req on every plain-JSON return): `JSON.stringify` + a
|
|
814
|
+
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical —
|
|
815
|
+
// same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
|
|
816
|
+
// result) delegates to Response.json so its TypeError contract stays the single source.
|
|
817
|
+
const body = JSON.stringify(result) as string | undefined
|
|
818
|
+
if (body !== undefined) {
|
|
819
|
+
return new Response(
|
|
820
|
+
body,
|
|
821
|
+
status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const init: ResponseInit = headers === undefined ? { status } : { status, headers }
|
|
826
|
+
return result === undefined ? new Response(null, init) : Response.json(result, init)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function appendCookiesToResponse(response: Response, set: CtxSet): Response {
|
|
830
|
+
// A handler may queue cookies (`c.set.cookie` — e.g. a session cookie) AND return its own Response
|
|
831
|
+
// (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
|
|
832
|
+
// returned Response — otherwise the canonical set-session-then-redirect pattern would silently drop
|
|
833
|
+
// the cookie. (Other `c.set` fields stay the returned Response's own concern.)
|
|
834
|
+
const cookies = set._cookies
|
|
835
|
+
if (cookies !== undefined && cookies.length > 0) {
|
|
836
|
+
for (const cookie of cookies) response.headers.append("set-cookie", cookie)
|
|
837
|
+
}
|
|
838
|
+
return response
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
|
|
843
|
+
* to the socket directly (`kind: "json"` — status + headers + cookies + a pre-stringified body, **no**
|
|
844
|
+
* undici `Response` built or drained), a marked buffered response body (`kind: "body"` — e.g.
|
|
845
|
+
* @nifrajs/web's non-deferred SSR HTML), or a `Response` (`kind: "response"`) for everything else
|
|
846
|
+
* (redirects, 404/405/errors, unmarked or streaming bodies). Internal to the nifra↔node bridge.
|
|
847
|
+
*/
|
|
848
|
+
export type NodeServeOutcome =
|
|
849
|
+
| { readonly kind: "response"; readonly response: Response }
|
|
850
|
+
| {
|
|
851
|
+
readonly kind: "json"
|
|
852
|
+
readonly status: number
|
|
853
|
+
/** `c.set.headers` backing, or `undefined` when the handler never set a header. */
|
|
854
|
+
readonly headers: Readonly<Record<string, string>> | undefined
|
|
855
|
+
/** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
|
|
856
|
+
readonly cookies: readonly string[] | undefined
|
|
857
|
+
/** The JSON body already stringified, or `null` for an empty (204) response. */
|
|
858
|
+
readonly body: string | null
|
|
859
|
+
}
|
|
860
|
+
| {
|
|
861
|
+
readonly kind: "body"
|
|
862
|
+
readonly status: number
|
|
863
|
+
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
864
|
+
readonly body: string | Uint8Array
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* `finalize` for the node-direct path — mirror of {@link toResponse} that skips the `Response` build:
|
|
869
|
+
* a plain value becomes pre-stringified JSON primitives (the adapter `JSON.stringify`s once, here, not
|
|
870
|
+
* via `Response.json` + a body drain); a handler-returned `Response` is wrapped as-is, with queued
|
|
871
|
+
* cookies appended exactly as `toResponse` does (so the set-cookie-then-`redirect()` pattern still
|
|
872
|
+
* works on Node).
|
|
873
|
+
*/
|
|
874
|
+
function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
|
|
875
|
+
if (isResponseResult(result)) {
|
|
876
|
+
const body = result.toNodeBody?.()
|
|
877
|
+
if (body !== undefined) {
|
|
878
|
+
return {
|
|
879
|
+
kind: "body",
|
|
880
|
+
status: body.status,
|
|
881
|
+
headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
|
|
882
|
+
body: body.body,
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set))
|
|
886
|
+
}
|
|
887
|
+
if (result instanceof Response) {
|
|
888
|
+
return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
|
|
889
|
+
}
|
|
890
|
+
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
891
|
+
return {
|
|
892
|
+
kind: "json",
|
|
893
|
+
status,
|
|
894
|
+
headers: set._headers,
|
|
895
|
+
cookies: set._cookies,
|
|
896
|
+
body: result === undefined ? null : JSON.stringify(result),
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
901
|
+
const IDENTITY_RESPONSE = (response: Response): Response => response
|
|
902
|
+
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
903
|
+
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
904
|
+
const NODE_FROM_RESPONSE = (response: Response): NodeServeOutcome =>
|
|
905
|
+
nodeOutcomeFromResponse(response)
|
|
906
|
+
const NODE_TIMEOUT = (): NodeServeOutcome => ({
|
|
907
|
+
kind: "response",
|
|
908
|
+
response: jsonError(503, "request_timeout"),
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
|
|
912
|
+
const body = nodeResponseBody(response)
|
|
913
|
+
return body === undefined
|
|
914
|
+
? { kind: "response", response }
|
|
915
|
+
: { kind: "body", status: response.status, headers: responseHeadersForNode(response), body }
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function nodeResponseBody(response: Response): string | Uint8Array | undefined {
|
|
919
|
+
if (response.bodyUsed) return undefined
|
|
920
|
+
const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
|
|
921
|
+
return typeof body === "string" || body instanceof Uint8Array ? body : undefined
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function responseHeadersForNode(
|
|
925
|
+
response: Response,
|
|
926
|
+
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
927
|
+
let headers: Record<string, string | readonly string[]> | undefined
|
|
928
|
+
response.headers.forEach((value, key) => {
|
|
929
|
+
headers ??= {}
|
|
930
|
+
headers[key] = value
|
|
931
|
+
})
|
|
932
|
+
const setCookies = response.headers.getSetCookie?.()
|
|
933
|
+
if (setCookies !== undefined && setCookies.length > 0) {
|
|
934
|
+
headers ??= {}
|
|
935
|
+
headers["set-cookie"] = setCookies
|
|
936
|
+
}
|
|
937
|
+
return headers
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function appendCookiesToNodeHeaders(
|
|
941
|
+
headers: Readonly<Record<string, string | readonly string[]>> | undefined,
|
|
942
|
+
cookies: readonly string[] | undefined,
|
|
943
|
+
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
944
|
+
if (cookies === undefined || cookies.length === 0) return headers
|
|
945
|
+
const out: Record<string, string | readonly string[]> =
|
|
946
|
+
headers === undefined ? {} : { ...headers }
|
|
947
|
+
const existing = out["set-cookie"]
|
|
948
|
+
const setCookies =
|
|
949
|
+
existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
|
|
950
|
+
out["set-cookie"] = [...setCookies, ...cookies]
|
|
951
|
+
return out
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
956
|
+
* `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
|
|
957
|
+
* with full types; `Ctx` is server-only and never touches the client registry.
|
|
958
|
+
*
|
|
959
|
+
* app.decorate("db", db).derive((c) => ({ user: auth(c) }))
|
|
960
|
+
* .get("/me", (c) => c.user) // c.user + c.db are typed
|
|
961
|
+
*/
|
|
962
|
+
export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
963
|
+
private readonly router: Router<RouteEntry>
|
|
964
|
+
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
965
|
+
private readonly wsRouter: Router<WsEntry>
|
|
966
|
+
private wsRouteCount: number
|
|
967
|
+
/** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
|
|
968
|
+
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
|
|
969
|
+
* no-WebSocket app never constructs (or bundles) it. */
|
|
970
|
+
private topics: TopicRegistry | undefined
|
|
971
|
+
private readonly routeList: RouteDescriptor[]
|
|
972
|
+
private readonly maxBodyBytes: number
|
|
973
|
+
private readonly requestTimeoutMs: number
|
|
974
|
+
private readonly gracefulSignals: boolean
|
|
975
|
+
private readonly logger: Logger
|
|
976
|
+
private bunServer: RunningServer | undefined
|
|
977
|
+
private readonly derives: RawDerive[]
|
|
978
|
+
private readonly decorations: Record<string, unknown>
|
|
979
|
+
private readonly beforeHandleHooks: RawBeforeHandle[]
|
|
980
|
+
private readonly afterHandleHooks: RawAfterHandle[]
|
|
981
|
+
private readonly onErrorHooks: RawErrorHandler[]
|
|
982
|
+
private readonly aroundHooks: RawAround[]
|
|
983
|
+
private readonly onRequestHooks: RawOnRequest[]
|
|
984
|
+
private readonly onResponseHooks: RawOnResponse[]
|
|
985
|
+
private readonly responseRequests: WeakMap<Request, Request>
|
|
986
|
+
/** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
|
|
987
|
+
private readonly appliedPlugins: Set<string>
|
|
988
|
+
|
|
989
|
+
constructor(options: ServerOptions = {}) {
|
|
990
|
+
this.router = new Router<RouteEntry>()
|
|
991
|
+
this.wsRouter = new Router<WsEntry>()
|
|
992
|
+
this.wsRouteCount = 0
|
|
993
|
+
this.topics = undefined
|
|
994
|
+
this.routeList = []
|
|
995
|
+
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
996
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
997
|
+
this.gracefulSignals = options.gracefulSignals ?? false
|
|
998
|
+
this.logger = options.logger ?? jsonLogger()
|
|
999
|
+
this.bunServer = undefined
|
|
1000
|
+
this.derives = []
|
|
1001
|
+
this.decorations = {}
|
|
1002
|
+
this.beforeHandleHooks = []
|
|
1003
|
+
this.afterHandleHooks = []
|
|
1004
|
+
this.onErrorHooks = []
|
|
1005
|
+
this.aroundHooks = []
|
|
1006
|
+
this.onRequestHooks = []
|
|
1007
|
+
this.onResponseHooks = []
|
|
1008
|
+
this.responseRequests = new WeakMap()
|
|
1009
|
+
this.appliedPlugins = new Set()
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Add a per-request, computed context extension for subsequent routes. */
|
|
1013
|
+
derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
|
|
1014
|
+
this.derives.push(fn as unknown as RawDerive)
|
|
1015
|
+
return this as unknown as Server<R, Ctx & D>
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Add a static context value for subsequent routes. */
|
|
1019
|
+
decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
|
|
1020
|
+
this.decorations[key] = value
|
|
1021
|
+
return this as unknown as Server<R, Ctx & Record<K, V>>
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
|
|
1026
|
+
* `Request` to continue routing with a rewritten method/URL/headers. Global.
|
|
1027
|
+
*/
|
|
1028
|
+
onRequest(fn: (req: Request) => MaybePromise<OnRequestResult>): this {
|
|
1029
|
+
this.onRequestHooks.push(fn)
|
|
1030
|
+
return this
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
|
|
1034
|
+
beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
1035
|
+
this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
|
|
1036
|
+
return this
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
|
|
1041
|
+
* route output, so wrappers like async context storage do not force Node's direct JSON path through
|
|
1042
|
+
* a Web `Response`. The first registered wrapper is outermost.
|
|
1043
|
+
*/
|
|
1044
|
+
around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
|
|
1045
|
+
this.aroundHooks.push(fn as unknown as RawAround)
|
|
1046
|
+
return this
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
1050
|
+
afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
1051
|
+
this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
|
|
1052
|
+
return this
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
|
|
1056
|
+
onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
1057
|
+
this.onErrorHooks.push(fn as unknown as RawErrorHandler)
|
|
1058
|
+
return this
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
|
|
1062
|
+
onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
|
|
1063
|
+
this.onResponseHooks.push(fn)
|
|
1064
|
+
return this
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) — it
|
|
1069
|
+
* registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
|
|
1070
|
+
* and context fully intact. This overload exists specifically so a *named* identity plugin (e.g.
|
|
1071
|
+
* `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
|
|
1072
|
+
* defeat the generic inference of the transforming overload below and collapse the result to `any`.
|
|
1073
|
+
*/
|
|
1074
|
+
use(plugin: IdentityPlugin): this
|
|
1075
|
+
/**
|
|
1076
|
+
* Apply a **plugin function** — `(app) => app`, typically built with {@link definePlugin}. It's
|
|
1077
|
+
* called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
|
|
1078
|
+
* the added context to handlers defined after `use` (the overload is generic over the concrete
|
|
1079
|
+
* `this`). A named plugin already applied is skipped (idempotent dedupe).
|
|
1080
|
+
*/
|
|
1081
|
+
use<Out extends AnyServer>(plugin: (app: this) => Out): Out
|
|
1082
|
+
/**
|
|
1083
|
+
* Apply a {@link Middleware} bundle — wire each hook it provides to its lifecycle point. Returns
|
|
1084
|
+
* `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
|
|
1085
|
+
* should cover (those are order-scoped; `onRequest`/`onResponse` are global). A named bundle already
|
|
1086
|
+
* applied is skipped (idempotent).
|
|
1087
|
+
*/
|
|
1088
|
+
use(mw: Middleware): this
|
|
1089
|
+
use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
|
|
1090
|
+
if (typeof arg === "function") {
|
|
1091
|
+
const name = (arg as { pluginName?: string }).pluginName
|
|
1092
|
+
if (name !== undefined) {
|
|
1093
|
+
if (this.appliedPlugins.has(name)) return this // idempotent: already applied
|
|
1094
|
+
this.appliedPlugins.add(name)
|
|
1095
|
+
}
|
|
1096
|
+
return arg(this)
|
|
1097
|
+
}
|
|
1098
|
+
if (arg.name !== undefined) {
|
|
1099
|
+
if (this.appliedPlugins.has(arg.name)) return this
|
|
1100
|
+
this.appliedPlugins.add(arg.name)
|
|
1101
|
+
}
|
|
1102
|
+
if (arg.onRequest !== undefined) this.onRequest(arg.onRequest)
|
|
1103
|
+
if (arg.around !== undefined) this.around(arg.around)
|
|
1104
|
+
if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
|
|
1105
|
+
if (arg.afterHandle !== undefined) this.afterHandle(arg.afterHandle)
|
|
1106
|
+
if (arg.onResponse !== undefined) this.onResponse(arg.onResponse)
|
|
1107
|
+
if (arg.onError !== undefined) this.onError(arg.onError)
|
|
1108
|
+
return this
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
get<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
|
|
1112
|
+
path: Path,
|
|
1113
|
+
schema: S,
|
|
1114
|
+
handler: H,
|
|
1115
|
+
): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
|
|
1116
|
+
get<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
|
|
1117
|
+
path: Path,
|
|
1118
|
+
handler: H,
|
|
1119
|
+
): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
|
|
1120
|
+
get(
|
|
1121
|
+
path: string,
|
|
1122
|
+
schemaOrHandler: RouteSchema | ErasedHandler,
|
|
1123
|
+
handler?: ErasedHandler,
|
|
1124
|
+
): Server<Registry, Ctx> {
|
|
1125
|
+
return this.route("GET", path, schemaOrHandler, handler)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
post<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
|
|
1129
|
+
path: Path,
|
|
1130
|
+
schema: S,
|
|
1131
|
+
handler: H,
|
|
1132
|
+
): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
|
|
1133
|
+
post<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
|
|
1134
|
+
path: Path,
|
|
1135
|
+
handler: H,
|
|
1136
|
+
): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
|
|
1137
|
+
post(
|
|
1138
|
+
path: string,
|
|
1139
|
+
schemaOrHandler: RouteSchema | ErasedHandler,
|
|
1140
|
+
handler?: ErasedHandler,
|
|
1141
|
+
): Server<Registry, Ctx> {
|
|
1142
|
+
return this.route("POST", path, schemaOrHandler, handler)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
put<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
|
|
1146
|
+
path: Path,
|
|
1147
|
+
schema: S,
|
|
1148
|
+
handler: H,
|
|
1149
|
+
): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
|
|
1150
|
+
put<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
|
|
1151
|
+
path: Path,
|
|
1152
|
+
handler: H,
|
|
1153
|
+
): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
|
|
1154
|
+
put(
|
|
1155
|
+
path: string,
|
|
1156
|
+
schemaOrHandler: RouteSchema | ErasedHandler,
|
|
1157
|
+
handler?: ErasedHandler,
|
|
1158
|
+
): Server<Registry, Ctx> {
|
|
1159
|
+
return this.route("PUT", path, schemaOrHandler, handler)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
patch<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
|
|
1163
|
+
path: Path,
|
|
1164
|
+
schema: S,
|
|
1165
|
+
handler: H,
|
|
1166
|
+
): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
|
|
1167
|
+
patch<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
|
|
1168
|
+
path: Path,
|
|
1169
|
+
handler: H,
|
|
1170
|
+
): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
|
|
1171
|
+
patch(
|
|
1172
|
+
path: string,
|
|
1173
|
+
schemaOrHandler: RouteSchema | ErasedHandler,
|
|
1174
|
+
handler?: ErasedHandler,
|
|
1175
|
+
): Server<Registry, Ctx> {
|
|
1176
|
+
return this.route("PATCH", path, schemaOrHandler, handler)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
delete<Path extends string, S extends RouteSchema, H extends Handler<Path, S, Ctx>>(
|
|
1180
|
+
path: Path,
|
|
1181
|
+
schema: S,
|
|
1182
|
+
handler: H,
|
|
1183
|
+
): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
|
|
1184
|
+
delete<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
|
|
1185
|
+
path: Path,
|
|
1186
|
+
handler: H,
|
|
1187
|
+
): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
|
|
1188
|
+
delete(
|
|
1189
|
+
path: string,
|
|
1190
|
+
schemaOrHandler: RouteSchema | ErasedHandler,
|
|
1191
|
+
handler?: ErasedHandler,
|
|
1192
|
+
): Server<Registry, Ctx> {
|
|
1193
|
+
return this.route("DELETE", path, schemaOrHandler, handler)
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
|
|
1198
|
+
* `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
|
|
1199
|
+
* reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
|
|
1200
|
+
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) — not by bare `app.fetch`, which
|
|
1201
|
+
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
1202
|
+
*
|
|
1203
|
+
* app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
|
|
1204
|
+
*/
|
|
1205
|
+
ws<Data = unknown, Schema extends StandardSchemaV1 | undefined = undefined>(
|
|
1206
|
+
path: string,
|
|
1207
|
+
handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema>,
|
|
1208
|
+
): this {
|
|
1209
|
+
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
1210
|
+
// bundle it. Registration is the loud, early failure point — never the first connection.
|
|
1211
|
+
const runtime = requireWsRuntime()
|
|
1212
|
+
this.topics ??= runtime.createTopics()
|
|
1213
|
+
// A `messageSchema` wraps `message` with validation once, here — every adapter then dispatches
|
|
1214
|
+
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
1215
|
+
this.wsRouter.add("GET", path, {
|
|
1216
|
+
handler: runtime.wrapHandler(handler as WebSocketHandler),
|
|
1217
|
+
})
|
|
1218
|
+
this.wsRouteCount += 1
|
|
1219
|
+
return this
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
|
|
1224
|
+
* In-process and **single-instance** (see {@link TopicRegistry}) — a multi-instance deploy must bridge
|
|
1225
|
+
* an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
|
|
1226
|
+
*/
|
|
1227
|
+
publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
|
|
1228
|
+
// No `app.ws()` yet ⇒ no registry and necessarily no subscribers — a publish is a no-op anyway.
|
|
1229
|
+
this.topics?.publish(topic, data)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private route(
|
|
1233
|
+
method: Method,
|
|
1234
|
+
path: string,
|
|
1235
|
+
schemaOrHandler: RouteSchema | ErasedHandler,
|
|
1236
|
+
handler?: ErasedHandler,
|
|
1237
|
+
): Server<Registry, Ctx> {
|
|
1238
|
+
let rawHandler: ErasedHandler
|
|
1239
|
+
let schema: RouteSchema | undefined
|
|
1240
|
+
if (handler !== undefined) {
|
|
1241
|
+
schema = schemaOrHandler as RouteSchema
|
|
1242
|
+
rawHandler = handler
|
|
1243
|
+
} else {
|
|
1244
|
+
schema = undefined
|
|
1245
|
+
rawHandler = schemaOrHandler as ErasedHandler
|
|
1246
|
+
}
|
|
1247
|
+
this.register(method, path, schema, rawHandler)
|
|
1248
|
+
// The accumulated registry type is compile-time only; the same instance
|
|
1249
|
+
// carries every route, so the public methods re-type `this` per call.
|
|
1250
|
+
return this as unknown as Server<Registry, Ctx>
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Low-level route registration shared by the inline builder and `implement()`.
|
|
1255
|
+
* Captures the server's current `derive`/`decorate` chain into the route — this
|
|
1256
|
+
* is the "compiled", order-scoped per-route chain.
|
|
1257
|
+
*/
|
|
1258
|
+
register(
|
|
1259
|
+
method: Method,
|
|
1260
|
+
path: string,
|
|
1261
|
+
schema: RouteSchema | undefined,
|
|
1262
|
+
handler: (context: never) => unknown,
|
|
1263
|
+
): void {
|
|
1264
|
+
const bare =
|
|
1265
|
+
schema?.body === undefined &&
|
|
1266
|
+
schema?.query === undefined &&
|
|
1267
|
+
this.derives.length === 0 &&
|
|
1268
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1269
|
+
this.afterHandleHooks.length === 0 &&
|
|
1270
|
+
this.onErrorHooks.length === 0
|
|
1271
|
+
const fusedWeb =
|
|
1272
|
+
bare && this.aroundHooks.length === 0
|
|
1273
|
+
? this.buildFusedWeb(
|
|
1274
|
+
handler as unknown as InternalHandler,
|
|
1275
|
+
Object.keys(this.decorations).length > 0 ? { ...this.decorations } : undefined,
|
|
1276
|
+
isContextlessNoArgArrow(handler),
|
|
1277
|
+
)
|
|
1278
|
+
: undefined
|
|
1279
|
+
this.router.add(method, path, {
|
|
1280
|
+
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
1281
|
+
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
1282
|
+
handler: handler as unknown as InternalHandler,
|
|
1283
|
+
schema,
|
|
1284
|
+
fusedWeb,
|
|
1285
|
+
derives: [...this.derives],
|
|
1286
|
+
decorations: { ...this.decorations },
|
|
1287
|
+
hasDecorations: Object.keys(this.decorations).length > 0,
|
|
1288
|
+
beforeHandle: [...this.beforeHandleHooks],
|
|
1289
|
+
afterHandle: [...this.afterHandleHooks],
|
|
1290
|
+
onError: [...this.onErrorHooks],
|
|
1291
|
+
around: [...this.aroundHooks],
|
|
1292
|
+
// Sync-fast-path eligibility (see RouteEntry.bare). Validation + every hook kind must be absent;
|
|
1293
|
+
// decorations and generic around wrappers are fine: around wraps the inner bare route in
|
|
1294
|
+
// `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
|
|
1295
|
+
// Most GET/no-schema routes qualify.
|
|
1296
|
+
bare,
|
|
1297
|
+
contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
|
|
1298
|
+
bodyOnly:
|
|
1299
|
+
schema?.body !== undefined &&
|
|
1300
|
+
schema.query === undefined &&
|
|
1301
|
+
this.derives.length === 0 &&
|
|
1302
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1303
|
+
this.afterHandleHooks.length === 0 &&
|
|
1304
|
+
this.onErrorHooks.length === 0,
|
|
1305
|
+
queryOnly:
|
|
1306
|
+
schema?.body === undefined &&
|
|
1307
|
+
schema?.query !== undefined &&
|
|
1308
|
+
this.derives.length === 0 &&
|
|
1309
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1310
|
+
this.afterHandleHooks.length === 0 &&
|
|
1311
|
+
this.onErrorHooks.length === 0,
|
|
1312
|
+
})
|
|
1313
|
+
this.routeList.push({ method, path, schema })
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Enumerate the registered routes (method, path, input schemas), in registration
|
|
1318
|
+
* order. Powers `toOpenAPI` and other introspection; the router trie itself no
|
|
1319
|
+
* longer holds the original patterns.
|
|
1320
|
+
*/
|
|
1321
|
+
routes(): ReadonlyArray<RouteDescriptor> {
|
|
1322
|
+
return this.routeList
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Resolve a `Request` to a `Response` — the whole lifecycle, testable without a port. The
|
|
1327
|
+
* optional `platform` carries edge inputs (`env`, `waitUntil`); edge adapters pass it, and
|
|
1328
|
+
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
1329
|
+
*/
|
|
1330
|
+
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
1331
|
+
// A real `Request` satisfies `RequestSource`, so it's passed straight through — no per-request
|
|
1332
|
+
// wrapper allocation on the Web/Bun hot path.
|
|
1333
|
+
return this.fetchSource(req, platform)
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
private fetchSource(
|
|
1337
|
+
source: RequestSource,
|
|
1338
|
+
platform?: Platform<EnvOf<Ctx>>,
|
|
1339
|
+
): MaybePromise<Response> {
|
|
1340
|
+
// Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
|
|
1341
|
+
// path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
|
|
1342
|
+
// promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
|
|
1343
|
+
// `await app.fetch(...)` continues to work exactly as before.
|
|
1344
|
+
const outcome = this.dispatch<Response>(
|
|
1345
|
+
source,
|
|
1346
|
+
platform,
|
|
1347
|
+
toResponse,
|
|
1348
|
+
IDENTITY_RESPONSE,
|
|
1349
|
+
RESPONSE_TIMEOUT,
|
|
1350
|
+
true,
|
|
1351
|
+
)
|
|
1352
|
+
if (this.onResponseHooks.length === 0) {
|
|
1353
|
+
return outcome
|
|
1354
|
+
}
|
|
1355
|
+
// onResponse sees every response — success, validation error, 404/405, timeout, onRequest
|
|
1356
|
+
// short-circuit; normalize to a promise, then thread through the hooks.
|
|
1357
|
+
return outcome instanceof Promise
|
|
1358
|
+
? outcome.then((response) => this.applyOnResponse(response, this.takeResponseRequest(source)))
|
|
1359
|
+
: this.applyOnResponse(outcome, this.takeResponseRequest(source))
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
|
|
1364
|
+
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
1365
|
+
* `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
|
|
1366
|
+
* returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
|
|
1367
|
+
* full Web path (`{ kind: "response" }`), so behavior is identical — only the common JSON-data case is
|
|
1368
|
+
* faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
|
|
1369
|
+
* render differs.
|
|
1370
|
+
*/
|
|
1371
|
+
resolveNode(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<NodeServeOutcome> {
|
|
1372
|
+
return this.resolveNodeSource(req, platform)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
resolveNodeSource(
|
|
1376
|
+
source: RequestSource,
|
|
1377
|
+
platform?: Platform<EnvOf<Ctx>>,
|
|
1378
|
+
): MaybePromise<NodeServeOutcome> {
|
|
1379
|
+
// onResponse hooks transform a Response, so they force the Web path; wrap its result.
|
|
1380
|
+
if (this.onResponseHooks.length > 0) {
|
|
1381
|
+
const response = this.fetchSource(source, platform)
|
|
1382
|
+
return response instanceof Promise
|
|
1383
|
+
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
1384
|
+
: { kind: "response", response }
|
|
1385
|
+
}
|
|
1386
|
+
// May resolve **synchronously** for a bare route + sync handler (RouteEntry.bare) — the `@nifrajs/node`
|
|
1387
|
+
// adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
|
|
1388
|
+
// at all on the Node hot path.
|
|
1389
|
+
return this.dispatch<NodeServeOutcome>(
|
|
1390
|
+
source,
|
|
1391
|
+
platform,
|
|
1392
|
+
toNodeOutcome,
|
|
1393
|
+
NODE_FROM_RESPONSE,
|
|
1394
|
+
NODE_TIMEOUT,
|
|
1395
|
+
false,
|
|
1396
|
+
)
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Resolve a WebSocket upgrade — the seam every serving adapter uses. Returns `pass` (not a WS
|
|
1401
|
+
* upgrade for a registered route → handle as normal HTTP), `reject` (a WS route matched but
|
|
1402
|
+
* `upgrade()` rejected, or the path was malformed → return `response`), or `upgrade` (perform the
|
|
1403
|
+
* runtime upgrade, then dispatch the native socket's events to `handler`, seeding `ws.data` with
|
|
1404
|
+
* `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
|
|
1405
|
+
* `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
|
|
1406
|
+
*/
|
|
1407
|
+
resolveWebSocketUpgrade(
|
|
1408
|
+
req: Request,
|
|
1409
|
+
platform?: Platform<EnvOf<Ctx>>,
|
|
1410
|
+
): MaybePromise<WebSocketUpgradeOutcome> {
|
|
1411
|
+
if (this.wsRouteCount === 0) return WS_PASS
|
|
1412
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
|
|
1413
|
+
const url = urlPartsOf(req.url)
|
|
1414
|
+
const match = this.wsRouter.find("GET", url.pathname)
|
|
1415
|
+
if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
|
|
1416
|
+
const params = match.params === EMPTY_PARAMS ? EMPTY_PARAMS : decodeParams(match.params)
|
|
1417
|
+
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
1418
|
+
const handler = match.payload.handler
|
|
1419
|
+
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
|
|
1420
|
+
const pubsub = this.topics as TopicRegistry
|
|
1421
|
+
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
1422
|
+
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
1423
|
+
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
1424
|
+
if (handler.allowedOrigins !== undefined) {
|
|
1425
|
+
const origin = req.headers.get("origin")
|
|
1426
|
+
const allowed =
|
|
1427
|
+
typeof handler.allowedOrigins === "function"
|
|
1428
|
+
? handler.allowedOrigins(origin)
|
|
1429
|
+
: origin !== null && handler.allowedOrigins.includes(origin)
|
|
1430
|
+
if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
1431
|
+
}
|
|
1432
|
+
if (handler.upgrade === undefined) {
|
|
1433
|
+
return { kind: "upgrade", handler, data: undefined, pubsub }
|
|
1434
|
+
}
|
|
1435
|
+
const ctx = new RequestContext(
|
|
1436
|
+
req,
|
|
1437
|
+
params,
|
|
1438
|
+
url.search,
|
|
1439
|
+
getNeverAbortSignal(),
|
|
1440
|
+
platform,
|
|
1441
|
+
this.maxBodyBytes,
|
|
1442
|
+
)
|
|
1443
|
+
const settle = (value: unknown): WebSocketUpgradeOutcome =>
|
|
1444
|
+
value instanceof Response
|
|
1445
|
+
? { kind: "reject", response: value }
|
|
1446
|
+
: { kind: "upgrade", handler, data: value, pubsub }
|
|
1447
|
+
try {
|
|
1448
|
+
const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
|
|
1449
|
+
return result instanceof Promise
|
|
1450
|
+
? result.then(settle, () => ({
|
|
1451
|
+
kind: "reject" as const,
|
|
1452
|
+
response: jsonError(500, "internal_error"),
|
|
1453
|
+
}))
|
|
1454
|
+
: settle(result)
|
|
1455
|
+
} catch {
|
|
1456
|
+
return { kind: "reject", response: jsonError(500, "internal_error") }
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
|
|
1461
|
+
* `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
|
|
1462
|
+
* (The socket dispatch itself lives in `ws-bun.ts`, loaded via `@nifrajs/core/ws`.) */
|
|
1463
|
+
private bunFetchWithWebSocket(
|
|
1464
|
+
req: Request,
|
|
1465
|
+
server: BunUpgradeServer,
|
|
1466
|
+
): MaybePromise<Response | undefined> {
|
|
1467
|
+
const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
|
|
1468
|
+
if (o.kind === "pass") return this.fetch(req)
|
|
1469
|
+
if (o.kind === "reject") return o.response
|
|
1470
|
+
return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
|
|
1471
|
+
? undefined
|
|
1472
|
+
: jsonError(426, "upgrade_required")
|
|
1473
|
+
}
|
|
1474
|
+
const outcome = this.resolveWebSocketUpgrade(req)
|
|
1475
|
+
return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* The shared lifecycle, generic over how the final value is rendered: `finalize` turns a handler's
|
|
1480
|
+
* result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
|
|
1481
|
+
* primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
|
|
1482
|
+
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
1483
|
+
* context + lifecycle implementation — no duplication across the trust boundary.
|
|
1484
|
+
*/
|
|
1485
|
+
private dispatch<T>(
|
|
1486
|
+
source: RequestSource,
|
|
1487
|
+
platform: Platform | undefined,
|
|
1488
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1489
|
+
wrapResponse: (response: Response) => T,
|
|
1490
|
+
onTimeout: () => T,
|
|
1491
|
+
// True only from the Web `fetch` path — unlocks each route's fused lane, whose output type IS
|
|
1492
|
+
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
1493
|
+
webFast: boolean,
|
|
1494
|
+
): MaybePromise<T> {
|
|
1495
|
+
// onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
|
|
1496
|
+
// case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
|
|
1497
|
+
if (this.onRequestHooks.length === 0) {
|
|
1498
|
+
return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast)
|
|
1499
|
+
}
|
|
1500
|
+
return this.runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast)
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
1505
|
+
* common case — e.g. CORS returning `undefined` for a non-preflight request): an `async` version
|
|
1506
|
+
* here put EVERY request of any app with one onRequest hook onto the promise machinery, profiled
|
|
1507
|
+
* at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
|
|
1508
|
+
* hooks to the async continuation; behavior is identical.
|
|
1509
|
+
*/
|
|
1510
|
+
private runWithOnRequest<T>(
|
|
1511
|
+
source: RequestSource,
|
|
1512
|
+
platform: Platform | undefined,
|
|
1513
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1514
|
+
wrapResponse: (response: Response) => T,
|
|
1515
|
+
onTimeout: () => T,
|
|
1516
|
+
webFast: boolean,
|
|
1517
|
+
): MaybePromise<T> {
|
|
1518
|
+
const hooks = this.onRequestHooks
|
|
1519
|
+
const originalRequest = requestOf(source)
|
|
1520
|
+
let current: RequestSource = source
|
|
1521
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
1522
|
+
const outcome = (hooks[i] as RawOnRequest)(requestOf(current))
|
|
1523
|
+
if (outcome instanceof Promise) {
|
|
1524
|
+
return outcome.then((early) =>
|
|
1525
|
+
this.continueOnRequest(
|
|
1526
|
+
early,
|
|
1527
|
+
i + 1,
|
|
1528
|
+
originalRequest,
|
|
1529
|
+
current,
|
|
1530
|
+
platform,
|
|
1531
|
+
finalize,
|
|
1532
|
+
wrapResponse,
|
|
1533
|
+
onTimeout,
|
|
1534
|
+
webFast,
|
|
1535
|
+
),
|
|
1536
|
+
)
|
|
1537
|
+
}
|
|
1538
|
+
if (outcome instanceof Request) {
|
|
1539
|
+
current = outcome
|
|
1540
|
+
if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
|
|
1541
|
+
continue
|
|
1542
|
+
}
|
|
1543
|
+
if (outcome !== undefined) return wrapResponse(outcome)
|
|
1544
|
+
}
|
|
1545
|
+
return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
|
|
1549
|
+
* the remaining hooks (awaiting freely — we're already async here). */
|
|
1550
|
+
private async continueOnRequest<T>(
|
|
1551
|
+
first: OnRequestResult,
|
|
1552
|
+
nextIndex: number,
|
|
1553
|
+
originalRequest: Request,
|
|
1554
|
+
sourceAtAwait: RequestSource,
|
|
1555
|
+
platform: Platform | undefined,
|
|
1556
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1557
|
+
wrapResponse: (response: Response) => T,
|
|
1558
|
+
onTimeout: () => T,
|
|
1559
|
+
webFast: boolean,
|
|
1560
|
+
): Promise<T> {
|
|
1561
|
+
let current = sourceAtAwait
|
|
1562
|
+
let early = first
|
|
1563
|
+
let index = nextIndex
|
|
1564
|
+
for (;;) {
|
|
1565
|
+
if (early instanceof Request) {
|
|
1566
|
+
current = early
|
|
1567
|
+
if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
|
|
1568
|
+
} else if (early !== undefined) {
|
|
1569
|
+
return wrapResponse(early)
|
|
1570
|
+
}
|
|
1571
|
+
if (index >= this.onRequestHooks.length) break
|
|
1572
|
+
const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current))
|
|
1573
|
+
early = outcome instanceof Promise ? await outcome : outcome
|
|
1574
|
+
index++
|
|
1575
|
+
}
|
|
1576
|
+
return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
private takeResponseRequest(source: RequestSource): Request {
|
|
1580
|
+
const request = requestOf(source)
|
|
1581
|
+
const rewritten = this.responseRequests.get(request)
|
|
1582
|
+
if (rewritten === undefined) return request
|
|
1583
|
+
this.responseRequests.delete(request)
|
|
1584
|
+
return rewritten
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Route → build context → run. Synchronous through to the handler for a **bare** route
|
|
1589
|
+
* ({@link RouteEntry.bare}), so a sync handler produces its result with zero promise allocations;
|
|
1590
|
+
* routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
|
|
1591
|
+
*/
|
|
1592
|
+
private routeAndRun<T>(
|
|
1593
|
+
source: RequestSource,
|
|
1594
|
+
platform: Platform | undefined,
|
|
1595
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1596
|
+
wrapResponse: (response: Response) => T,
|
|
1597
|
+
onTimeout: () => T,
|
|
1598
|
+
webFast: boolean,
|
|
1599
|
+
): MaybePromise<T> {
|
|
1600
|
+
const url = urlPartsOf(source.url)
|
|
1601
|
+
const match = this.router.find(source.method, url.pathname)
|
|
1602
|
+
if (!match.found) {
|
|
1603
|
+
if (match.reason === "method-not-allowed") {
|
|
1604
|
+
return wrapResponse(
|
|
1605
|
+
jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }),
|
|
1606
|
+
)
|
|
1607
|
+
}
|
|
1608
|
+
return wrapResponse(jsonError(404, "not_found"))
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const params = match.params === EMPTY_PARAMS ? EMPTY_PARAMS : decodeParams(match.params)
|
|
1612
|
+
if (params === null) {
|
|
1613
|
+
return wrapResponse(jsonError(400, "malformed_path"))
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const entry = match.payload
|
|
1617
|
+
// A per-request cancellation signal. Only allocate a real controller when a
|
|
1618
|
+
// timeout is armed; otherwise share a never-aborting signal so the (common)
|
|
1619
|
+
// no-timeout path pays nothing.
|
|
1620
|
+
let controller: AbortController | undefined
|
|
1621
|
+
let signal = getNeverAbortSignal()
|
|
1622
|
+
if (this.requestTimeoutMs > 0) {
|
|
1623
|
+
controller = new AbortController()
|
|
1624
|
+
signal = controller.signal
|
|
1625
|
+
}
|
|
1626
|
+
// A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
|
|
1627
|
+
// lifecycle. `runBare` returns a value directly for a sync handler — no promise.
|
|
1628
|
+
let outcome: MaybePromise<T>
|
|
1629
|
+
if (webFast && entry.fusedWeb !== undefined) {
|
|
1630
|
+
// Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
|
|
1631
|
+
// passed only by the `fetch` path, where T = Response by construction.
|
|
1632
|
+
outcome = entry.fusedWeb(source, params, url.search, signal, platform) as MaybePromise<T>
|
|
1633
|
+
} else if (entry.contextlessBare) {
|
|
1634
|
+
outcome = this.runContextlessBare(
|
|
1635
|
+
entry,
|
|
1636
|
+
source,
|
|
1637
|
+
params,
|
|
1638
|
+
url.search,
|
|
1639
|
+
signal,
|
|
1640
|
+
platform,
|
|
1641
|
+
finalize,
|
|
1642
|
+
wrapResponse,
|
|
1643
|
+
)
|
|
1644
|
+
} else {
|
|
1645
|
+
// Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
|
|
1646
|
+
// stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
|
|
1647
|
+
const ctx = new RequestContext(
|
|
1648
|
+
source,
|
|
1649
|
+
params,
|
|
1650
|
+
url.search,
|
|
1651
|
+
signal,
|
|
1652
|
+
platform,
|
|
1653
|
+
this.maxBodyBytes,
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
if (entry.around.length === 0) {
|
|
1657
|
+
outcome = entry.bare
|
|
1658
|
+
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
1659
|
+
: entry.bodyOnly
|
|
1660
|
+
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1661
|
+
: entry.queryOnly
|
|
1662
|
+
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1663
|
+
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse)
|
|
1664
|
+
} else {
|
|
1665
|
+
outcome = this.runWithAround(
|
|
1666
|
+
entry,
|
|
1667
|
+
ctx,
|
|
1668
|
+
() =>
|
|
1669
|
+
entry.bare
|
|
1670
|
+
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
1671
|
+
: entry.bodyOnly
|
|
1672
|
+
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1673
|
+
: entry.queryOnly
|
|
1674
|
+
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1675
|
+
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse),
|
|
1676
|
+
finalize,
|
|
1677
|
+
wrapResponse,
|
|
1678
|
+
)
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
1682
|
+
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
1683
|
+
if (controller !== undefined && outcome instanceof Promise) {
|
|
1684
|
+
return this.withTimeout(outcome, controller, onTimeout)
|
|
1685
|
+
}
|
|
1686
|
+
return outcome
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
1690
|
+
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
1691
|
+
private runContextlessBare<T>(
|
|
1692
|
+
entry: RouteEntry,
|
|
1693
|
+
source: RequestSource,
|
|
1694
|
+
params: Record<string, string>,
|
|
1695
|
+
search: string,
|
|
1696
|
+
signal: AbortSignal,
|
|
1697
|
+
platform: Platform | undefined,
|
|
1698
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1699
|
+
wrapResponse: (response: Response) => T,
|
|
1700
|
+
): MaybePromise<T> {
|
|
1701
|
+
let result: unknown
|
|
1702
|
+
try {
|
|
1703
|
+
result = (entry.handler as unknown as ContextlessHandler)()
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse)
|
|
1706
|
+
}
|
|
1707
|
+
if (result instanceof Promise) {
|
|
1708
|
+
return result.then(
|
|
1709
|
+
(value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
|
|
1710
|
+
(err) =>
|
|
1711
|
+
this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse),
|
|
1712
|
+
)
|
|
1713
|
+
}
|
|
1714
|
+
return finalize(result, EMPTY_RESPONSE_CONTROLS)
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
private contextlessBareError<T>(
|
|
1718
|
+
err: unknown,
|
|
1719
|
+
source: RequestSource,
|
|
1720
|
+
params: Record<string, string>,
|
|
1721
|
+
search: string,
|
|
1722
|
+
signal: AbortSignal,
|
|
1723
|
+
platform: Platform | undefined,
|
|
1724
|
+
wrapResponse: (response: Response) => T,
|
|
1725
|
+
): T {
|
|
1726
|
+
if (err instanceof Response) return wrapResponse(err)
|
|
1727
|
+
const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes)
|
|
1728
|
+
this.logRequestError(err, ctx)
|
|
1729
|
+
return wrapResponse(jsonError(500, "internal_error"))
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* The synchronous fast path for a {@link RouteEntry.bare} route: apply static decorations, call the
|
|
1734
|
+
* handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
|
|
1735
|
+
* the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
|
|
1736
|
+
* {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
|
|
1737
|
+
* (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
|
|
1738
|
+
* the per-request async-frame tax — the same win codegen routers get, but without `eval`.
|
|
1739
|
+
*/
|
|
1740
|
+
private runBare<T>(
|
|
1741
|
+
entry: RouteEntry,
|
|
1742
|
+
ctx: RawContext,
|
|
1743
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1744
|
+
wrapResponse: (response: Response) => T,
|
|
1745
|
+
): MaybePromise<T> {
|
|
1746
|
+
let result: unknown
|
|
1747
|
+
try {
|
|
1748
|
+
if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
|
|
1749
|
+
result = entry.handler(ctx)
|
|
1750
|
+
} catch (err) {
|
|
1751
|
+
return this.bareError(err, ctx, wrapResponse)
|
|
1752
|
+
}
|
|
1753
|
+
if (result instanceof Promise) {
|
|
1754
|
+
// Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
|
|
1755
|
+
return result.then(
|
|
1756
|
+
(value) => finalize(value, responseSet(ctx)),
|
|
1757
|
+
(err) => this.bareError(err, ctx, wrapResponse),
|
|
1758
|
+
)
|
|
1759
|
+
}
|
|
1760
|
+
return finalize(result, responseSet(ctx))
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
|
|
1764
|
+
* loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
|
|
1765
|
+
/**
|
|
1766
|
+
* Build a route's fused Web lane (see {@link RouteEntry.fusedWeb}). Composition happens once at
|
|
1767
|
+
* registration; the returned closure is what every request to the route runs. Behavior is
|
|
1768
|
+
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
|
|
1769
|
+
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
1770
|
+
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1771
|
+
*/
|
|
1772
|
+
private buildFusedWeb(
|
|
1773
|
+
handler: InternalHandler,
|
|
1774
|
+
decorations: Record<string, unknown> | undefined,
|
|
1775
|
+
contextless: boolean,
|
|
1776
|
+
): FusedWebRunner {
|
|
1777
|
+
const maxBodyBytes = this.maxBodyBytes
|
|
1778
|
+
const logError = (err: unknown, ctx: RawContext): Response => {
|
|
1779
|
+
if (err instanceof Response) return err
|
|
1780
|
+
this.logRequestError(err, ctx)
|
|
1781
|
+
return jsonError(500, "internal_error")
|
|
1782
|
+
}
|
|
1783
|
+
if (contextless && decorations === undefined) {
|
|
1784
|
+
// `() => ...` can't observe the context — skip allocating one entirely (errors still build
|
|
1785
|
+
// one for the structured log, exactly like runContextlessBare).
|
|
1786
|
+
const contextlessHandler = handler as unknown as ContextlessHandler
|
|
1787
|
+
return (source, params, search, signal, platform) => {
|
|
1788
|
+
let result: unknown
|
|
1789
|
+
try {
|
|
1790
|
+
result = contextlessHandler()
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
return logError(
|
|
1793
|
+
err,
|
|
1794
|
+
new RequestContext(source, params, search, signal, platform, maxBodyBytes),
|
|
1795
|
+
)
|
|
1796
|
+
}
|
|
1797
|
+
if (result instanceof Promise) {
|
|
1798
|
+
return result.then(fusedRespondNoSet, (err) =>
|
|
1799
|
+
logError(
|
|
1800
|
+
err,
|
|
1801
|
+
new RequestContext(source, params, search, signal, platform, maxBodyBytes),
|
|
1802
|
+
),
|
|
1803
|
+
)
|
|
1804
|
+
}
|
|
1805
|
+
return fusedRespondNoSet(result)
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
return (source, params, search, signal, platform) => {
|
|
1809
|
+
const ctx = new RequestContext(source, params, search, signal, platform, maxBodyBytes)
|
|
1810
|
+
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
1811
|
+
let result: unknown
|
|
1812
|
+
try {
|
|
1813
|
+
result = handler(ctx)
|
|
1814
|
+
} catch (err) {
|
|
1815
|
+
return logError(err, ctx)
|
|
1816
|
+
}
|
|
1817
|
+
if (result instanceof Promise) {
|
|
1818
|
+
return result.then(
|
|
1819
|
+
(value) => fusedRespond(value, ctx),
|
|
1820
|
+
(err) => logError(err, ctx),
|
|
1821
|
+
)
|
|
1822
|
+
}
|
|
1823
|
+
return fusedRespond(result, ctx)
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
private bareError<T>(err: unknown, ctx: RawContext, wrapResponse: (response: Response) => T): T {
|
|
1828
|
+
if (err instanceof Response) return wrapResponse(err)
|
|
1829
|
+
this.logRequestError(err, ctx)
|
|
1830
|
+
return wrapResponse(jsonError(500, "internal_error"))
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
private runWithAround<T>(
|
|
1834
|
+
entry: RouteEntry,
|
|
1835
|
+
ctx: RawContext,
|
|
1836
|
+
run: () => MaybePromise<T>,
|
|
1837
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1838
|
+
wrapResponse: (response: Response) => T,
|
|
1839
|
+
): MaybePromise<T> {
|
|
1840
|
+
let outcome: MaybePromise<T>
|
|
1841
|
+
try {
|
|
1842
|
+
outcome = this.runAround(entry.around, ctx, run)
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
1845
|
+
}
|
|
1846
|
+
return outcome instanceof Promise
|
|
1847
|
+
? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
|
|
1848
|
+
: outcome
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
private runAround<T>(
|
|
1852
|
+
hooks: ReadonlyArray<RawAround>,
|
|
1853
|
+
ctx: RawContext,
|
|
1854
|
+
run: () => MaybePromise<T>,
|
|
1855
|
+
): MaybePromise<T> {
|
|
1856
|
+
const dispatch = (index: number): MaybePromise<T> => {
|
|
1857
|
+
if (index >= hooks.length) return run()
|
|
1858
|
+
const hook = hooks[index]!
|
|
1859
|
+
let called = false
|
|
1860
|
+
return hook(ctx, () => {
|
|
1861
|
+
if (called) throw new Error("around next() called multiple times")
|
|
1862
|
+
called = true
|
|
1863
|
+
return dispatch(index + 1)
|
|
1864
|
+
})
|
|
1865
|
+
}
|
|
1866
|
+
return dispatch(0)
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
private runBodyOnly<T>(
|
|
1870
|
+
entry: RouteEntry,
|
|
1871
|
+
source: RequestSource,
|
|
1872
|
+
ctx: RawContext,
|
|
1873
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1874
|
+
wrapResponse: (response: Response) => T,
|
|
1875
|
+
): Promise<T> {
|
|
1876
|
+
const contentType = headerOf(source, "content-type") ?? ""
|
|
1877
|
+
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
1878
|
+
if (isUrlEncodedForm(contentType)) {
|
|
1879
|
+
// HTML form posts validate through the same schema as JSON (parity with Elysia/Hono).
|
|
1880
|
+
// Off the JSON inline lane on purpose: forms are a fraction of API traffic, and the
|
|
1881
|
+
// bounded form reader shares the streaming guard. JSON requests are untouched.
|
|
1882
|
+
return readBoundedForm(source, this.maxBodyBytes).then((form) => {
|
|
1883
|
+
if (form instanceof Response) return wrapResponse(form)
|
|
1884
|
+
return this.finishBodyOnly(entry, form, ctx, finalize, wrapResponse) as Promise<T> | T
|
|
1885
|
+
}) as Promise<T>
|
|
1886
|
+
}
|
|
1887
|
+
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
|
|
1891
|
+
// `req.json()` directly — one promise, one `.then` closure per request. The generic
|
|
1892
|
+
// `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
|
|
1893
|
+
// closures) is kept for the chunked / length-less / oversized cases it exists for.
|
|
1894
|
+
// Semantics are identical to readBoundedJsonSource — same checks, same error codes.
|
|
1895
|
+
const declared = headerOf(source, "content-length")
|
|
1896
|
+
if (declared !== null) {
|
|
1897
|
+
const length = parseContentLength(declared)
|
|
1898
|
+
if (length === undefined) {
|
|
1899
|
+
return Promise.resolve(wrapResponse(jsonError(400, "invalid_content_length")))
|
|
1900
|
+
}
|
|
1901
|
+
if (length > this.maxBodyBytes) {
|
|
1902
|
+
return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")))
|
|
1903
|
+
}
|
|
1904
|
+
if (headerOf(source, "transfer-encoding") === null) {
|
|
1905
|
+
return source.json().then(
|
|
1906
|
+
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
1907
|
+
// Native json() rejection = malformed JSON; anything past parse goes through
|
|
1908
|
+
// finishBodyOnly's own error handling.
|
|
1909
|
+
() => wrapResponse(jsonError(400, "invalid_json")),
|
|
1910
|
+
)
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
try {
|
|
1915
|
+
return this.readBoundedJson(source).then(
|
|
1916
|
+
(parsed) => {
|
|
1917
|
+
if (parsed instanceof Response) return wrapResponse(parsed)
|
|
1918
|
+
return this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse)
|
|
1919
|
+
},
|
|
1920
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
1921
|
+
)
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/** Validate + run the handler for the bodyOnly path — shared by the inline fast path and the
|
|
1928
|
+
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
1929
|
+
* beyond the one `.then` continuation. */
|
|
1930
|
+
private finishBodyOnly<T>(
|
|
1931
|
+
entry: RouteEntry,
|
|
1932
|
+
parsed: unknown,
|
|
1933
|
+
ctx: RawContext,
|
|
1934
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1935
|
+
wrapResponse: (response: Response) => T,
|
|
1936
|
+
): MaybePromise<T> {
|
|
1937
|
+
try {
|
|
1938
|
+
const bodySchema = entry.schema!.body!
|
|
1939
|
+
const validation = bodySchema["~standard"].validate(parsed)
|
|
1940
|
+
if (validation instanceof Promise) {
|
|
1941
|
+
return validation.then(
|
|
1942
|
+
(result) => {
|
|
1943
|
+
try {
|
|
1944
|
+
const outcome = this.applyBodyValidation(entry, result, ctx, finalize, wrapResponse)
|
|
1945
|
+
return outcome instanceof Promise
|
|
1946
|
+
? outcome.catch((err) =>
|
|
1947
|
+
this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
1948
|
+
)
|
|
1949
|
+
: outcome
|
|
1950
|
+
} catch (err) {
|
|
1951
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
1955
|
+
)
|
|
1956
|
+
}
|
|
1957
|
+
const outcome = this.applyBodyValidation(entry, validation, ctx, finalize, wrapResponse)
|
|
1958
|
+
return outcome instanceof Promise
|
|
1959
|
+
? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
|
|
1960
|
+
: outcome
|
|
1961
|
+
} catch (err) {
|
|
1962
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
private applyBodyValidation<T>(
|
|
1967
|
+
entry: RouteEntry,
|
|
1968
|
+
result: StandardResult<unknown>,
|
|
1969
|
+
ctx: RawContext,
|
|
1970
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1971
|
+
wrapResponse: (response: Response) => T,
|
|
1972
|
+
): MaybePromise<T> {
|
|
1973
|
+
if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
|
|
1974
|
+
ctx.body = result.value
|
|
1975
|
+
if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
|
|
1976
|
+
const handlerOutput = entry.handler(ctx)
|
|
1977
|
+
return handlerOutput instanceof Promise
|
|
1978
|
+
? handlerOutput.then((value) => finalize(value, responseSet(ctx)))
|
|
1979
|
+
: finalize(handlerOutput, responseSet(ctx))
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
private runQueryOnly<T>(
|
|
1983
|
+
entry: RouteEntry,
|
|
1984
|
+
ctx: RawContext,
|
|
1985
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
1986
|
+
wrapResponse: (response: Response) => T,
|
|
1987
|
+
): MaybePromise<T> {
|
|
1988
|
+
try {
|
|
1989
|
+
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`) — skip
|
|
1990
|
+
// `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
|
|
1991
|
+
const validation = entry.schema!.query!["~standard"].validate(
|
|
1992
|
+
queryObjectOf(ctx[CONTEXT_SEARCH]),
|
|
1993
|
+
)
|
|
1994
|
+
if (validation instanceof Promise) {
|
|
1995
|
+
return validation.then(
|
|
1996
|
+
(settled) => {
|
|
1997
|
+
try {
|
|
1998
|
+
const outcome = this.applyQueryValidation(entry, settled, ctx, finalize, wrapResponse)
|
|
1999
|
+
return outcome instanceof Promise
|
|
2000
|
+
? outcome.catch((err) =>
|
|
2001
|
+
this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
2002
|
+
)
|
|
2003
|
+
: outcome
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
2006
|
+
}
|
|
2007
|
+
},
|
|
2008
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
2009
|
+
)
|
|
2010
|
+
}
|
|
2011
|
+
return this.applyQueryValidation(entry, validation, ctx, finalize, wrapResponse)
|
|
2012
|
+
} catch (err) {
|
|
2013
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/** Validate-result → set `ctx.query` → run handler. A method (not a per-request closure), the
|
|
2018
|
+
* query analogue of {@link applyBodyValidation}. */
|
|
2019
|
+
private applyQueryValidation<T>(
|
|
2020
|
+
entry: RouteEntry,
|
|
2021
|
+
result: StandardResult<unknown>,
|
|
2022
|
+
ctx: RawContext,
|
|
2023
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2024
|
+
wrapResponse: (response: Response) => T,
|
|
2025
|
+
): MaybePromise<T> {
|
|
2026
|
+
if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
|
|
2027
|
+
ctx.query = result.value
|
|
2028
|
+
if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
|
|
2029
|
+
const handlerOutput = entry.handler(ctx)
|
|
2030
|
+
return handlerOutput instanceof Promise
|
|
2031
|
+
? handlerOutput.then(
|
|
2032
|
+
(value) => finalize(value, responseSet(ctx)),
|
|
2033
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
2034
|
+
)
|
|
2035
|
+
: finalize(handlerOutput, responseSet(ctx))
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
|
|
2040
|
+
* actually returns a Promise — an `async` version forced a promise + microtask on EVERY response
|
|
2041
|
+
* of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
|
|
2042
|
+
* same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
|
|
2043
|
+
* rest to {@link continueOnResponse}.
|
|
2044
|
+
*/
|
|
2045
|
+
private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
|
|
2046
|
+
const hooks = this.onResponseHooks
|
|
2047
|
+
let current = response
|
|
2048
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
2049
|
+
const next = (hooks[i] as RawOnResponse)(current, req)
|
|
2050
|
+
if (next instanceof Promise) {
|
|
2051
|
+
return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
|
|
2052
|
+
}
|
|
2053
|
+
current = next
|
|
2054
|
+
}
|
|
2055
|
+
return current
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
|
|
2059
|
+
private async continueOnResponse(
|
|
2060
|
+
response: Response,
|
|
2061
|
+
nextIndex: number,
|
|
2062
|
+
req: Request,
|
|
2063
|
+
): Promise<Response> {
|
|
2064
|
+
let current = response
|
|
2065
|
+
for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
|
|
2066
|
+
const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
|
|
2067
|
+
current = next instanceof Promise ? await next : next
|
|
2068
|
+
}
|
|
2069
|
+
return current
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
|
|
2074
|
+
* handlers can bail) and return 503; the in-flight work keeps running but its
|
|
2075
|
+
* result is discarded — JS can't forcibly cancel a promise.
|
|
2076
|
+
*/
|
|
2077
|
+
private async withTimeout<T>(
|
|
2078
|
+
work: Promise<T>,
|
|
2079
|
+
controller: AbortController,
|
|
2080
|
+
onTimeout: () => T,
|
|
2081
|
+
): Promise<T> {
|
|
2082
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2083
|
+
const timeout = new Promise<T>((resolve) => {
|
|
2084
|
+
timer = setTimeout(() => {
|
|
2085
|
+
controller.abort()
|
|
2086
|
+
resolve(onTimeout())
|
|
2087
|
+
}, this.requestTimeoutMs)
|
|
2088
|
+
})
|
|
2089
|
+
try {
|
|
2090
|
+
return await Promise.race([work, timeout])
|
|
2091
|
+
} finally {
|
|
2092
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
/**
|
|
2097
|
+
* The post-match lifecycle: validate → derive → beforeHandle → handler → afterHandle, with onError.
|
|
2098
|
+
* Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
|
|
2099
|
+
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
2100
|
+
*/
|
|
2101
|
+
private async runLifecycle<T>(
|
|
2102
|
+
entry: RouteEntry,
|
|
2103
|
+
source: RequestSource,
|
|
2104
|
+
ctx: RawContext,
|
|
2105
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2106
|
+
wrapResponse: (response: Response) => T,
|
|
2107
|
+
): Promise<T> {
|
|
2108
|
+
try {
|
|
2109
|
+
if (entry.schema?.body !== undefined) {
|
|
2110
|
+
const bodyError = await this.readAndValidateBody(source, entry.schema.body, ctx)
|
|
2111
|
+
if (bodyError !== undefined) return wrapResponse(bodyError)
|
|
2112
|
+
}
|
|
2113
|
+
if (entry.schema?.query !== undefined) {
|
|
2114
|
+
// Repeated query keys promote to `string[]` (so an array schema validates `?a=1&a=2`); a
|
|
2115
|
+
// single occurrence stays a string. Raw `.validate` (read `.issues`/`.value`) skips the
|
|
2116
|
+
// validateStandard wrapper alloc.
|
|
2117
|
+
const validation = entry.schema.query["~standard"].validate(
|
|
2118
|
+
queryObjectOf(ctx[CONTEXT_SEARCH]),
|
|
2119
|
+
)
|
|
2120
|
+
const result = validation instanceof Promise ? await validation : validation
|
|
2121
|
+
if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
|
|
2122
|
+
ctx.query = result.value
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// Context extensions: static decorations, then per-request derives.
|
|
2126
|
+
// Each hook below only awaits when it actually returns a Promise — a sync hook
|
|
2127
|
+
// skips the microtask tick (a fast-path; ~64 ns/hook).
|
|
2128
|
+
if (entry.hasDecorations) Object.assign(ctx, entry.decorations) // skip the no-op on bare routes
|
|
2129
|
+
// The `.length` guards skip iterator setup for the common no-hook route (most
|
|
2130
|
+
// routes have neither derives nor before/after hooks); a hooked route pays only
|
|
2131
|
+
// the length check.
|
|
2132
|
+
if (entry.derives.length > 0) {
|
|
2133
|
+
for (const derive of entry.derives) {
|
|
2134
|
+
const extension = derive(ctx)
|
|
2135
|
+
Object.assign(ctx, extension instanceof Promise ? await extension : extension)
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// beforeHandle: a non-undefined return short-circuits, skipping the handler.
|
|
2140
|
+
if (entry.beforeHandle.length > 0) {
|
|
2141
|
+
for (const hook of entry.beforeHandle) {
|
|
2142
|
+
const outcome = hook(ctx)
|
|
2143
|
+
const early = outcome instanceof Promise ? await outcome : outcome
|
|
2144
|
+
if (early !== undefined) return finalize(early, responseSet(ctx))
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const handlerOutput = entry.handler(ctx)
|
|
2149
|
+
let result = handlerOutput instanceof Promise ? await handlerOutput : handlerOutput
|
|
2150
|
+
|
|
2151
|
+
// afterHandle: transform the result before serialization.
|
|
2152
|
+
if (entry.afterHandle.length > 0) {
|
|
2153
|
+
for (const hook of entry.afterHandle) {
|
|
2154
|
+
const transformed = hook(result, ctx)
|
|
2155
|
+
result = transformed instanceof Promise ? await transformed : transformed
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
return finalize(result, responseSet(ctx))
|
|
2160
|
+
} catch (err) {
|
|
2161
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
private async handleLifecycleError<T>(
|
|
2166
|
+
entry: RouteEntry,
|
|
2167
|
+
err: unknown,
|
|
2168
|
+
ctx: RawContext,
|
|
2169
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2170
|
+
wrapResponse: (response: Response) => T,
|
|
2171
|
+
): Promise<T> {
|
|
2172
|
+
// A *thrown* Response is deliberate control flow, not an error — a guard throws a redirect/401,
|
|
2173
|
+
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
2174
|
+
// or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
|
|
2175
|
+
// any handler or loader.
|
|
2176
|
+
if (err instanceof Response) return wrapResponse(err)
|
|
2177
|
+
// onError hooks may return a custom response; otherwise the default 500 stands.
|
|
2178
|
+
for (const hook of entry.onError) {
|
|
2179
|
+
const outcome = hook(err, ctx)
|
|
2180
|
+
const handled = outcome instanceof Promise ? await outcome : outcome
|
|
2181
|
+
if (handled !== undefined) return finalize(handled, responseSet(ctx))
|
|
2182
|
+
}
|
|
2183
|
+
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
2184
|
+
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
2185
|
+
this.logRequestError(err, ctx)
|
|
2186
|
+
return wrapResponse(jsonError(500, "internal_error"))
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** Log an unhandled request error to the (redacting) logger — shared by {@link runLifecycle} and the
|
|
2190
|
+
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
2191
|
+
private logRequestError(err: unknown, ctx: RawContext): void {
|
|
2192
|
+
this.logger.error("unhandled request error", {
|
|
2193
|
+
method: ctx.req.method,
|
|
2194
|
+
path: pathnameOf(ctx.req.url),
|
|
2195
|
+
name: err instanceof Error ? err.name : "Error",
|
|
2196
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2197
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
2198
|
+
})
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
private async readAndValidateBody(
|
|
2202
|
+
req: RequestSource,
|
|
2203
|
+
schema: StandardSchemaV1,
|
|
2204
|
+
ctx: RawContext,
|
|
2205
|
+
): Promise<Response | undefined> {
|
|
2206
|
+
const contentType = headerOf(req, "content-type") ?? ""
|
|
2207
|
+
let parsed: unknown
|
|
2208
|
+
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
2209
|
+
const json = await this.readBoundedJson(req)
|
|
2210
|
+
if (json instanceof Response) return json
|
|
2211
|
+
parsed = json
|
|
2212
|
+
} else if (isUrlEncodedForm(contentType)) {
|
|
2213
|
+
const form = await readBoundedForm(req, this.maxBodyBytes)
|
|
2214
|
+
if (form instanceof Response) return form
|
|
2215
|
+
parsed = form
|
|
2216
|
+
} else {
|
|
2217
|
+
// multipart/form-data (file uploads) stays 415 on the schema path by design — files don't
|
|
2218
|
+
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
2219
|
+
return jsonError(415, "unsupported_media_type")
|
|
2220
|
+
}
|
|
2221
|
+
const validation = schema["~standard"].validate(parsed)
|
|
2222
|
+
const result = validation instanceof Promise ? await validation : validation
|
|
2223
|
+
if (result.issues !== undefined) return validationError(result.issues)
|
|
2224
|
+
ctx.body = result.value
|
|
2225
|
+
return undefined
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
|
|
2230
|
+
* `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
|
|
2231
|
+
* running byte count exceeds it — so a lying or absent length can't force us to
|
|
2232
|
+
* buffer an oversized payload.
|
|
2233
|
+
*
|
|
2234
|
+
* Fast path: when a non-chunked request carries a `Content-Length` within the cap,
|
|
2235
|
+
* a native `req.json()` is already bounded — under HTTP/1.1 + HTTP/2 framing the
|
|
2236
|
+
* runtime delivers at most `Content-Length` bytes — so we skip the manual stream
|
|
2237
|
+
* loop and a separate text decode. It trusts the wire
|
|
2238
|
+
* *framing*, not the header value: nifra only ever receives framed Requests from the
|
|
2239
|
+
* runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
|
|
2240
|
+
* length-less bodies fall through to the streaming byte-cap guard below.
|
|
2241
|
+
*/
|
|
2242
|
+
private async readBoundedJson(req: RequestSource): Promise<unknown | Response> {
|
|
2243
|
+
return readBoundedJsonSource(req, this.maxBodyBytes)
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|
|
2248
|
+
*
|
|
2249
|
+
* `reusePort` sets `SO_REUSEPORT` so **multiple processes can bind the same port** and the kernel
|
|
2250
|
+
* load-balances connections across them — the standard way to use every core (Bun is
|
|
2251
|
+
* single-threaded per process). Spawn one process per core, each calling
|
|
2252
|
+
* `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
|
|
2253
|
+
* and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
|
|
2254
|
+
* favor one socket (fine for dev, measure on Linux for production numbers).
|
|
2255
|
+
*/
|
|
2256
|
+
listen(port: number, options?: { readonly reusePort?: boolean }): RunningServer {
|
|
2257
|
+
if (typeof Bun === "undefined") {
|
|
2258
|
+
// listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
|
|
2259
|
+
// than letting the Bun.serve call below throw a bare `ReferenceError: Bun is not
|
|
2260
|
+
// defined`. Exercised by the @nifrajs/deno suite (which runs on a non-Bun runtime).
|
|
2261
|
+
throw new FrameworkError(
|
|
2262
|
+
"BUN_REQUIRED",
|
|
2263
|
+
"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.).",
|
|
2264
|
+
)
|
|
2265
|
+
}
|
|
2266
|
+
// Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
|
|
2267
|
+
// subset so the public types don't depend on the ambient `Bun` global. The cast
|
|
2268
|
+
// bridges them — Bun's `.port` is `number | undefined` (undefined only for unix
|
|
2269
|
+
// sockets, never a TCP `listen`) and its `.stop` returns a promise we don't await.
|
|
2270
|
+
// Pass only the request — Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
|
|
2271
|
+
// With WS routes, hand Bun a `websocket` config + a fetch that upgrades matching requests (the
|
|
2272
|
+
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
2273
|
+
// `websocket` handlers are one shared dispatcher — each connection's `ws.data.handler` is the
|
|
2274
|
+
// matched route's handler, set by `server.upgrade`.
|
|
2275
|
+
// With WS routes, the dispatcher comes from the `@nifrajs/core/ws` runtime — non-null because
|
|
2276
|
+
// wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
2277
|
+
const wsHandlers =
|
|
2278
|
+
this.wsRouteCount === 0
|
|
2279
|
+
? undefined
|
|
2280
|
+
: (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
2281
|
+
const reusePort = options?.reusePort === true
|
|
2282
|
+
const running = (wsHandlers === undefined
|
|
2283
|
+
? Bun.serve({ port, reusePort, fetch: (req: Request) => this.fetch(req) })
|
|
2284
|
+
: Bun.serve<BunWsData>({
|
|
2285
|
+
port,
|
|
2286
|
+
reusePort,
|
|
2287
|
+
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
2288
|
+
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
2289
|
+
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
2290
|
+
// `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
|
|
2291
|
+
websocket: {
|
|
2292
|
+
open: (ws) => wsHandlers.open(ws),
|
|
2293
|
+
message: (ws, message) => wsHandlers.message(ws, message),
|
|
2294
|
+
close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
|
|
2295
|
+
},
|
|
2296
|
+
})) as unknown as RunningServer
|
|
2297
|
+
this.bunServer = running
|
|
2298
|
+
if (this.gracefulSignals) this.installSignalHandlers()
|
|
2299
|
+
return running
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/**
|
|
2303
|
+
* Gracefully stop: wait for in-flight requests to finish (up to `drainMs`), then
|
|
2304
|
+
* issue a single terminal stop — graceful if everything drained, forced if
|
|
2305
|
+
* stragglers remain. Safe to call when not listening.
|
|
2306
|
+
*
|
|
2307
|
+
* The Bun semantics: poll `pendingRequests` (awaiting
|
|
2308
|
+
* `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
|
|
2309
|
+
* ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
|
|
2310
|
+
* New connections may be accepted during the drain window; in a real deploy the
|
|
2311
|
+
* load balancer has already stopped routing here, and `drainMs` bounds it.
|
|
2312
|
+
*/
|
|
2313
|
+
async stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
|
|
2314
|
+
const server = this.bunServer
|
|
2315
|
+
if (server === undefined) return
|
|
2316
|
+
this.bunServer = undefined
|
|
2317
|
+
const deadline = Date.now() + drainMs
|
|
2318
|
+
while (server.pendingRequests > 0 && Date.now() < deadline) {
|
|
2319
|
+
await Bun.sleep(DRAIN_POLL_MS)
|
|
2320
|
+
}
|
|
2321
|
+
server.stop(server.pendingRequests > 0) // force-close iff stragglers remain past the deadline
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
private installSignalHandlers(): void {
|
|
2325
|
+
// Drain, then let the process exit naturally — the stopped server no longer
|
|
2326
|
+
// holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
|
|
2327
|
+
// signals is consented; we don't force `process.exit`.
|
|
2328
|
+
const onSignal = (): void => {
|
|
2329
|
+
void this.stop()
|
|
2330
|
+
}
|
|
2331
|
+
process.once("SIGTERM", onSignal)
|
|
2332
|
+
process.once("SIGINT", onSignal)
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* Create a new {@link Server}. Pass an `Env` to type the platform bindings — `server<Env>()` makes
|
|
2338
|
+
* `c.env: Env` in every handler + middleware, and types the `env` argument of `app.fetch` /
|
|
2339
|
+
* `toFetchHandler`. Omit it and `c.env` is `unknown` (validate/cast before use).
|
|
2340
|
+
*/
|
|
2341
|
+
export function server<Env = unknown>(
|
|
2342
|
+
options?: ServerOptions,
|
|
2343
|
+
): Server<EmptyRegistry, { readonly env: Env }> {
|
|
2344
|
+
// `Env` is a phantom type-level marker: the runtime `env` arrives via `app.fetch(req, { env })` at
|
|
2345
|
+
// request time, not stored on the builder — so seed the context type with a cast (as `derive`/
|
|
2346
|
+
// `decorate` do for their `Ctx` extensions).
|
|
2347
|
+
return new Server(options) as unknown as Server<EmptyRegistry, { readonly env: Env }>
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** A Cloudflare Workers-style execution context (the `fetch` 3rd arg). Structural — only
|
|
2351
|
+
* `waitUntil` is used; declared here so `@nifrajs/core` needs no Workers type dependency. */
|
|
2352
|
+
export interface ExecutionContext {
|
|
2353
|
+
waitUntil(promise: Promise<unknown>): void
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/** A Cloudflare Workers-style scheduled (cron) controller. Structural — no Workers type dependency. */
|
|
2357
|
+
export interface ScheduledController {
|
|
2358
|
+
/** Epoch ms the run was scheduled for. */
|
|
2359
|
+
readonly scheduledTime: number
|
|
2360
|
+
/** The matching cron expression from `wrangler.toml` `[triggers]`. */
|
|
2361
|
+
readonly cron: string
|
|
2362
|
+
/** Tell the platform not to retry this run on failure. */
|
|
2363
|
+
noRetry(): void
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/** A nifra cron handler: the platform controller + the same typed `env`/`waitUntil` nifra threads into
|
|
2367
|
+
* request handlers. Schedule background work with `waitUntil` so it outlives the trigger. */
|
|
2368
|
+
export type ScheduledHandler<Env = unknown> = (
|
|
2369
|
+
controller: ScheduledController,
|
|
2370
|
+
context: { readonly env: Env; waitUntil(promise: Promise<unknown>): void },
|
|
2371
|
+
) => MaybePromise<void>
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* Adapt a nifra app to an edge "ExportedHandler" — use it as a Cloudflare Workers (or any
|
|
2375
|
+
* `fetch(request, env, ctx)` runtime) default export. It threads `env` + `ctx.waitUntil` into the
|
|
2376
|
+
* nifra Context, so handlers read `c.env` and schedule background work via `c.waitUntil`:
|
|
2377
|
+
*
|
|
2378
|
+
* export default toFetchHandler(app)
|
|
2379
|
+
*
|
|
2380
|
+
* Pass `{ scheduled }` to also export a Workers cron handler (for a `[triggers]` schedule) — it
|
|
2381
|
+
* receives the platform controller plus the same typed `env`/`waitUntil`:
|
|
2382
|
+
*
|
|
2383
|
+
* export default toFetchHandler(app, {
|
|
2384
|
+
* scheduled: (controller, { env, waitUntil }) =>
|
|
2385
|
+
* waitUntil(env.KV.put("last-run", String(controller.scheduledTime))),
|
|
2386
|
+
* })
|
|
2387
|
+
*
|
|
2388
|
+
* No Workers-only deps — `app.fetch` stays a portable Web-standard handler; this is the thin
|
|
2389
|
+
* shim from the platform's 3-arg `fetch`/`scheduled` to it.
|
|
2390
|
+
*/
|
|
2391
|
+
/** Cloudflare's `WebSocketPair` — feature-detected (absent off Workers). Yields `{ 0: client, 1: server }`. */
|
|
2392
|
+
type WebSocketPairCtor = new () => {
|
|
2393
|
+
readonly 0: unknown
|
|
2394
|
+
readonly 1: StandardWebSocket & { accept(): void }
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
/** Structural view of a Cloudflare Durable Object namespace binding — keeps `@cloudflare/workers-types`
|
|
2398
|
+
* out of `@nifrajs/core`. The real `DurableObjectNamespace` satisfies it. */
|
|
2399
|
+
export interface DurableObjectNamespaceLike {
|
|
2400
|
+
idFromName(name: string): unknown
|
|
2401
|
+
get(id: unknown): { fetch(request: Request): Promise<Response> }
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/** The single hub Durable Object id nifra routes WS upgrades to (one hub per app — see `@nifrajs/workers`). */
|
|
2405
|
+
const NIFRA_WS_HUB_ID = "nifra-ws-hub"
|
|
2406
|
+
|
|
2407
|
+
export function toFetchHandler<Env = unknown>(
|
|
2408
|
+
app: {
|
|
2409
|
+
fetch(request: Request, platform?: Platform<Env>): MaybePromise<Response>
|
|
2410
|
+
resolveWebSocketUpgrade?(
|
|
2411
|
+
request: Request,
|
|
2412
|
+
platform?: Platform<Env>,
|
|
2413
|
+
): MaybePromise<WebSocketUpgradeOutcome>
|
|
2414
|
+
},
|
|
2415
|
+
options?: {
|
|
2416
|
+
scheduled?: ScheduledHandler<Env>
|
|
2417
|
+
/**
|
|
2418
|
+
* Route WebSocket upgrades to a Durable Object that holds the connections and runs the app's
|
|
2419
|
+
* pub/sub — enabling cross-connection broadcast (`app.publish`) on Cloudflare Workers, where a
|
|
2420
|
+
* stateless isolate can't. Pass the DO namespace binding from `env`; pair with `@nifrajs/workers`'
|
|
2421
|
+
* `createWebSocketHub(app)` (the DO class) + a `wrangler.toml` binding. Without this, WS upgrades use
|
|
2422
|
+
* a per-connection `WebSocketPair` (no broadcast).
|
|
2423
|
+
*/
|
|
2424
|
+
webSocketHub?: (env: Env) => DurableObjectNamespaceLike
|
|
2425
|
+
},
|
|
2426
|
+
): {
|
|
2427
|
+
fetch(request: Request, env: Env, ctx: ExecutionContext): MaybePromise<Response>
|
|
2428
|
+
scheduled?(controller: ScheduledController, env: Env, ctx: ExecutionContext): MaybePromise<void>
|
|
2429
|
+
} {
|
|
2430
|
+
const scheduled = options?.scheduled
|
|
2431
|
+
const webSocketHub = options?.webSocketHub
|
|
2432
|
+
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
2433
|
+
return {
|
|
2434
|
+
fetch: (request, env, ctx) => {
|
|
2435
|
+
const platform: Platform<Env> = { env, waitUntil: (promise) => ctx.waitUntil(promise) }
|
|
2436
|
+
// WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
|
|
2437
|
+
// connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
|
|
2438
|
+
// resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
|
|
2439
|
+
if (
|
|
2440
|
+
webSocketHub !== undefined &&
|
|
2441
|
+
request.headers.get("upgrade")?.toLowerCase() === "websocket"
|
|
2442
|
+
) {
|
|
2443
|
+
const ns = webSocketHub(env)
|
|
2444
|
+
return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request)
|
|
2445
|
+
}
|
|
2446
|
+
// Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
|
|
2447
|
+
// Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` — e.g. Deno Deploy
|
|
2448
|
+
// uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
|
|
2449
|
+
const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor }).WebSocketPair
|
|
2450
|
+
if (resolveWs !== undefined && Pair !== undefined) {
|
|
2451
|
+
const accept = (outcome: WebSocketUpgradeOutcome): MaybePromise<Response> => {
|
|
2452
|
+
if (outcome.kind === "pass") return app.fetch(request, platform)
|
|
2453
|
+
if (outcome.kind === "reject") return outcome.response
|
|
2454
|
+
const pair = new Pair()
|
|
2455
|
+
const server = pair[1]
|
|
2456
|
+
server.accept()
|
|
2457
|
+
// Non-null: an `upgrade` outcome only exists for an app with ws() routes, and ws()
|
|
2458
|
+
// requires the `@nifrajs/core/ws` runtime at registration.
|
|
2459
|
+
;(getWsRuntime() as WsRuntime).attach(server, outcome.handler, outcome.data, {
|
|
2460
|
+
openNow: true,
|
|
2461
|
+
pubsub: outcome.pubsub,
|
|
2462
|
+
})
|
|
2463
|
+
// `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
|
|
2464
|
+
// 101 status is only valid on the Workers runtime — both gated by the `Pair` feature check.
|
|
2465
|
+
return new Response(null, { status: 101, webSocket: pair[0] } as unknown as ResponseInit)
|
|
2466
|
+
}
|
|
2467
|
+
const out = resolveWs(request, platform)
|
|
2468
|
+
return out instanceof Promise ? out.then(accept) : accept(out)
|
|
2469
|
+
}
|
|
2470
|
+
return app.fetch(request, platform)
|
|
2471
|
+
},
|
|
2472
|
+
...(scheduled !== undefined
|
|
2473
|
+
? {
|
|
2474
|
+
scheduled: (controller: ScheduledController, env: Env, ctx: ExecutionContext) =>
|
|
2475
|
+
scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
|
|
2476
|
+
}
|
|
2477
|
+
: {}),
|
|
2478
|
+
}
|
|
2479
|
+
}
|