castrum 0.9.8 → 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 +164 -4
- package/dist/index.js +434 -70
- package/docs/ENVIRONMENT.md +7 -0
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +5 -0
- package/package.json +2 -1
- package/rust/ffi/task.rs +19 -18
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +4 -2
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/handlers.ts +1 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/router.ts +36 -1
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/echo.ts +10 -0
- package/src/ingress/routes/json-write.ts +11 -0
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +25 -5
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +102 -23
- package/src/ingress/server.ts +140 -32
- package/src/integration/pipeline.ts +6 -0
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/types.ts +6 -1
- package/src/native/types.ts +6 -0
- package/src/runtime/detect.ts +14 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/scalar/factories.ts +6 -0
- package/src/rust-ffi/scalar/interface.ts +14 -0
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +10 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +52 -9
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/ingress/routes/safe-record.ts — prototype-safe key assignment for
|
|
2
|
+
// request-derived objects.
|
|
3
|
+
//
|
|
4
|
+
// PURE: no addon and no pooled-buffer state. Request keys are attacker
|
|
5
|
+
// controlled, so a key of `__proto__` must not invoke the inherited setter on a
|
|
6
|
+
// plain object. `assignOwn` writes `__proto__` with `Object.defineProperty` (an
|
|
7
|
+
// own enumerable data property) and every other key with plain assignment,
|
|
8
|
+
// which is already safe: `constructor` / `prototype` just become own shadowing
|
|
9
|
+
// properties.
|
|
10
|
+
//
|
|
11
|
+
// Scope: this protects the RECORD itself — it keeps `Object.prototype` (so
|
|
12
|
+
// `hasOwnProperty` / `instanceof Object` still work), gains an own `__proto__`
|
|
13
|
+
// data key, and is never mutated through the setter. It does NOT protect a
|
|
14
|
+
// consumer that blindly merges the record into another object, e.g.
|
|
15
|
+
// `Object.assign({}, rec)`: the own `__proto__` key is still enumerable, so a
|
|
16
|
+
// merge target's inherited setter can fire there. Such consumers must guard
|
|
17
|
+
// their own merge.
|
|
18
|
+
|
|
19
|
+
/** A request-derived key that would invoke the inherited `__proto__` setter. */
|
|
20
|
+
const PROTO_KEY = '__proto__'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create an empty plain record (`Object.prototype` is preserved, so
|
|
24
|
+
* `hasOwnProperty` / `instanceof Object` keep working). Safety comes from
|
|
25
|
+
* {@link assignOwn}, not from the container.
|
|
26
|
+
*
|
|
27
|
+
* @returns A fresh, empty `Record<string, unknown>`.
|
|
28
|
+
*/
|
|
29
|
+
export function safeRecord(): Record<string, unknown> {
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Assign a request-derived key as an OWN enumerable data property.
|
|
35
|
+
*
|
|
36
|
+
* Only `__proto__` is written with `Object.defineProperty` — a plain
|
|
37
|
+
* `target[key] = value` would invoke the inherited `__proto__` setter and
|
|
38
|
+
* mutate the prototype. Every other key (including `constructor` /
|
|
39
|
+
* `prototype`) uses plain assignment: faster, and it merely shadows on the
|
|
40
|
+
* object.
|
|
41
|
+
*
|
|
42
|
+
* @param target - Record to write into (normally from {@link safeRecord}).
|
|
43
|
+
* @param key - Request-derived key (may be `__proto__` / `constructor`).
|
|
44
|
+
* @param value - Request-derived value.
|
|
45
|
+
*/
|
|
46
|
+
export function assignOwn(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
47
|
+
if (key === PROTO_KEY) {
|
|
48
|
+
Object.defineProperty(target, key, {
|
|
49
|
+
value,
|
|
50
|
+
enumerable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
configurable: true,
|
|
53
|
+
})
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
target[key] = value
|
|
57
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/ingress/server-error.ts — request-handler fault containment.
|
|
2
|
+
//
|
|
3
|
+
// User-supplied route handlers (a raw `spec.read`, a `responder`, a `native`
|
|
4
|
+
// responder) run OUTSIDE the native pipeline. On Bun a sync throw / async
|
|
5
|
+
// rejection would otherwise escape to Bun's default handler (which is not
|
|
6
|
+
// castrum-controlled and may surface stack/internal detail); on Node it hit a
|
|
7
|
+
// local generic-500 construction. This module provides ONE masked-500 responder
|
|
8
|
+
// shared by both runtimes plus a guard that contains sync throws and async
|
|
9
|
+
// rejections. The response body is the pre-encoded internal error body — the
|
|
10
|
+
// thrown message/stack is NEVER placed on the wire (info-leak rule).
|
|
11
|
+
|
|
12
|
+
import { memoizedHeaders } from './headers/memoized-headers'
|
|
13
|
+
import { ERROR_BODIES } from './response/error-bodies'
|
|
14
|
+
import type { RouteHandler } from './server'
|
|
15
|
+
|
|
16
|
+
/** Options for {@link createServerErrorHandler}. */
|
|
17
|
+
export interface ServerErrorOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Observability hook for an escaped handler error. Receives the real `Error`
|
|
20
|
+
* (unmasked — server-side only). Never throws: a failing hook is swallowed so
|
|
21
|
+
* it cannot break the masked response.
|
|
22
|
+
*/
|
|
23
|
+
onError?: (info: { error: Error; request?: Request }) => void
|
|
24
|
+
/**
|
|
25
|
+
* Structured-log sink: one JSON line per escaped error (message only, no
|
|
26
|
+
* stack, no response leak). Never throws.
|
|
27
|
+
*/
|
|
28
|
+
logger?: (line: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Coerce an unknown thrown value to an `Error` for the hook/logger. */
|
|
32
|
+
function toError(value: unknown): Error {
|
|
33
|
+
return value instanceof Error ? value : new Error(String(value))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const INTERNAL_HEADERS: readonly [string, string][] = [
|
|
37
|
+
['content-type', 'application/json; charset=utf-8'],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a masked-500 responder. The returned function never throws, always
|
|
42
|
+
* answers with the pre-encoded internal error body, and runs the optional
|
|
43
|
+
* `onError`/`logger` hooks in a contained way.
|
|
44
|
+
*
|
|
45
|
+
* @param opts - Optional observability hooks.
|
|
46
|
+
* @returns A `(error, req?) => Response` responder for escaped handler errors.
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const onServerError = createServerErrorHandler({ onError: console.error })
|
|
50
|
+
* onServerError(new Error('boom')) // Response(500)
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createServerErrorHandler(
|
|
54
|
+
opts: ServerErrorOptions,
|
|
55
|
+
): (error: unknown, req?: Request) => Response {
|
|
56
|
+
const headers = memoizedHeaders(INTERNAL_HEADERS)
|
|
57
|
+
return (error, req) => {
|
|
58
|
+
const err = toError(error)
|
|
59
|
+
if (opts.onError) {
|
|
60
|
+
try {
|
|
61
|
+
opts.onError({ error: err, request: req })
|
|
62
|
+
} catch {
|
|
63
|
+
// Observability must never break the response.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (opts.logger) {
|
|
67
|
+
try {
|
|
68
|
+
opts.logger(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
level: 'error',
|
|
71
|
+
msg: 'unhandled_handler_error',
|
|
72
|
+
error: err.message,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
} catch {
|
|
76
|
+
// Logging must never break the response.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return new Response(ERROR_BODIES.internal, { status: 500, headers })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wrap a route handler so a sync throw or an async rejection is contained and
|
|
85
|
+
* turned into the shared masked 500. The success path is passed through
|
|
86
|
+
* unchanged.
|
|
87
|
+
*
|
|
88
|
+
* @param handler - The route handler to guard.
|
|
89
|
+
* @param onServerError - The masked-500 responder (see
|
|
90
|
+
* {@link createServerErrorHandler}).
|
|
91
|
+
* @returns A `RouteHandler` with the same signature and success semantics.
|
|
92
|
+
*/
|
|
93
|
+
export function guardRouteHandler(
|
|
94
|
+
handler: RouteHandler,
|
|
95
|
+
onServerError: (error: unknown, req?: Request) => Response,
|
|
96
|
+
): RouteHandler {
|
|
97
|
+
return (req, srv, params) => {
|
|
98
|
+
try {
|
|
99
|
+
const out = handler(req, srv, params)
|
|
100
|
+
// Duck-type thenables (cross-realm Promises/custom thenables fail
|
|
101
|
+
// `instanceof Promise`). A `Response` never exposes `then`.
|
|
102
|
+
const thenable = out as Promise<Response> | null | undefined
|
|
103
|
+
if (thenable !== null && thenable !== undefined && typeof thenable.then === 'function') {
|
|
104
|
+
return thenable.catch((e) => onServerError(e, req))
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return onServerError(e, req)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -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
|
|
|
@@ -77,6 +90,16 @@ function nodeRequestToWebRequest(req: IncomingMessage): Request {
|
|
|
77
90
|
|
|
78
91
|
const init: RequestInit & { duplex?: 'half' } = { method, headers }
|
|
79
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
|
+
|
|
80
103
|
// Stream the request body through (the route handlers call
|
|
81
104
|
// `req.arrayBuffer()` which drains this stream).
|
|
82
105
|
if (hasBody && !bodyForbidden) {
|
|
@@ -161,6 +184,54 @@ function requestHasBody(req: IncomingMessage): boolean {
|
|
|
161
184
|
)
|
|
162
185
|
}
|
|
163
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
|
+
|
|
164
235
|
/** Build a request listener that dispatches to the shared route map. */
|
|
165
236
|
function makeRequestListener(
|
|
166
237
|
options: CreateIngressServerOptions,
|
|
@@ -169,6 +240,11 @@ function makeRequestListener(
|
|
|
169
240
|
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
170
241
|
// Exact paths win; `:param`/`*` patterns are matched most-specific-first.
|
|
171
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
|
+
})
|
|
172
248
|
|
|
173
249
|
return async (req, res) => {
|
|
174
250
|
try {
|
|
@@ -201,14 +277,27 @@ function makeRequestListener(
|
|
|
201
277
|
const method = webReq.method ?? 'GET'
|
|
202
278
|
|
|
203
279
|
const matched = matchPath(pathname)
|
|
204
|
-
const
|
|
280
|
+
const entry = matched?.methods?.[method]
|
|
205
281
|
|
|
206
282
|
let response: Response
|
|
207
|
-
if (
|
|
283
|
+
if (entry !== undefined && matched !== undefined) {
|
|
208
284
|
// The ingress pipeline does not echo path params, so matching only
|
|
209
285
|
// selects the handler; the extracted params are passed as a 3rd arg
|
|
210
286
|
// (ignored by current handlers) for future/raw-handler use.
|
|
211
|
-
|
|
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
|
+
}
|
|
212
301
|
} else if (options.fallback !== undefined) {
|
|
213
302
|
const fallback = fallbackHandler(options.fallback, baseOpts)
|
|
214
303
|
response = await fallback(webReq, req)
|
|
@@ -218,28 +307,18 @@ function makeRequestListener(
|
|
|
218
307
|
|
|
219
308
|
await writeResponse(res, response)
|
|
220
309
|
} catch (err) {
|
|
221
|
-
// 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.
|
|
222
312
|
if (!res.headersSent) {
|
|
223
|
-
res
|
|
224
|
-
res.setHeader('content-type', 'application/json')
|
|
225
|
-
res.end(
|
|
226
|
-
JSON.stringify({
|
|
227
|
-
error: { code: 'internal_error', message: 'Internal Server Error' },
|
|
228
|
-
}),
|
|
229
|
-
)
|
|
313
|
+
await writeResponse(res, onServerError(err))
|
|
230
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)
|
|
231
320
|
res.destroy()
|
|
232
321
|
}
|
|
233
|
-
// NOTE: surfaced via the onError hook (observability phase).
|
|
234
|
-
if (options.onError) {
|
|
235
|
-
try {
|
|
236
|
-
options.onError({
|
|
237
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
238
|
-
})
|
|
239
|
-
} catch {
|
|
240
|
-
// hook must never crash the server
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
322
|
}
|
|
244
323
|
}
|
|
245
324
|
}
|
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) {
|
|
@@ -353,6 +443,14 @@ interface BunServerOptions {
|
|
|
353
443
|
maxRequestBodySize: number
|
|
354
444
|
reusePort?: boolean
|
|
355
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>
|
|
356
454
|
}
|
|
357
455
|
|
|
358
456
|
/** Build a Bun.serve config from pre-baked route handlers. */
|
|
@@ -368,6 +466,15 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
|
|
|
368
466
|
}
|
|
369
467
|
const { routes: serverRoutes, baseOpts } = buildRouteHandlers(options)
|
|
370
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
|
+
|
|
371
478
|
const serverOptions: BunServerOptions = {
|
|
372
479
|
hostname: options.hostname ?? '0.0.0.0',
|
|
373
480
|
port: options.port,
|
|
@@ -377,6 +484,7 @@ export function createIngressServer(options: CreateIngressServerOptions): BakedS
|
|
|
377
484
|
// to 16 MiB so an oversized request is rejected at the socket without ever
|
|
378
485
|
// being buffered (route handlers enforce the tighter `maxBodyBytes`).
|
|
379
486
|
maxRequestBodySize: options.maxRequestBodySize ?? DEFAULT_MAX_REQUEST_BODY_SIZE,
|
|
487
|
+
error: (error, req) => onServerError(error, req),
|
|
380
488
|
}
|
|
381
489
|
if (options.reusePort) {
|
|
382
490
|
serverOptions.reusePort = true
|
|
@@ -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),
|
|
@@ -37,7 +37,8 @@ export function buildTask(
|
|
|
37
37
|
return {
|
|
38
38
|
taskInit: (n) => Number(init(n)),
|
|
39
39
|
// `args` is copied onto the worker by Rust, so the caller's pooled buffer
|
|
40
|
-
// may be released as soon as this returns.
|
|
40
|
+
// may be released as soon as this returns. The return code is `1` accepted,
|
|
41
|
+
// `2` overloaded (bounded work queue full), `0` invalid args.
|
|
41
42
|
taskSubmit: (op, args, taskId) => Number(submit(op, args, lenOrView(args), taskId)),
|
|
42
43
|
taskSubmitOut: (op, args, taskId, output) =>
|
|
43
44
|
Number(submitOut(op, args, lenOrView(args), taskId, output, lenOrView(output))),
|
package/src/native/ffi/types.ts
CHANGED
|
@@ -515,18 +515,23 @@ export interface BunFFI {
|
|
|
515
515
|
// ── Off-thread task runtime (castrum_task_*) ──────────────────────
|
|
516
516
|
/** Start the shared native task pool; 0 = started, 1 = already running. */
|
|
517
517
|
taskInit(threads: number): number
|
|
518
|
-
/**
|
|
518
|
+
/**
|
|
519
|
+
* Submit a task op with packed `args` under `taskId`. Returns `1` accepted,
|
|
520
|
+
* `2` overloaded (bounded queue full), `0` invalid args.
|
|
521
|
+
*/
|
|
519
522
|
taskSubmit(op: number, args: Uint8Array, taskId: number): number
|
|
520
523
|
/**
|
|
521
524
|
* Zero-copy submit: the op writes its result DIRECTLY into `output` on the
|
|
522
525
|
* pool thread. `output` must be a stable, caller-owned buffer that stays
|
|
523
526
|
* alive (and untouched) until the completion is drained — the runtime holds
|
|
524
527
|
* the reference. The completion body is the 8-byte LE written length.
|
|
528
|
+
* Returns `1` accepted, `2` overloaded, `0` invalid args.
|
|
525
529
|
*/
|
|
526
530
|
taskSubmitOut(op: number, args: Uint8Array, taskId: number, output: Uint8Array): number
|
|
527
531
|
/**
|
|
528
532
|
* Zero-copy INPUT: only `hdr` is copied; `data` is read in place by the pool
|
|
529
533
|
* thread, so the caller must keep it alive (and untouched) until completion.
|
|
534
|
+
* Returns `1` accepted, `2` overloaded, `0` invalid args.
|
|
530
535
|
*/
|
|
531
536
|
taskSubmitSlice(op: number, hdr: Uint8Array, data: Uint8Array, taskId: number): number
|
|
532
537
|
/**
|