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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/ingress/abort.ts — PURE request-cancellation helpers.
|
|
2
|
+
//
|
|
3
|
+
// The async ingress reads `Request.signal` (fired when the client disconnects)
|
|
4
|
+
// at its genuine async boundaries. A body read or a route wrapper that observes
|
|
5
|
+
// an abort returns the cancelled-client response instead of running more work.
|
|
6
|
+
// PURE: no addon / pool imports — safe for the route factories.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `err.code` value a body read carries when it is rejected because the
|
|
10
|
+
* client disconnected (`Request.signal` aborted).
|
|
11
|
+
*/
|
|
12
|
+
export const ABORT_CODE = 'REQUEST_ABORTED'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a thrown value is the cancellation error produced when `req.signal`
|
|
16
|
+
* aborts during a body read (see `readBodyWithLimit` in `./body`).
|
|
17
|
+
*
|
|
18
|
+
* @param err - The caught value.
|
|
19
|
+
* @returns `true` when `err.code === 'REQUEST_ABORTED'`.
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* try {
|
|
23
|
+
* await readBodyWithLimit(req, 1 << 20, true)
|
|
24
|
+
* } catch (err) {
|
|
25
|
+
* if (isAbortError(err)) return abortResponse()
|
|
26
|
+
* throw err
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function isAbortError(err: unknown): boolean {
|
|
31
|
+
return (err as { code?: unknown } | null | undefined)?.code === ABORT_CODE
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the cancelled-client response: status **499** ("Client Closed
|
|
36
|
+
* Request", a de-facto nginx status) with no body. This is the only new
|
|
37
|
+
* outcome added by cancellation — it never replaces an existing success or
|
|
38
|
+
* error status.
|
|
39
|
+
*
|
|
40
|
+
* @returns A bodyless `Response` with status 499.
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* if (req.signal?.aborted) return abortResponse()
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function abortResponse(): Response {
|
|
47
|
+
return new Response(null, { status: 499 })
|
|
48
|
+
}
|
package/src/ingress/body.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// API.
|
|
3
3
|
|
|
4
4
|
import { isBun } from '../shared/runtime'
|
|
5
|
+
import { ABORT_CODE } from './abort'
|
|
5
6
|
|
|
6
7
|
const EMPTY_BODY = new Uint8Array(0)
|
|
7
8
|
|
|
@@ -65,6 +66,59 @@ function bodyTooLargeError(): Error & { code?: string } {
|
|
|
65
66
|
return err
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function abortError(): Error & { code?: string } {
|
|
70
|
+
const err = new Error(ABORT_CODE) as Error & { code?: string }
|
|
71
|
+
err.code = ABORT_CODE
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reject when `signal` is already aborted (the entry guard for every path). */
|
|
76
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
77
|
+
if (signal?.aborted) throw abortError()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A reusable rejection promise + listener-cleanup pair for a read race. */
|
|
81
|
+
interface ReadGuard {
|
|
82
|
+
promise: Promise<never>
|
|
83
|
+
cancel: () => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Track `req.signal` as a rejection racer: settles with a `REQUEST_ABORTED`
|
|
88
|
+
* error when the client disconnects. Returns `null` when the request carries no
|
|
89
|
+
* signal, so runtimes without `Request.signal` are unaffected. The
|
|
90
|
+
* `{ once: true }` listener self-removes when it fires; `cancel()` detaches it
|
|
91
|
+
* on the read-completed-first path so no listener outlives the read.
|
|
92
|
+
*/
|
|
93
|
+
function createAbortGuard(signal: AbortSignal | undefined): ReadGuard | null {
|
|
94
|
+
if (!signal) return null
|
|
95
|
+
if (signal.aborted) {
|
|
96
|
+
return { promise: Promise.reject(abortError()), cancel: () => {} }
|
|
97
|
+
}
|
|
98
|
+
let listener: (() => void) | null = null
|
|
99
|
+
const promise = new Promise<never>((_resolve, reject) => {
|
|
100
|
+
listener = () => reject(abortError())
|
|
101
|
+
signal.addEventListener('abort', listener, { once: true })
|
|
102
|
+
})
|
|
103
|
+
return {
|
|
104
|
+
promise,
|
|
105
|
+
cancel: () => {
|
|
106
|
+
if (listener !== null) {
|
|
107
|
+
signal.removeEventListener('abort', listener)
|
|
108
|
+
listener = null
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Race a read against guard rejections (a plain await when there are none). */
|
|
115
|
+
function raceRead<T>(read: Promise<T>, guards: ReadonlyArray<ReadGuard>): Promise<T> {
|
|
116
|
+
if (guards.length === 0) return read
|
|
117
|
+
const racers: Promise<unknown>[] = [read]
|
|
118
|
+
for (const guard of guards) racers.push(guard.promise)
|
|
119
|
+
return Promise.race(racers) as Promise<T>
|
|
120
|
+
}
|
|
121
|
+
|
|
68
122
|
// ── Shared body-read deadline watchdog ──────────────────────────────────────
|
|
69
123
|
// One interval timer sweeps ALL in-flight body reads that carry a deadline,
|
|
70
124
|
// replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
|
|
@@ -145,6 +199,9 @@ function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel:
|
|
|
145
199
|
* (thrown as soon as the limit is crossed — the body is never fully
|
|
146
200
|
* buffered first, which is the slowloris/large-body protection).
|
|
147
201
|
* - `REQUEST_TIMEOUT` when `timeoutMs` elapses before the body completes.
|
|
202
|
+
* - `REQUEST_ABORTED` when `req.signal` aborts (client disconnect) before the
|
|
203
|
+
* read settles — including an already-aborted signal at entry. The race's
|
|
204
|
+
* abort listener is removed once the read settles.
|
|
148
205
|
*/
|
|
149
206
|
export async function readBodyWithLimit(
|
|
150
207
|
req: Request,
|
|
@@ -152,6 +209,10 @@ export async function readBodyWithLimit(
|
|
|
152
209
|
guard: boolean,
|
|
153
210
|
timeoutMs?: number,
|
|
154
211
|
): Promise<Uint8Array> {
|
|
212
|
+
// Client disconnect before any read work: reject immediately (also covers an
|
|
213
|
+
// already-aborted signal on every downstream path).
|
|
214
|
+
throwIfAborted(req.signal)
|
|
215
|
+
|
|
155
216
|
const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
|
|
156
217
|
|
|
157
218
|
// Fast path: a declared Content-Length lets us PROVE the body fits the
|
|
@@ -173,11 +234,34 @@ export async function readBodyWithLimit(
|
|
|
173
234
|
// path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
|
|
174
235
|
// body directly, avoiding a lazily-constructed stream per write request.
|
|
175
236
|
if (deadline <= 0) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
237
|
+
// No deadline: use the same buffered fast path as the deadline branch.
|
|
238
|
+
// Under Bun a small declared-length body is usually already buffered —
|
|
239
|
+
// `Bun.peek` returns the bytes synchronously, so the common case skips
|
|
240
|
+
// the abort race + microtask entirely. Only a genuinely pending read
|
|
241
|
+
// (slow/trickling body) or a runtime without `req.bytes()` (Node) pays
|
|
242
|
+
// the abort race.
|
|
243
|
+
const buffered = peekBufferedBody(req)
|
|
244
|
+
if (buffered !== false && 'bytes' in buffered) {
|
|
245
|
+
throwIfAborted(req.signal)
|
|
246
|
+
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
247
|
+
throw bodyTooLargeError()
|
|
248
|
+
}
|
|
249
|
+
return buffered.bytes
|
|
250
|
+
}
|
|
251
|
+
const abort = createAbortGuard(req.signal)
|
|
252
|
+
try {
|
|
253
|
+
const bytes = await raceRead(
|
|
254
|
+
buffered === false ? readBodyBytes(req) : buffered,
|
|
255
|
+
abort !== null ? [abort] : [],
|
|
256
|
+
)
|
|
257
|
+
throwIfAborted(req.signal)
|
|
258
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
259
|
+
throw bodyTooLargeError()
|
|
260
|
+
}
|
|
261
|
+
return bytes
|
|
262
|
+
} finally {
|
|
263
|
+
abort?.cancel()
|
|
179
264
|
}
|
|
180
|
-
return bytes
|
|
181
265
|
}
|
|
182
266
|
// Deadline path. Under Bun, a small declared-length body is usually
|
|
183
267
|
// already buffered — `Bun.peek` returns the bytes synchronously, so we
|
|
@@ -187,31 +271,38 @@ export async function readBodyWithLimit(
|
|
|
187
271
|
const buffered = peekBufferedBody(req)
|
|
188
272
|
if (buffered === false) {
|
|
189
273
|
const dl = createDeadline(deadline)
|
|
274
|
+
const abort = createAbortGuard(req.signal)
|
|
190
275
|
try {
|
|
191
|
-
const bytes = await
|
|
276
|
+
const bytes = await raceRead(readBodyBytes(req), abort !== null ? [dl, abort] : [dl])
|
|
277
|
+
throwIfAborted(req.signal)
|
|
192
278
|
if (guard && bytes.byteLength > maxBytes) {
|
|
193
279
|
throw bodyTooLargeError()
|
|
194
280
|
}
|
|
195
281
|
return bytes
|
|
196
282
|
} finally {
|
|
197
283
|
dl.cancel()
|
|
284
|
+
abort?.cancel()
|
|
198
285
|
}
|
|
199
286
|
}
|
|
200
287
|
if ('bytes' in buffered) {
|
|
288
|
+
throwIfAborted(req.signal)
|
|
201
289
|
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
202
290
|
throw bodyTooLargeError()
|
|
203
291
|
}
|
|
204
292
|
return buffered.bytes
|
|
205
293
|
}
|
|
206
294
|
const dl = createDeadline(deadline)
|
|
295
|
+
const abort = createAbortGuard(req.signal)
|
|
207
296
|
try {
|
|
208
|
-
const bytes = await
|
|
297
|
+
const bytes = await raceRead(buffered, abort !== null ? [dl, abort] : [dl])
|
|
298
|
+
throwIfAborted(req.signal)
|
|
209
299
|
if (guard && bytes.byteLength > maxBytes) {
|
|
210
300
|
throw bodyTooLargeError()
|
|
211
301
|
}
|
|
212
302
|
return bytes
|
|
213
303
|
} finally {
|
|
214
304
|
dl.cancel()
|
|
305
|
+
abort?.cancel()
|
|
215
306
|
}
|
|
216
307
|
}
|
|
217
308
|
}
|
|
@@ -220,6 +311,7 @@ export async function readBodyWithLimit(
|
|
|
220
311
|
// is only touched here — the declared-length fast path never needs it.
|
|
221
312
|
const body = req.body
|
|
222
313
|
if (!body) {
|
|
314
|
+
throwIfAborted(req.signal)
|
|
223
315
|
return EMPTY_BODY
|
|
224
316
|
}
|
|
225
317
|
|
|
@@ -231,11 +323,14 @@ export async function readBodyWithLimit(
|
|
|
231
323
|
// per request. It is still required when the loop exits early (timeout/error).
|
|
232
324
|
let completed = false
|
|
233
325
|
const dl = deadline > 0 ? createDeadline(deadline) : null
|
|
326
|
+
const abort = createAbortGuard(req.signal)
|
|
327
|
+
const guards: ReadGuard[] = []
|
|
328
|
+
if (dl !== null) guards.push(dl)
|
|
329
|
+
if (abort !== null) guards.push(abort)
|
|
234
330
|
|
|
235
331
|
try {
|
|
236
332
|
for (;;) {
|
|
237
|
-
const { done, value } =
|
|
238
|
-
dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
|
|
333
|
+
const { done, value } = await raceRead(reader.read(), guards)
|
|
239
334
|
|
|
240
335
|
if (done) {
|
|
241
336
|
completed = true
|
|
@@ -253,6 +348,7 @@ export async function readBodyWithLimit(
|
|
|
253
348
|
}
|
|
254
349
|
} finally {
|
|
255
350
|
dl?.cancel()
|
|
351
|
+
abort?.cancel()
|
|
256
352
|
// Release the underlying stream. Cancelling while a read() is still
|
|
257
353
|
// pending (the timeout case) can resolve that pending read() as `done`
|
|
258
354
|
// and RACE the timeout rejection — so reject first, cancel only AFTER
|
|
@@ -36,12 +36,12 @@ import { IngressResultBase } from './result-base'
|
|
|
36
36
|
|
|
37
37
|
/** Zero-alloc, reusable result decoder for the pre-baked handler path. */
|
|
38
38
|
export class BakedIngressResult extends IngressResultBase {
|
|
39
|
-
refresh(buf: Uint8Array, body: Uint8Array, view: DataView): void {
|
|
39
|
+
refresh(buf: Uint8Array, body: Uint8Array, view: DataView, len: number = buf.byteLength): void {
|
|
40
40
|
// Defensive: the native core always writes the full fixed header
|
|
41
41
|
// (>= OUT_DATA_START bytes) before returning `written`. The cached
|
|
42
42
|
// whole-buffer DataView (see viewForArrayBuffer) would otherwise decode
|
|
43
43
|
// stale bytes if this contract is ever violated — treat as internal error.
|
|
44
|
-
if (
|
|
44
|
+
if (len < OUT_DATA_START) {
|
|
45
45
|
this.setInternalError()
|
|
46
46
|
return
|
|
47
47
|
}
|
|
@@ -59,7 +59,7 @@ export class BakedIngressResult extends IngressResultBase {
|
|
|
59
59
|
|
|
60
60
|
// Bounds-checked section offsets shared with the fast decoder: a
|
|
61
61
|
// malformed/truncated buffer can never produce slices past its end.
|
|
62
|
-
const layout = sectionLayout(
|
|
62
|
+
const layout = sectionLayout(len, cookiesLenRaw, queryLenRaw, bodyJsonLenRaw)
|
|
63
63
|
|
|
64
64
|
if (h0 === 0 && h1 === 0) {
|
|
65
65
|
this.verdict = 1
|
|
@@ -85,7 +85,7 @@ export class BakedIngressResult extends IngressResultBase {
|
|
|
85
85
|
|
|
86
86
|
// The baked path trusts the already-clamped section lengths, so pass the
|
|
87
87
|
// safe body len here (the fast path re-checks bounds on read instead).
|
|
88
|
-
this.setSections(buf, layout, layout.safeBodyJsonLen)
|
|
88
|
+
this.setSections(buf, layout, layout.safeBodyJsonLen, len)
|
|
89
89
|
|
|
90
90
|
this.updateOkTerminal()
|
|
91
91
|
|
|
@@ -115,7 +115,15 @@ export class BakedIngressResult extends IngressResultBase {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
bodyJson(copy: boolean): Uint8Array {
|
|
118
|
+
if (copy) {
|
|
119
|
+
// Copy directly out of the buffer — `buffer.slice(begin, end)` avoids the
|
|
120
|
+
// intermediate `subarray()` view that `bodyJsonSlice().slice()` created.
|
|
121
|
+
if (this._bodyJsonLen === 0) return this._buf.slice(0, 0)
|
|
122
|
+
const end = this._bodyJsonStart + this._bodyJsonLen
|
|
123
|
+
if (end > this._len) return this._buf.slice(0, 0)
|
|
124
|
+
return this._buf.slice(this._bodyJsonStart, end)
|
|
125
|
+
}
|
|
118
126
|
const slice = this.bodyJsonSlice()
|
|
119
|
-
return
|
|
127
|
+
return slice as Uint8Array
|
|
120
128
|
}
|
|
121
129
|
}
|
|
@@ -127,7 +127,7 @@ export class FastIngressResult extends IngressResultBase {
|
|
|
127
127
|
// Store slices for lazy decode instead of eagerly decoding strings. The
|
|
128
128
|
// fast path trusts the RAW declared body length and re-checks bounds on
|
|
129
129
|
// read (bodyJson()), so pass the raw declared length here.
|
|
130
|
-
this.setSections(buf, layout, bodyJsonLen)
|
|
130
|
+
this.setSections(buf, layout, bodyJsonLen, buf.byteLength)
|
|
131
131
|
|
|
132
132
|
// Parity with the baked decoder: any declared section (cookies, query,
|
|
133
133
|
// OR body) that overran the buffer counts as truncated.
|
|
@@ -62,6 +62,13 @@ export abstract class IngressResultBase {
|
|
|
62
62
|
body: Uint8Array = EMPTY_BODY
|
|
63
63
|
|
|
64
64
|
protected _buf: Uint8Array = EMPTY_BODY
|
|
65
|
+
/**
|
|
66
|
+
* Valid byte length of `_buf` (the native `written` count). `_buf` may be the
|
|
67
|
+
* WHOLE pooled output buffer (`outputBufferSize` bytes), so bounds checks must
|
|
68
|
+
* use this, not `_buf.byteLength` — that lets `run()` skip a per-request
|
|
69
|
+
* `buffer.subarray(0, written)` view.
|
|
70
|
+
*/
|
|
71
|
+
protected _len = 0
|
|
65
72
|
protected _bodyJsonStart = OUT_DATA_START
|
|
66
73
|
protected _bodyJsonLen = 0
|
|
67
74
|
private _cookiesBuf: Uint8Array | null = null
|
|
@@ -89,6 +96,7 @@ export abstract class IngressResultBase {
|
|
|
89
96
|
this.bodyTruncated = false
|
|
90
97
|
this.body = EMPTY_BODY
|
|
91
98
|
this._buf = EMPTY_BODY
|
|
99
|
+
this._len = 0
|
|
92
100
|
this._bodyJsonStart = OUT_DATA_START
|
|
93
101
|
this._bodyJsonLen = 0
|
|
94
102
|
this._cookiesBuf = null
|
|
@@ -134,8 +142,14 @@ export abstract class IngressResultBase {
|
|
|
134
142
|
* baked path passes the already-clamped `layout.safeBodyJsonLen`. Sections
|
|
135
143
|
* are stored as slices and decoded lazily by `cookiesJson()`/`queryJson()`.
|
|
136
144
|
*/
|
|
137
|
-
protected setSections(
|
|
145
|
+
protected setSections(
|
|
146
|
+
buf: Uint8Array,
|
|
147
|
+
layout: PackedSectionLayout,
|
|
148
|
+
bodyJsonLen: number,
|
|
149
|
+
len: number = buf.byteLength,
|
|
150
|
+
): void {
|
|
138
151
|
this._buf = buf
|
|
152
|
+
this._len = len
|
|
139
153
|
this._bodyJsonStart = layout.bodyJsonStart
|
|
140
154
|
this._bodyJsonLen = bodyJsonLen
|
|
141
155
|
this._cookiesBuf =
|
|
@@ -174,7 +188,7 @@ export abstract class IngressResultBase {
|
|
|
174
188
|
protected bodyJsonSlice(): Uint8Array {
|
|
175
189
|
if (this._bodyJsonLen === 0) return EMPTY_BODY
|
|
176
190
|
const end = this._bodyJsonStart + this._bodyJsonLen
|
|
177
|
-
if (end > this.
|
|
191
|
+
if (end > this._len) return EMPTY_BODY
|
|
178
192
|
return this._buf.subarray(this._bodyJsonStart, end)
|
|
179
193
|
}
|
|
180
194
|
}
|
package/src/ingress/handlers.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
|
|
|
44
44
|
import { decoder, viewForArrayBuffer } from '../shared/bytes'
|
|
45
45
|
import { createStructuredLogger } from '../shared/log'
|
|
46
46
|
import { generateRequestId } from '../shared/request-id'
|
|
47
|
-
import { OUT_DATA_START } from './constants'
|
|
47
|
+
import { OUT_DATA_START, HV_CORS_SIMPLE, HV_JSON, HV_RATE_ACTIVE } from './constants'
|
|
48
48
|
import { BakedIngressResult } from './decode/baked-result'
|
|
49
49
|
import { buildBakedHeaderTemplates } from './headers/baked-templates'
|
|
50
50
|
import { buildBakedSecurityEntries } from './headers/security'
|
|
@@ -54,9 +54,13 @@ import {
|
|
|
54
54
|
type IngressHandlerOptions,
|
|
55
55
|
warnTrustProxyDeprecated,
|
|
56
56
|
} from './options'
|
|
57
|
-
import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
|
|
57
|
+
import { gatherRawHeadersPacked, prewarmOriginBlocks } from './packing/gather-raw-headers'
|
|
58
58
|
import { IngressInputPacker } from './packing/input-packer'
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
type BakedResponseState,
|
|
61
|
+
buildBakedResponseBuilders,
|
|
62
|
+
prewarmOriginHeaders,
|
|
63
|
+
} from './response/baked-response'
|
|
60
64
|
import {
|
|
61
65
|
assertSyncCallback,
|
|
62
66
|
buildHeaderPlan,
|
|
@@ -90,6 +94,7 @@ export type {
|
|
|
90
94
|
CreateIngressServerOptions,
|
|
91
95
|
GracefulShutdownOptions,
|
|
92
96
|
ServerHandle,
|
|
97
|
+
StaticRoute,
|
|
93
98
|
} from './server'
|
|
94
99
|
export { createIngressServer, gracefulShutdown } from './server'
|
|
95
100
|
export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
|
|
@@ -110,6 +115,22 @@ export const RATE_LIMIT_U32_MAX = 4_294_967_295
|
|
|
110
115
|
*/
|
|
111
116
|
const MAX_FFI_FAILURES = 3
|
|
112
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Number of output buffers pre-allocated into the handler's pool at
|
|
120
|
+
* construction (`prefill`), so the first burst wave never pays per-request
|
|
121
|
+
* `new Uint8Array` + GC. Memory trade: `outputBufferSize` × this many bytes
|
|
122
|
+
* retained per handler (bounded by the pool's `maxBuffers` cap).
|
|
123
|
+
*/
|
|
124
|
+
const PREWARM_POOL_BUFFERS = 16
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Byte budget for the pool prefill. `createIngressRouter` builds one handler
|
|
128
|
+
* (and therefore one pool) per route, so an unbounded `prefill × outputBufferSize`
|
|
129
|
+
* could reach ~1 GiB per route at the 64 MiB output clamp. This caps the
|
|
130
|
+
* per-handler up-front allocation at 4 MiB regardless of buffer size.
|
|
131
|
+
*/
|
|
132
|
+
const PREWARM_POOL_BYTES = 4 * 1024 * 1024
|
|
133
|
+
|
|
113
134
|
/**
|
|
114
135
|
* Best-effort URL pathname extraction for log/error lines.
|
|
115
136
|
*
|
|
@@ -285,12 +306,24 @@ export function createIngressHandler(
|
|
|
285
306
|
logger
|
|
286
307
|
)
|
|
287
308
|
|
|
309
|
+
// Hoisted observer-presence flags: the hot path skips the response-status
|
|
310
|
+
// computation (an `instanceof` + `safeTerminalStatus`) and the per-request
|
|
311
|
+
// hook call entirely when nothing observes the outcome.
|
|
312
|
+
const hasOnRequest = runtime.onRequest !== undefined
|
|
313
|
+
const hasOnResponse = runtime.onResponse !== undefined
|
|
314
|
+
|
|
288
315
|
// Reusable output-buffer pool: eliminates the per-request output-buffer
|
|
289
316
|
// allocation by reusing buffers across requests.
|
|
290
317
|
// `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
|
|
318
|
+
// `prefill` spends up-front memory (`outputBufferSize` × 16 per handler) so
|
|
319
|
+
// a burst ramp never allocates during its first wave — the packaged
|
|
320
|
+
// load-test flow, where the pool would otherwise go 1 buffered → allocate
|
|
321
|
+
// under a 2000-connection flood.
|
|
291
322
|
const outputPool = new BufferPool({
|
|
292
323
|
initialSize: outputBufferSize,
|
|
293
324
|
maxInFlight: runtime.maxInFlight,
|
|
325
|
+
prefill: PREWARM_POOL_BUFFERS,
|
|
326
|
+
prefillBytes: PREWARM_POOL_BYTES,
|
|
294
327
|
})
|
|
295
328
|
|
|
296
329
|
// Reusable packed-input builder (same zero-alloc discipline as the fast
|
|
@@ -313,16 +346,24 @@ export function createIngressHandler(
|
|
|
313
346
|
// flaky FFI path degrades to a correct response instead of a failure storm.
|
|
314
347
|
let ffiFailures = 0
|
|
315
348
|
let ffiDisabled = false
|
|
349
|
+
// Cold path only (called from a `catch`): counts a recurrence and trips the
|
|
350
|
+
// circuit breaker. Defined once at construction, so it adds nothing per call.
|
|
351
|
+
const noteFfiFailure = (): void => {
|
|
352
|
+
ffiFailures++
|
|
353
|
+
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
354
|
+
ffiDisabled = true
|
|
355
|
+
}
|
|
356
|
+
}
|
|
316
357
|
|
|
317
358
|
// Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
|
|
318
359
|
// steady-state CORS case: the pre-baked template + one
|
|
319
360
|
// `access-control-allow-origin` row). Callers treat the returned array as
|
|
320
361
|
// read-only (the `Headers`/`Response` constructors copy entries), so every
|
|
321
362
|
// distinct (variant, origin) is reused after its first hit — removing the
|
|
322
|
-
// per-request array alloc + copy when an Origin header is present.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
363
|
+
// per-request array alloc + copy when an Origin header is present. The map is
|
|
364
|
+
// bounded by `ORIGIN_HEADER_CACHE_MAX` in baked-response.ts: with wildcard
|
|
365
|
+
// CORS the Origin is attacker-controlled, so an unbounded map would be a
|
|
366
|
+
// remote memory-exhaustion vector.
|
|
326
367
|
const originHeaderCache = new Map<string, [string, string][]>()
|
|
327
368
|
|
|
328
369
|
// ── Variant-indexed header templates (precomputed once) ──
|
|
@@ -379,6 +420,34 @@ export function createIngressHandler(
|
|
|
379
420
|
state: responseState,
|
|
380
421
|
})
|
|
381
422
|
|
|
423
|
+
// Pre-warm the CORS caches for the configured exact allow-origins
|
|
424
|
+
// (load-time-for-RPS trade): the FIRST request from each known origin then
|
|
425
|
+
// hits the baked origin-packed header block AND the cached
|
|
426
|
+
// origin-augmented header array + memoized `Headers` instead of building
|
|
427
|
+
// them. Wildcard/pattern entries (e.g. `https://*.example.com`) are skipped
|
|
428
|
+
// — the concrete resolved origin differs from the pattern string, so
|
|
429
|
+
// caching the pattern would only waste a slot.
|
|
430
|
+
if (headerPlan.cors && typeof cors?.allowOrigin === 'object') {
|
|
431
|
+
const exactOrigins: string[] = []
|
|
432
|
+
for (const origin of cors.allowOrigin) {
|
|
433
|
+
if (
|
|
434
|
+
typeof origin === 'string' &&
|
|
435
|
+
origin.length > 0 &&
|
|
436
|
+
origin.length <= 512 &&
|
|
437
|
+
!origin.includes('*')
|
|
438
|
+
) {
|
|
439
|
+
exactOrigins.push(origin)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
prewarmOriginBlocks(exactOrigins)
|
|
443
|
+
// Warm the SUCCESS variant the native pipeline actually returns for an
|
|
444
|
+
// allowed CORS JSON response (HV_JSON | HV_CORS_SIMPLE, plus HV_RATE_ACTIVE
|
|
445
|
+
// when rate limiting is on) — not HV_CORS_SIMPLE alone, which is a
|
|
446
|
+
// different cache key and left the warmed entry unreachable.
|
|
447
|
+
const successVariant = HV_JSON | HV_CORS_SIMPLE | (rateEnabled ? HV_RATE_ACTIVE : 0)
|
|
448
|
+
prewarmOriginHeaders(responseBuilders.responseHeaders, exactOrigins, successVariant)
|
|
449
|
+
}
|
|
450
|
+
|
|
382
451
|
const result = new BakedIngressResult()
|
|
383
452
|
const ctx: BakedContext = {
|
|
384
453
|
requestIdHeader: null,
|
|
@@ -399,7 +468,7 @@ export function createIngressHandler(
|
|
|
399
468
|
ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
|
|
400
469
|
ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
|
|
401
470
|
|
|
402
|
-
runtime.onRequest?.(req, requestIdStr, ip)
|
|
471
|
+
if (hasOnRequest) runtime.onRequest?.(req, requestIdStr, ip)
|
|
403
472
|
|
|
404
473
|
const ipStr = ip ?? EMPTY_IP
|
|
405
474
|
|
|
@@ -463,10 +532,7 @@ export function createIngressHandler(
|
|
|
463
532
|
)
|
|
464
533
|
ffiFailures = 0
|
|
465
534
|
} catch {
|
|
466
|
-
|
|
467
|
-
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
468
|
-
ffiDisabled = true
|
|
469
|
-
}
|
|
535
|
+
noteFfiFailure()
|
|
470
536
|
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
471
537
|
// identical — a transient ffi panic still serves the request. The
|
|
472
538
|
// packed frame respects the same url/ip minimal-processing.
|
|
@@ -485,10 +551,7 @@ export function createIngressHandler(
|
|
|
485
551
|
written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
|
|
486
552
|
ffiFailures = 0
|
|
487
553
|
} catch {
|
|
488
|
-
|
|
489
|
-
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
490
|
-
ffiDisabled = true
|
|
491
|
-
}
|
|
554
|
+
noteFfiFailure()
|
|
492
555
|
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
493
556
|
// identical — a transient ffi panic still serves the request.
|
|
494
557
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
@@ -498,9 +561,16 @@ export function createIngressHandler(
|
|
|
498
561
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
499
562
|
}
|
|
500
563
|
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
|
|
564
|
+
// Pass the WHOLE pooled buffer + the written length instead of a
|
|
565
|
+
// per-request `buffer.subarray(0, written)` view (a ~35ns allocation in
|
|
566
|
+
// Bun); the decoder bounds-checks against the length. Same DataView
|
|
567
|
+
// (cached per-ArrayBuffer, offset unchanged).
|
|
568
|
+
result.refresh(
|
|
569
|
+
handle.buffer,
|
|
570
|
+
body ?? EMPTY_BODY,
|
|
571
|
+
viewForArrayBuffer(handle.buffer.buffer, handle.buffer.byteOffset),
|
|
572
|
+
written,
|
|
573
|
+
)
|
|
504
574
|
} catch (err) {
|
|
505
575
|
result.setInternalError()
|
|
506
576
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
@@ -522,18 +592,21 @@ export function createIngressHandler(
|
|
|
522
592
|
assertSyncCallback(out, 'createIngressHandler().run()')
|
|
523
593
|
// Report observability for every outcome. Non-Response callbacks
|
|
524
594
|
// (e.g. echoHandler's object return) fall back to the decoded terminal
|
|
525
|
-
// status so they are not silently invisible to onResponse/logging.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
595
|
+
// status so they are not silently invisible to onResponse/logging. The
|
|
596
|
+
// status is computed ONLY when something observes it.
|
|
597
|
+
if (hasOnResponse || logger !== null) {
|
|
598
|
+
const status = out instanceof Response ? out.status : safeTerminalStatus(result)
|
|
599
|
+
if (hasOnResponse) runtime.onResponse?.(req, result, status, requestIdStr)
|
|
600
|
+
if (logger !== null) {
|
|
601
|
+
logger.request({
|
|
602
|
+
requestId: requestIdStr,
|
|
603
|
+
method: req.method,
|
|
604
|
+
path: pathForLog(req),
|
|
605
|
+
status,
|
|
606
|
+
durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
|
|
607
|
+
ip: ipStr,
|
|
608
|
+
})
|
|
609
|
+
}
|
|
537
610
|
}
|
|
538
611
|
return out
|
|
539
612
|
} finally {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/ingress/headers/memoized-headers.ts — memoized `Headers` for baked header arrays.
|
|
2
|
+
//
|
|
3
|
+
// `new Response(body, { headers })` copies its headers either way, but Bun
|
|
4
|
+
// re-parses an array-of-pairs on every construction whereas a persistent
|
|
5
|
+
// `Headers` instance is copied directly — measured ~1.4 µs cheaper per response
|
|
6
|
+
// with the full security + CORS set (398 ns vs 1832 ns), the single largest
|
|
7
|
+
// per-request JS cost on the success path.
|
|
8
|
+
//
|
|
9
|
+
// The arrays the ingress builds are themselves cached (the pre-baked templates
|
|
10
|
+
// and the per-origin cache return the SAME array instance across requests), so
|
|
11
|
+
// this memo hits on the steady state. Fresh per-request arrays (rate-limit /
|
|
12
|
+
// request-id extras) are simply never retained — a `WeakMap` lets them be
|
|
13
|
+
// collected, so the cache cannot grow without bound.
|
|
14
|
+
//
|
|
15
|
+
// The returned `Headers` is treated as IMMUTABLE: it is only ever handed to a
|
|
16
|
+
// `Response` constructor, which copies it. Verified: mutating the source array
|
|
17
|
+
// after construction, or one served response's headers, never leaks into
|
|
18
|
+
// another response.
|
|
19
|
+
|
|
20
|
+
/** Memoized `Headers` keyed by the identity of the baked header array. */
|
|
21
|
+
const CACHE = new WeakMap<readonly [string, string][], Headers>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Return a memoized `Headers` for a baked header array.
|
|
25
|
+
*
|
|
26
|
+
* @param entries Header pairs from a handler header builder (baked template,
|
|
27
|
+
* per-origin cache, or a fresh extras array).
|
|
28
|
+
* @returns A `Headers` instance safe to pass to a `Response` constructor.
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* new Response(body, { status: 200, headers: memoizedHeaders(pairs) })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function memoizedHeaders(entries: readonly [string, string][]): Headers {
|
|
35
|
+
const existing = CACHE.get(entries)
|
|
36
|
+
if (existing !== undefined) return existing
|
|
37
|
+
const built = new Headers(entries as [string, string][])
|
|
38
|
+
CACHE.set(entries, built)
|
|
39
|
+
return built
|
|
40
|
+
}
|
package/src/ingress/health.ts
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
// "/livez": { read: healthHandler() },
|
|
12
12
|
// }});
|
|
13
13
|
//
|
|
14
|
+
// For probes that are truly constant, prefer static promotion — it skips the
|
|
15
|
+
// JS ingress pipeline entirely:
|
|
16
|
+
//
|
|
17
|
+
// createIngressServer({ routes: { "/livez": { static: staticLiveness() } } });
|
|
18
|
+
//
|
|
14
19
|
// The liveness probe NEVER touches dependencies (process alive? -> 200).
|
|
15
20
|
// The readiness probe runs the optional `check` (deps up? -> 200/503).
|
|
16
21
|
// The health probe is an alias of liveness (process up) plus optional check.
|
|
@@ -36,6 +41,23 @@ export function livenessHandler() {
|
|
|
36
41
|
return (_req: Request): Response => jsonResponse(200, { status: 'ok' })
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Liveness probe as a PREBUILT `Response`, for static route promotion.
|
|
46
|
+
*
|
|
47
|
+
* Wire it as `{ static: staticLiveness() }` so `Bun.serve` snapshots the
|
|
48
|
+
* constant body into its native route table and serves `/livez` without
|
|
49
|
+
* entering the JavaScript ingress pipeline at all (see `BakedRoute.static` in
|
|
50
|
+
* src/ingress/server.ts). On the node:http adapter the bare `Response` is
|
|
51
|
+
* re-materialized per request from cached body bytes, so the one-shot body is
|
|
52
|
+
* never replayed. Unlike {@link readinessHandler}, it never runs a dependency
|
|
53
|
+
* check.
|
|
54
|
+
*
|
|
55
|
+
* @returns A fresh 200 `{"status":"ok"}` `Response`.
|
|
56
|
+
*/
|
|
57
|
+
export function staticLiveness(): Response {
|
|
58
|
+
return jsonResponse(200, { status: 'ok' })
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
/**
|
|
40
62
|
* Readiness probe: runs the optional async `check` (dependency readiness).
|
|
41
63
|
* Returns `200 {"status":"ready"}` when the check passes (or no check is
|