castrum 0.9.7 → 0.9.9
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +664 -8
- package/dist/index.js +2014 -1327
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +8 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +6 -0
- package/package.json +4 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +55 -35
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +25 -5
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +2 -1
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +128 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +104 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +50 -3
- package/src/ingress/routes/common.ts +35 -11
- package/src/ingress/routes/echo.ts +60 -7
- package/src/ingress/routes/json-write.ts +32 -8
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +29 -9
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +135 -37
- package/src/ingress/server.ts +143 -36
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/integration/pipeline.ts +6 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi/types.ts +6 -1
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/native/types.ts +6 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/detect.ts +14 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +53 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +18 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +54 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/packed/parsers.ts +64 -7
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +81 -37
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { type Duplex, Readable } from 'node:stream'
|
|
23
23
|
import { buildPathMatcher } from './path-matcher'
|
|
24
24
|
import { fallbackHandler } from './routes'
|
|
25
|
+
import { createServerErrorHandler } from './server-error'
|
|
25
26
|
import {
|
|
26
27
|
type BakedServer,
|
|
27
28
|
buildRouteHandlers,
|
|
@@ -52,8 +53,20 @@ export interface NodeIngressServer extends BakedServer {
|
|
|
52
53
|
ready: Promise<number>
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Convert a Node IncomingMessage into a web-standard `Request`.
|
|
58
|
+
*
|
|
59
|
+
* Node's `IncomingMessage` has no web `AbortSignal`, so this bridges one to
|
|
60
|
+
* `Request.signal`: the request aborts when the client disconnects — on the
|
|
61
|
+
* explicit `'aborted'` event, or on `'close'` while the request has NOT
|
|
62
|
+
* completed (`close` after a fully-received request is normal socket
|
|
63
|
+
* teardown and must not abort). Exported so the adapter's signal wiring is
|
|
64
|
+
* unit-testable without a socket.
|
|
65
|
+
*
|
|
66
|
+
* @param req - The Node incoming request.
|
|
67
|
+
* @returns A web `Request` whose `signal` tracks client disconnect.
|
|
68
|
+
*/
|
|
69
|
+
export function nodeRequestToWebRequest(req: IncomingMessage): Request {
|
|
57
70
|
const host = req.headers.host ?? 'localhost'
|
|
58
71
|
const url = new URL(req.url ?? '/', `http://${host}`)
|
|
59
72
|
|
|
@@ -73,11 +86,20 @@ function nodeRequestToWebRequest(req: IncomingMessage): Request {
|
|
|
73
86
|
// request actually carries one (content-length > 0 or chunked), so DELETE/
|
|
74
87
|
// OPTIONS-with-a-body are no longer dropped.
|
|
75
88
|
const bodyForbidden = method === 'GET' || method === 'HEAD'
|
|
76
|
-
const hasBody =
|
|
77
|
-
Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
|
|
89
|
+
const hasBody = requestHasBody(req)
|
|
78
90
|
|
|
79
91
|
const init: RequestInit & { duplex?: 'half' } = { method, headers }
|
|
80
92
|
|
|
93
|
+
// Client-disconnect bridge (Node-only code — the Bun path uses Bun's native
|
|
94
|
+
// `Request.signal`). `abort()` is idempotent, so the two listeners may both
|
|
95
|
+
// fire on a premature close.
|
|
96
|
+
const abort = new AbortController()
|
|
97
|
+
req.on('aborted', () => abort.abort())
|
|
98
|
+
req.on('close', () => {
|
|
99
|
+
if (!req.complete) abort.abort()
|
|
100
|
+
})
|
|
101
|
+
init.signal = abort.signal
|
|
102
|
+
|
|
81
103
|
// Stream the request body through (the route handlers call
|
|
82
104
|
// `req.arrayBuffer()` which drains this stream).
|
|
83
105
|
if (hasBody && !bodyForbidden) {
|
|
@@ -143,7 +165,7 @@ async function writeResponse(res: ServerResponse, response: Response): Promise<v
|
|
|
143
165
|
*/
|
|
144
166
|
function drainUnreadBody(res: ServerResponse): void {
|
|
145
167
|
const req = res.req
|
|
146
|
-
if (req !== undefined && req !== null && !req.complete &&
|
|
168
|
+
if (req !== undefined && req !== null && !req.complete && requestHasBody(req)) {
|
|
147
169
|
req.resume()
|
|
148
170
|
// Only force-close when the request actually carried a body that was not
|
|
149
171
|
// consumed. A bodiless GET/HEAD is always safe to keep alive (bun's
|
|
@@ -156,12 +178,60 @@ function drainUnreadBody(res: ServerResponse): void {
|
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
/** Whether an incoming request carried a body (content-length or chunked). */
|
|
159
|
-
function
|
|
181
|
+
function requestHasBody(req: IncomingMessage): boolean {
|
|
160
182
|
return (
|
|
161
183
|
Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
|
|
162
184
|
)
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Cached body/status/headers for a bare static `Response` route value.
|
|
189
|
+
*
|
|
190
|
+
* A web `Response` body is ONE-SHOT: writing it once consumes the stream.
|
|
191
|
+
* `Bun.serve` snapshots a bare `Response` into its native table, but the
|
|
192
|
+
* node:http adapter must materialize a FRESH `Response` per request, so the
|
|
193
|
+
* body is read once and the bytes cached. The WeakMap key is the original
|
|
194
|
+
* `Response` instance, so the cache never retains anything the route table
|
|
195
|
+
* does not already hold. The cached value is the in-flight PROMISE (not the
|
|
196
|
+
* resolved bytes) so concurrent first requests share ONE body read instead of
|
|
197
|
+
* racing to consume the same one-shot stream.
|
|
198
|
+
*/
|
|
199
|
+
const STATIC_RESPONSE_CACHE = new WeakMap<
|
|
200
|
+
Response,
|
|
201
|
+
Promise<{ status: number; statusText: string; headers: Headers; body: Uint8Array | null }>
|
|
202
|
+
>()
|
|
203
|
+
|
|
204
|
+
/** Materialize a fresh, unconsumed `Response` from a static route value. */
|
|
205
|
+
function materializeStaticResponse(response: Response): Promise<Response> {
|
|
206
|
+
let cached = STATIC_RESPONSE_CACHE.get(response)
|
|
207
|
+
if (cached === undefined) {
|
|
208
|
+
cached = (async () => {
|
|
209
|
+
const body = response.body === null ? null : new Uint8Array(await response.arrayBuffer())
|
|
210
|
+
const headers = new Headers(response.headers)
|
|
211
|
+
// Framing headers describe the ORIGINAL stream; drop them so the runtime
|
|
212
|
+
// recomputes them from the re-materialized body (avoiding a stale
|
|
213
|
+
// `content-length` on every replay).
|
|
214
|
+
headers.delete('content-length')
|
|
215
|
+
headers.delete('transfer-encoding')
|
|
216
|
+
return {
|
|
217
|
+
status: response.status,
|
|
218
|
+
statusText: response.statusText,
|
|
219
|
+
headers,
|
|
220
|
+
body,
|
|
221
|
+
}
|
|
222
|
+
})()
|
|
223
|
+
STATIC_RESPONSE_CACHE.set(response, cached)
|
|
224
|
+
}
|
|
225
|
+
return cached.then(
|
|
226
|
+
(c) =>
|
|
227
|
+
new Response(c.body, {
|
|
228
|
+
status: c.status,
|
|
229
|
+
statusText: c.statusText,
|
|
230
|
+
headers: new Headers(c.headers),
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
165
235
|
/** Build a request listener that dispatches to the shared route map. */
|
|
166
236
|
function makeRequestListener(
|
|
167
237
|
options: CreateIngressServerOptions,
|
|
@@ -170,6 +240,11 @@ function makeRequestListener(
|
|
|
170
240
|
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
171
241
|
// Exact paths win; `:param`/`*` patterns are matched most-specific-first.
|
|
172
242
|
const matchPath = buildPathMatcher(routes)
|
|
243
|
+
// Shared masked-500 responder: same body + `onError` hook as the Bun path.
|
|
244
|
+
const onServerError = createServerErrorHandler({
|
|
245
|
+
onError: options.onError,
|
|
246
|
+
logger: options.logger,
|
|
247
|
+
})
|
|
173
248
|
|
|
174
249
|
return async (req, res) => {
|
|
175
250
|
try {
|
|
@@ -202,14 +277,27 @@ function makeRequestListener(
|
|
|
202
277
|
const method = webReq.method ?? 'GET'
|
|
203
278
|
|
|
204
279
|
const matched = matchPath(pathname)
|
|
205
|
-
const
|
|
280
|
+
const entry = matched?.methods?.[method]
|
|
206
281
|
|
|
207
282
|
let response: Response
|
|
208
|
-
if (
|
|
283
|
+
if (entry !== undefined && matched !== undefined) {
|
|
209
284
|
// The ingress pipeline does not echo path params, so matching only
|
|
210
285
|
// selects the handler; the extracted params are passed as a 3rd arg
|
|
211
286
|
// (ignored by current handlers) for future/raw-handler use.
|
|
212
|
-
|
|
287
|
+
if (typeof entry === 'function') {
|
|
288
|
+
response = await (entry as RouteHandler)(webReq, req, matched.params)
|
|
289
|
+
} else if (entry instanceof Response) {
|
|
290
|
+
// Bare static route value (Bun's native-table path). Node cannot
|
|
291
|
+
// replay a consumed body, so build a fresh Response per request.
|
|
292
|
+
// `instanceof Response` assumes the value was built with the global
|
|
293
|
+
// `Response` constructor (the one this adapter also uses).
|
|
294
|
+
response = await materializeStaticResponse(entry)
|
|
295
|
+
} else if (options.fallback !== undefined) {
|
|
296
|
+
const fallback = fallbackHandler(options.fallback, baseOpts)
|
|
297
|
+
response = await fallback(webReq, req)
|
|
298
|
+
} else {
|
|
299
|
+
response = new Response('Not Found', { status: 404 })
|
|
300
|
+
}
|
|
213
301
|
} else if (options.fallback !== undefined) {
|
|
214
302
|
const fallback = fallbackHandler(options.fallback, baseOpts)
|
|
215
303
|
response = await fallback(webReq, req)
|
|
@@ -219,28 +307,18 @@ function makeRequestListener(
|
|
|
219
307
|
|
|
220
308
|
await writeResponse(res, response)
|
|
221
309
|
} catch (err) {
|
|
222
|
-
// Never leave a client hanging on a handler failure.
|
|
310
|
+
// Never leave a client hanging on a handler failure. Reuse the shared
|
|
311
|
+
// masked-500 responder so both runtimes emit the same body and hook.
|
|
223
312
|
if (!res.headersSent) {
|
|
224
|
-
res
|
|
225
|
-
res.setHeader('content-type', 'application/json')
|
|
226
|
-
res.end(
|
|
227
|
-
JSON.stringify({
|
|
228
|
-
error: { code: 'internal_error', message: 'Internal Server Error' },
|
|
229
|
-
}),
|
|
230
|
-
)
|
|
313
|
+
await writeResponse(res, onServerError(err))
|
|
231
314
|
} else {
|
|
315
|
+
// Headers already flushed (e.g. a body-stream error mid-write): the
|
|
316
|
+
// masked 500 cannot be delivered, but the onError/logger hooks MUST
|
|
317
|
+
// still observe the failure before the socket is dropped. The returned
|
|
318
|
+
// Response is intentionally discarded.
|
|
319
|
+
onServerError(err)
|
|
232
320
|
res.destroy()
|
|
233
321
|
}
|
|
234
|
-
// NOTE: surfaced via the onError hook (observability phase).
|
|
235
|
-
if (options.onError) {
|
|
236
|
-
try {
|
|
237
|
-
options.onError({
|
|
238
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
239
|
-
})
|
|
240
|
-
} catch {
|
|
241
|
-
// hook must never crash the server
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
322
|
}
|
|
245
323
|
}
|
|
246
324
|
}
|
|
@@ -254,6 +332,35 @@ function makeRequestListener(
|
|
|
254
332
|
* (which owns the frame codec — e.g. the `ws` library). Previously a 101 was
|
|
255
333
|
* written as a normal response and the connection never became a socket.
|
|
256
334
|
*/
|
|
335
|
+
/** RFC 6455 subprotocol is an HTTP token (no separators / control bytes). */
|
|
336
|
+
const WS_PROTOCOL_TOKEN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Build the RFC 6455 `101 Switching Protocols` response head for a hijacked
|
|
340
|
+
* socket. `protocol` is emitted only when it is a valid HTTP token, so a caller
|
|
341
|
+
* that reflects an untrusted `Sec-WebSocket-Protocol` cannot inject CRLF or
|
|
342
|
+
* extra headers.
|
|
343
|
+
*
|
|
344
|
+
* @param accept - The `Sec-WebSocket-Accept` value (must contain no CR/LF).
|
|
345
|
+
* @param protocol - Optional negotiated subprotocol.
|
|
346
|
+
* @returns The raw response head, terminated by a blank line.
|
|
347
|
+
* @throws If `accept` contains a CR/LF.
|
|
348
|
+
*/
|
|
349
|
+
export function formatWebSocketHandshake(accept: string, protocol?: string | null): string {
|
|
350
|
+
if (/[\r\n]/.test(accept)) {
|
|
351
|
+
throw new Error('invalid Sec-WebSocket-Accept value')
|
|
352
|
+
}
|
|
353
|
+
let raw =
|
|
354
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
355
|
+
'Upgrade: websocket\r\n' +
|
|
356
|
+
'Connection: Upgrade\r\n' +
|
|
357
|
+
`Sec-WebSocket-Accept: ${accept}\r\n`
|
|
358
|
+
if (protocol && WS_PROTOCOL_TOKEN.test(protocol)) {
|
|
359
|
+
raw += `Sec-WebSocket-Protocol: ${protocol}\r\n`
|
|
360
|
+
}
|
|
361
|
+
return `${raw}\r\n`
|
|
362
|
+
}
|
|
363
|
+
|
|
257
364
|
function makeUpgradeListener(
|
|
258
365
|
options: CreateIngressServerOptions,
|
|
259
366
|
): (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void> {
|
|
@@ -268,16 +375,7 @@ function makeUpgradeListener(
|
|
|
268
375
|
}
|
|
269
376
|
|
|
270
377
|
// Complete the RFC 6455 handshake on the hijacked socket.
|
|
271
|
-
|
|
272
|
-
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
273
|
-
'Upgrade: websocket\r\n' +
|
|
274
|
-
'Connection: Upgrade\r\n' +
|
|
275
|
-
`Sec-WebSocket-Accept: ${handshake.accept}\r\n`
|
|
276
|
-
if (handshake.protocol) {
|
|
277
|
-
raw += `Sec-WebSocket-Protocol: ${handshake.protocol}\r\n`
|
|
278
|
-
}
|
|
279
|
-
raw += '\r\n'
|
|
280
|
-
socket.write(raw)
|
|
378
|
+
socket.write(formatWebSocketHandshake(handshake.accept, handshake.protocol))
|
|
281
379
|
|
|
282
380
|
// Hand the upgraded socket to the caller (frame codec / message loop).
|
|
283
381
|
options.onUpgrade?.(socket, req, head)
|
package/src/ingress/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import type { BakedHandlerOptions } from './routes/common'
|
|
16
16
|
import { nativeRouteHandler } from './routes/native'
|
|
17
17
|
import { nativeResponderRoute } from './routes/responder'
|
|
18
|
+
import { createServerErrorHandler, guardRouteHandler } from './server-error'
|
|
18
19
|
import type { NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
19
20
|
|
|
20
21
|
/** Server-level default for the socket request-body cap (16 MiB). */
|
|
@@ -33,8 +34,46 @@ export type RouteHandler = (
|
|
|
33
34
|
params?: Record<string, string>,
|
|
34
35
|
) => Response | Promise<Response>
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A prebuilt constant response for static route promotion.
|
|
39
|
+
*
|
|
40
|
+
* Either a bare `Response` or a factory that builds a FRESH `Response` per
|
|
41
|
+
* request. Behavior differs by runtime:
|
|
42
|
+
*
|
|
43
|
+
* - **Bun**: a bare `Response` is snapshotted into `Bun.serve`'s native route
|
|
44
|
+
* table at bind time (verified on Bun 1.4.2 — the body is NOT consumed by the
|
|
45
|
+
* first request) and served on every request without entering JS.
|
|
46
|
+
* - **Node** (`createIngressServerNode`): the adapter reads the bare `Response`
|
|
47
|
+
* body ONCE and materializes a fresh `Response` per request (a web body is
|
|
48
|
+
* one-shot).
|
|
49
|
+
*
|
|
50
|
+
* A factory is invoked by the runtime on every request, so it MUST return a new
|
|
51
|
+
* `Response` each call. Returning the SAME already-sent instance fails on the
|
|
52
|
+
* second request with `ERR_BODY_ALREADY_USED` (Bun) / a consumed stream
|
|
53
|
+
* (Node) — use a factory only when the response genuinely varies per request.
|
|
54
|
+
*/
|
|
55
|
+
export type StaticRoute = Response | (() => Response)
|
|
56
|
+
|
|
36
57
|
/** Route spec for the Bun.serve builder. */
|
|
37
58
|
export interface BakedRoute {
|
|
59
|
+
/**
|
|
60
|
+
* A prebuilt constant response served for GET, bypassing the ingress
|
|
61
|
+
* pipeline entirely. Takes precedence over `read`/`cookies` for GET (the
|
|
62
|
+
* pipeline handler is never wired); other methods (`write`/`echo`/`delete`)
|
|
63
|
+
* are unaffected. See {@link StaticRoute} for the one-shot-body contract.
|
|
64
|
+
*
|
|
65
|
+
* HEAD is intentionally NOT wired for `static` (a bare `Response` carries a
|
|
66
|
+
* body; static responses are probes/constants served over GET). This matches
|
|
67
|
+
* the raw-`read` probe behavior — but note that a route which previously
|
|
68
|
+
* served HEAD via a native `read` now 405s (Node) on HEAD once `static` is
|
|
69
|
+
* set. When `static` is combined with `write`/`echo`/`delete`, those dynamic
|
|
70
|
+
* methods remain wired.
|
|
71
|
+
*
|
|
72
|
+
* Runtime note: `createIngressRouter` treats `static` differently — there,
|
|
73
|
+
* like `raw`, it OWNS THE WHOLE SPEC (the router has no compiled ingress for
|
|
74
|
+
* the other methods; see `RouterRouteSpec.static` in src/ingress/router.ts).
|
|
75
|
+
*/
|
|
76
|
+
static?: StaticRoute
|
|
38
77
|
/**
|
|
39
78
|
* Wires GET + HEAD read handlers. Accepts an optimized ingress handler OR a
|
|
40
79
|
* raw `RouteHandler` function (health/metrics probes are raw — see
|
|
@@ -113,11 +152,17 @@ export interface CreateIngressServerOptions {
|
|
|
113
152
|
routes: Record<string, BakedRoute>
|
|
114
153
|
fallback?: OptimizedIngressHandler
|
|
115
154
|
/**
|
|
116
|
-
* Invoked when an unhandled error escapes a request handler (
|
|
117
|
-
*
|
|
118
|
-
*
|
|
155
|
+
* Invoked when an unhandled error escapes a request handler (raw `read`
|
|
156
|
+
* functions, `responder`/`native` responders, or the Bun/Node adapter
|
|
157
|
+
* catch-all). The error is masked to a generic 500 on the wire. Never
|
|
158
|
+
* throws — hook failures are swallowed.
|
|
119
159
|
*/
|
|
120
160
|
onError?: (info: { error: Error; request?: Request }) => void
|
|
161
|
+
/**
|
|
162
|
+
* Optional structured-log sink for escaped handler errors (one JSON line per
|
|
163
|
+
* masked 500, message only). Never throws.
|
|
164
|
+
*/
|
|
165
|
+
logger?: (line: string) => void
|
|
121
166
|
/** Node adapter only: ms to receive the complete request (headers + body).
|
|
122
167
|
* Guards slowloris/trickling requests. Default: 30_000. */
|
|
123
168
|
requestTimeoutMs?: number
|
|
@@ -225,7 +270,7 @@ export function gracefulShutdown(
|
|
|
225
270
|
* options — it does not need `port` or the Node-only server fields). */
|
|
226
271
|
export type BuildRouteHandlersOptions = Pick<
|
|
227
272
|
CreateIngressServerOptions,
|
|
228
|
-
'routes' | 'fallback' | 'getIp' | 'copyBody'
|
|
273
|
+
'routes' | 'fallback' | 'getIp' | 'copyBody' | 'onError' | 'logger'
|
|
229
274
|
>
|
|
230
275
|
|
|
231
276
|
/**
|
|
@@ -243,6 +288,16 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
243
288
|
copyBody: options.copyBody,
|
|
244
289
|
}
|
|
245
290
|
|
|
291
|
+
// Masked-500 responder for the escape-capable handlers below. Built once per
|
|
292
|
+
// server: the raw `read` function, the `responder` route, and the `native`
|
|
293
|
+
// route run OUTSIDE the native pipeline (and outside the built-in factories'
|
|
294
|
+
// own error handling). Built-in factories stay unwrapped so the hot path is
|
|
295
|
+
// unchanged.
|
|
296
|
+
const onServerError = createServerErrorHandler({
|
|
297
|
+
onError: options.onError,
|
|
298
|
+
logger: options.logger,
|
|
299
|
+
})
|
|
300
|
+
|
|
246
301
|
const serverRoutes: Record<string, Record<string, unknown>> = {}
|
|
247
302
|
|
|
248
303
|
for (const [path, spec] of Object.entries(options.routes)) {
|
|
@@ -252,13 +307,27 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
252
307
|
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
253
308
|
}
|
|
254
309
|
const methods: Record<string, unknown> = {}
|
|
255
|
-
|
|
256
|
-
|
|
310
|
+
const hasStatic = spec.static !== undefined
|
|
311
|
+
|
|
312
|
+
if (hasStatic) {
|
|
313
|
+
// Static promotion: `static` owns GET and is placed in the route table
|
|
314
|
+
// verbatim (a bare Response relies on Bun's native table; see
|
|
315
|
+
// BakedRoute.static). The read/cookies GET wiring below is skipped so
|
|
316
|
+
// the ingress pipeline is NEVER entered for a static route. A
|
|
317
|
+
// function-valued factory is user code with no built-in fault
|
|
318
|
+
// containment, so guard it like a raw `read` function (a bare Response
|
|
319
|
+
// cannot throw and is placed verbatim for zero-copy Bun serving).
|
|
320
|
+
methods.GET =
|
|
321
|
+
typeof spec.static === 'function'
|
|
322
|
+
? guardRouteHandler(spec.static, onServerError)
|
|
323
|
+
: spec.static
|
|
324
|
+
} else if (spec.read) {
|
|
257
325
|
if (typeof spec.read === 'function') {
|
|
258
326
|
// Raw request→Response handler (probes, /metrics): serve GET directly,
|
|
259
327
|
// outside the ingress pipeline. HEAD is intentionally not wired (a raw
|
|
260
|
-
// handler returns a body; probes use GET).
|
|
261
|
-
|
|
328
|
+
// handler returns a body; probes use GET). Guarded: a raw handler is
|
|
329
|
+
// user code with no built-in fault containment.
|
|
330
|
+
methods.GET = guardRouteHandler(spec.read, onServerError)
|
|
262
331
|
} else {
|
|
263
332
|
methods.GET = readHandler(spec.read, routeOpts)
|
|
264
333
|
methods.HEAD = headHandler(spec.read, routeOpts)
|
|
@@ -279,7 +348,7 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
279
348
|
methods.POST = echoHandler(spec.echo, routeOpts)
|
|
280
349
|
}
|
|
281
350
|
|
|
282
|
-
if (spec.cookies) {
|
|
351
|
+
if (spec.cookies && !hasStatic) {
|
|
283
352
|
methods.GET = readHandler(spec.cookies, routeOpts)
|
|
284
353
|
}
|
|
285
354
|
|
|
@@ -288,44 +357,65 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
288
357
|
}
|
|
289
358
|
|
|
290
359
|
if (spec.responder) {
|
|
291
|
-
// JS responder route: native decides + rejects; JS builds the 2xx.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
360
|
+
// JS responder route: native decides + rejects; JS builds the 2xx. The
|
|
361
|
+
// user responder is guarded — a throw/rejection becomes a masked 500.
|
|
362
|
+
// A static GET takes precedence: filter GET out of the wired methods.
|
|
363
|
+
const responderMethods = (spec.responder.methods ?? ['GET']).filter(
|
|
364
|
+
(m) => !(hasStatic && m === 'GET'),
|
|
365
|
+
)
|
|
366
|
+
if (responderMethods.length > 0) {
|
|
367
|
+
const responderRoute = nativeResponderRoute(
|
|
368
|
+
spec.responder.ingress,
|
|
369
|
+
spec.responder.handler,
|
|
370
|
+
{
|
|
371
|
+
...routeOpts,
|
|
372
|
+
terminalStyle: spec.responder.terminalStyle,
|
|
373
|
+
readBody: spec.responder.readBody,
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
const guardedResponderRoute = guardRouteHandler(responderRoute, onServerError)
|
|
377
|
+
for (const m of responderMethods) {
|
|
378
|
+
methods[m] = guardedResponderRoute
|
|
379
|
+
}
|
|
300
380
|
}
|
|
301
381
|
}
|
|
302
382
|
|
|
303
383
|
if (spec.native) {
|
|
304
384
|
// LEAN native-stack responder route: route-wire v3 stack, no envelope.
|
|
305
385
|
// The compiled route is injected into the pure route factory (DI across
|
|
306
|
-
// the purity boundary — the compile touches the dlopen layer here).
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
...routeOpts,
|
|
312
|
-
readBody: spec.native.readBody,
|
|
313
|
-
},
|
|
386
|
+
// the purity boundary — the compile touches the dlopen layer here). The
|
|
387
|
+
// user responder is guarded — a throw/rejection becomes a masked 500.
|
|
388
|
+
// A static GET takes precedence: filter GET out of the wired methods.
|
|
389
|
+
const nativeMethods = (spec.native.methods ?? ['GET']).filter(
|
|
390
|
+
(m) => !(hasStatic && m === 'GET'),
|
|
314
391
|
)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
392
|
+
if (nativeMethods.length > 0) {
|
|
393
|
+
const nativeRoute = nativeRouteHandler(
|
|
394
|
+
createNativeRoute(spec.native.plan),
|
|
395
|
+
spec.native.handler,
|
|
396
|
+
{
|
|
397
|
+
...routeOpts,
|
|
398
|
+
readBody: spec.native.readBody,
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
const guardedNativeRoute = guardRouteHandler(nativeRoute, onServerError)
|
|
402
|
+
for (const m of nativeMethods) {
|
|
403
|
+
methods[m] = guardedNativeRoute
|
|
404
|
+
}
|
|
318
405
|
}
|
|
319
406
|
}
|
|
320
407
|
|
|
321
408
|
// CORS preflight (OPTIONS) is served for EVERY route with a NATIVE handler
|
|
322
409
|
// (not just write routes with a fallback), so read-only routes also answer
|
|
323
410
|
// preflights with 204/403 from the native pipeline. Raw probe handlers
|
|
324
|
-
// (plain functions)
|
|
411
|
+
// (plain functions) and the GET that `static` replaced are excluded from
|
|
412
|
+
// `primary` — browsers don't preflight probes, and a static route has no
|
|
413
|
+
// pipeline to answer with. A static route that ALSO wires `write`/`echo`/
|
|
414
|
+
// `delete` still gets OPTIONS from that other native handler.
|
|
325
415
|
const primary =
|
|
326
416
|
spec.delete ??
|
|
327
|
-
(spec.read && typeof spec.read !== 'function' ? spec.read : undefined) ??
|
|
328
|
-
spec.cookies ??
|
|
417
|
+
(!hasStatic && spec.read && typeof spec.read !== 'function' ? spec.read : undefined) ??
|
|
418
|
+
(!hasStatic ? spec.cookies : undefined) ??
|
|
329
419
|
spec.write ??
|
|
330
420
|
spec.echo
|
|
331
421
|
if (primary) {
|
|
@@ -339,11 +429,10 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
339
429
|
}
|
|
340
430
|
|
|
341
431
|
// ── Path matching (shared with server-node.ts + router.ts) ─────────
|
|
342
|
-
// `
|
|
343
|
-
// re-exported
|
|
344
|
-
// module).
|
|
432
|
+
// `PathMatch` / `safeDecode` live in path-matcher.ts and are not part of this
|
|
433
|
+
// module's surface; only `buildPathMatcher` is re-exported for back-compat.
|
|
345
434
|
|
|
346
|
-
export { buildPathMatcher
|
|
435
|
+
export { buildPathMatcher } from './path-matcher'
|
|
347
436
|
|
|
348
437
|
/** Typed options forwarded to `Bun.serve`. */
|
|
349
438
|
interface BunServerOptions {
|
|
@@ -354,6 +443,14 @@ interface BunServerOptions {
|
|
|
354
443
|
maxRequestBodySize: number
|
|
355
444
|
reusePort?: boolean
|
|
356
445
|
fetch?: (req: Request) => Response | Promise<Response>
|
|
446
|
+
/**
|
|
447
|
+
* Bun's server-level error trap. Invoked when an error escapes a route
|
|
448
|
+
* handler that was NOT already contained by `guardRouteHandler` (e.g. a
|
|
449
|
+
* built-in factory calling user/config code such as `getIp`). Delegates to
|
|
450
|
+
* the shared masked-500 responder so Bun's default handler never surfaces
|
|
451
|
+
* stack/internal detail. See `server-error.ts`.
|
|
452
|
+
*/
|
|
453
|
+
error?: (error: Error, req: Request) => Response | Promise<Response>
|
|
357
454
|
}
|
|
358
455
|
|
|
359
456
|
/** Build a Bun.serve config from pre-baked route handlers. */
|
|
@@ -369,6 +466,15 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
|
|
|
369
466
|
}
|
|
370
467
|
const { routes: serverRoutes, baseOpts } = buildRouteHandlers(options)
|
|
371
468
|
|
|
469
|
+
// Server-level backstop (Bun's `error` option). `buildRouteHandlers` guards
|
|
470
|
+
// the escape-capable route handlers; this covers everything else — built-in
|
|
471
|
+
// factories that call user/config code without a try/catch (notably
|
|
472
|
+
// `resolveIp` → `opts.getIp`) — with the SAME masked 500 + `onError` hook.
|
|
473
|
+
const onServerError = createServerErrorHandler({
|
|
474
|
+
onError: options.onError,
|
|
475
|
+
logger: options.logger,
|
|
476
|
+
})
|
|
477
|
+
|
|
372
478
|
const serverOptions: BunServerOptions = {
|
|
373
479
|
hostname: options.hostname ?? '0.0.0.0',
|
|
374
480
|
port: options.port,
|
|
@@ -378,6 +484,7 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
|
|
|
378
484
|
// to 16 MiB so an oversized request is rejected at the socket without ever
|
|
379
485
|
// being buffered (route handlers enforce the tighter `maxBodyBytes`).
|
|
380
486
|
maxRequestBodySize: options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE,
|
|
487
|
+
error: (error, req) => onServerError(error, req),
|
|
381
488
|
}
|
|
382
489
|
if (options.reusePort) {
|
|
383
490
|
serverOptions.reusePort = true
|
package/src/ingress/shared.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const DEFAULT_BAKED_OUTPUT_BUFFER_SIZE = 131_072
|
|
|
36
36
|
* (`handle_request_full_sync` allocates `vec![0u8; output_size]`), OOM-ing the
|
|
37
37
|
* process. Callers that genuinely need more can raise it here.
|
|
38
38
|
*/
|
|
39
|
-
|
|
39
|
+
const MAX_OUTPUT_BUFFER_SIZE = 64 * 1024 * 1024
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Clamp an ingress output-buffer size into `[min, MAX_OUTPUT_BUFFER_SIZE]` so
|
package/src/ingress/types.ts
CHANGED
|
@@ -120,6 +120,21 @@ export interface OptimizedIngressHandler {
|
|
|
120
120
|
retryAfterSecs?: number,
|
|
121
121
|
): [string, string][]
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Success-path headers as a ready `Headers` instance: the memoized base
|
|
125
|
+
* (template + reflected origin) plus per-request rate/request-id extras,
|
|
126
|
+
* without rebuilding `Headers` from an array on every response. Optional so
|
|
127
|
+
* hand-rolled mocks need not provide it; callers fall back to
|
|
128
|
+
* `memoizedHeaders(responseHeaders(...))`.
|
|
129
|
+
*/
|
|
130
|
+
successHeaders?(
|
|
131
|
+
variant: number,
|
|
132
|
+
requestIdHeader: string | null,
|
|
133
|
+
origin: string | null,
|
|
134
|
+
rateRemaining?: number,
|
|
135
|
+
rateResetSecs?: number,
|
|
136
|
+
): Headers
|
|
137
|
+
|
|
123
138
|
terminalHeaders(
|
|
124
139
|
variant: number,
|
|
125
140
|
ctx: BakedContext,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// context to the app's own handler. `handleRequest` is a fetch-compatible
|
|
8
8
|
// convenience that serves the terminal response or a rendered OK response.
|
|
9
9
|
|
|
10
|
+
import { abortResponse, isAbortError } from '../ingress/abort'
|
|
10
11
|
import { readBodyWithLimit } from '../ingress/body'
|
|
11
12
|
import type { BakedIngressResult } from '../ingress/decode/baked-result'
|
|
12
13
|
import {
|
|
@@ -172,6 +173,11 @@ export function createPipeline(opts: CreatePipelineOptions = {}): IngressPipelin
|
|
|
172
173
|
try {
|
|
173
174
|
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
174
175
|
} catch (err) {
|
|
176
|
+
// Client disconnect is the cancelled response, never the
|
|
177
|
+
// `bodyErrorResponse` 400/408/413 fallback.
|
|
178
|
+
if (isAbortError(err)) {
|
|
179
|
+
return { terminal: true, response: abortResponse(), result: null, ctx }
|
|
180
|
+
}
|
|
175
181
|
return {
|
|
176
182
|
terminal: true,
|
|
177
183
|
response: bodyErrorResponse(ingress, req, requestId, err),
|
package/src/loader/ops.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { decoder } from '../shared/bytes'
|
|
|
26
26
|
import type { SchemaValidator } from '../shared/packed'
|
|
27
27
|
|
|
28
28
|
/** Empty bytes sentinel for skip-on-error scalar parity (e.g. AEAD decrypt). */
|
|
29
|
-
|
|
29
|
+
const EMPTY_BYTES = new Uint8Array(0)
|
|
30
30
|
|
|
31
31
|
/** Result kinds supported by the loader (mirrors the packed batch wire shapes). */
|
|
32
32
|
export type LoaderResultKind = 'boolean' | 'number' | 'bigint' | 'bytes'
|