castrum 0.9.7 → 0.9.8
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 +501 -5
- package/dist/index.js +1648 -1325
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/index.ts +1 -0
- package/package.json +3 -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 +51 -32
- 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/native_route.rs +21 -3
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +1 -1
- package/rust/task/tests.rs +30 -0
- 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 +103 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- 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 +14 -2
- package/src/ingress/routes/common.ts +26 -10
- package/src/ingress/routes/echo.ts +50 -7
- package/src/ingress/routes/json-write.ts +21 -8
- package/src/ingress/routes/responder.ts +4 -4
- package/src/ingress/server-node.ts +33 -14
- package/src/ingress/server.ts +3 -4
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -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.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/runtime/codec.ts +24 -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/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +47 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +4 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/shared/buffer-pool.ts +44 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/packed/parsers.ts +64 -7
- package/src/task/runtime.ts +29 -28
|
@@ -224,9 +224,11 @@ impl NativeRoute {
|
|
|
224
224
|
if self.require_json_body || self.validate_body {
|
|
225
225
|
if !has_body || body.len() > self.max_body_bytes {
|
|
226
226
|
body_valid_json = false;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
// Inside `require_json_body || validate_body`: an absent or
|
|
228
|
+
// oversized body can never satisfy either constraint, so fail
|
|
229
|
+
// closed. (Pre-fix a validateBody-only route returned OK with
|
|
230
|
+
// error_code 0 and skipped schema validation entirely.)
|
|
231
|
+
error_code = ERR_BODY_NOT_JSON;
|
|
230
232
|
} else {
|
|
231
233
|
body_valid_json = crate::json::json_ops::json_valid_bytes(body);
|
|
232
234
|
if self.require_json_body && !body_valid_json {
|
|
@@ -732,6 +734,22 @@ mod tests {
|
|
|
732
734
|
assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
733
735
|
}
|
|
734
736
|
|
|
737
|
+
#[test]
|
|
738
|
+
fn validate_body_without_require_json_fails_closed_on_absent_body() {
|
|
739
|
+
let schema = br#"{"type":"object","required":["x"],"properties":{"x":{"type":"number"}}}"#;
|
|
740
|
+
// Deliberately NO requireJsonBody stage: a route that only declares
|
|
741
|
+
// validateBody must still reject a missing body. Pre-fix this returned
|
|
742
|
+
// OK with error_code 0 and skipped schema validation entirely.
|
|
743
|
+
let d = descriptor(&[STAGE_VALIDATE_BODY], &[(PART_BODY, schema)]);
|
|
744
|
+
let r = NativeRoute::compile(&d).unwrap();
|
|
745
|
+
let mut out = vec![0u8; 64];
|
|
746
|
+
r.run(&frame(b"", b"", None), &mut out).unwrap();
|
|
747
|
+
let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
|
|
748
|
+
let code = u32::from_le_bytes(out[4..8].try_into().unwrap());
|
|
749
|
+
assert_eq!(code, 400);
|
|
750
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
751
|
+
}
|
|
752
|
+
|
|
735
753
|
#[test]
|
|
736
754
|
fn body_only_route_is_bare_header() {
|
|
737
755
|
let d = descriptor(&[STAGE_REQUIRE_JSON_BODY], &[]);
|
package/rust/task/completion.rs
CHANGED
|
@@ -54,6 +54,13 @@ pub fn set_doorbell(cb: usize) {
|
|
|
54
54
|
DOORBELL.store(cb, Ordering::Release);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/// Disable the doorbell trampoline (`0`). Called on shutdown BEFORE the JS
|
|
58
|
+
/// owner closes the `JSCallback`, so a completion that lands late can never
|
|
59
|
+
/// call a freed trampoline (poll-only mode drains it instead).
|
|
60
|
+
pub fn clear_doorbell() {
|
|
61
|
+
DOORBELL.store(0, Ordering::Release);
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
/// Push a completion and ring the doorbell at most once per armed window.
|
|
58
65
|
///
|
|
59
66
|
/// The ring lock is released BEFORE the trampoline is called: the JS callback
|
package/rust/task/mod.rs
CHANGED
|
@@ -22,7 +22,7 @@ pub mod runtime;
|
|
|
22
22
|
#[cfg(test)]
|
|
23
23
|
mod tests;
|
|
24
24
|
|
|
25
|
-
pub use completion::{drain, pending, set_doorbell};
|
|
25
|
+
pub use completion::{clear_doorbell, drain, pending, set_doorbell};
|
|
26
26
|
pub use ops::{mark_cancelled, STATUS_CANCELLED, STATUS_ERROR, STATUS_OK};
|
|
27
27
|
pub use runtime::{init, shutdown, threads};
|
|
28
28
|
|
package/rust/task/tests.rs
CHANGED
|
@@ -223,6 +223,36 @@ fn doorbell_coalesces_a_batch() {
|
|
|
223
223
|
completion::clear();
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
#[test]
|
|
227
|
+
fn clear_doorbell_suppresses_the_ring() {
|
|
228
|
+
let _g = LOCK.lock();
|
|
229
|
+
completion::clear();
|
|
230
|
+
completion::set_doorbell(bell as *const () as usize);
|
|
231
|
+
BELLS.store(0, Ordering::SeqCst);
|
|
232
|
+
completion::push(Completion {
|
|
233
|
+
id: 1,
|
|
234
|
+
status: STATUS_OK,
|
|
235
|
+
body: vec![0],
|
|
236
|
+
});
|
|
237
|
+
assert_eq!(BELLS.load(Ordering::SeqCst), 1);
|
|
238
|
+
// Disarm by draining, then clear the trampoline. A later completion must
|
|
239
|
+
// NOT call the (now closed) trampoline address.
|
|
240
|
+
let mut buf = vec![0u8; 4096];
|
|
241
|
+
completion::drain(&mut buf);
|
|
242
|
+
completion::clear_doorbell();
|
|
243
|
+
completion::push(Completion {
|
|
244
|
+
id: 2,
|
|
245
|
+
status: STATUS_OK,
|
|
246
|
+
body: vec![0],
|
|
247
|
+
});
|
|
248
|
+
assert_eq!(
|
|
249
|
+
BELLS.load(Ordering::SeqCst),
|
|
250
|
+
1,
|
|
251
|
+
"a cleared doorbell must never be called"
|
|
252
|
+
);
|
|
253
|
+
completion::clear();
|
|
254
|
+
}
|
|
255
|
+
|
|
226
256
|
#[test]
|
|
227
257
|
fn pool_starts_with_a_nonzero_thread_count() {
|
|
228
258
|
let _g = LOCK.lock();
|
|
@@ -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,
|
|
@@ -110,6 +114,22 @@ export const RATE_LIMIT_U32_MAX = 4_294_967_295
|
|
|
110
114
|
*/
|
|
111
115
|
const MAX_FFI_FAILURES = 3
|
|
112
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Number of output buffers pre-allocated into the handler's pool at
|
|
119
|
+
* construction (`prefill`), so the first burst wave never pays per-request
|
|
120
|
+
* `new Uint8Array` + GC. Memory trade: `outputBufferSize` × this many bytes
|
|
121
|
+
* retained per handler (bounded by the pool's `maxBuffers` cap).
|
|
122
|
+
*/
|
|
123
|
+
const PREWARM_POOL_BUFFERS = 16
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Byte budget for the pool prefill. `createIngressRouter` builds one handler
|
|
127
|
+
* (and therefore one pool) per route, so an unbounded `prefill × outputBufferSize`
|
|
128
|
+
* could reach ~1 GiB per route at the 64 MiB output clamp. This caps the
|
|
129
|
+
* per-handler up-front allocation at 4 MiB regardless of buffer size.
|
|
130
|
+
*/
|
|
131
|
+
const PREWARM_POOL_BYTES = 4 * 1024 * 1024
|
|
132
|
+
|
|
113
133
|
/**
|
|
114
134
|
* Best-effort URL pathname extraction for log/error lines.
|
|
115
135
|
*
|
|
@@ -285,12 +305,24 @@ export function createIngressHandler(
|
|
|
285
305
|
logger
|
|
286
306
|
)
|
|
287
307
|
|
|
308
|
+
// Hoisted observer-presence flags: the hot path skips the response-status
|
|
309
|
+
// computation (an `instanceof` + `safeTerminalStatus`) and the per-request
|
|
310
|
+
// hook call entirely when nothing observes the outcome.
|
|
311
|
+
const hasOnRequest = runtime.onRequest !== undefined
|
|
312
|
+
const hasOnResponse = runtime.onResponse !== undefined
|
|
313
|
+
|
|
288
314
|
// Reusable output-buffer pool: eliminates the per-request output-buffer
|
|
289
315
|
// allocation by reusing buffers across requests.
|
|
290
316
|
// `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
|
|
317
|
+
// `prefill` spends up-front memory (`outputBufferSize` × 16 per handler) so
|
|
318
|
+
// a burst ramp never allocates during its first wave — the packaged
|
|
319
|
+
// load-test flow, where the pool would otherwise go 1 buffered → allocate
|
|
320
|
+
// under a 2000-connection flood.
|
|
291
321
|
const outputPool = new BufferPool({
|
|
292
322
|
initialSize: outputBufferSize,
|
|
293
323
|
maxInFlight: runtime.maxInFlight,
|
|
324
|
+
prefill: PREWARM_POOL_BUFFERS,
|
|
325
|
+
prefillBytes: PREWARM_POOL_BYTES,
|
|
294
326
|
})
|
|
295
327
|
|
|
296
328
|
// Reusable packed-input builder (same zero-alloc discipline as the fast
|
|
@@ -313,16 +345,24 @@ export function createIngressHandler(
|
|
|
313
345
|
// flaky FFI path degrades to a correct response instead of a failure storm.
|
|
314
346
|
let ffiFailures = 0
|
|
315
347
|
let ffiDisabled = false
|
|
348
|
+
// Cold path only (called from a `catch`): counts a recurrence and trips the
|
|
349
|
+
// circuit breaker. Defined once at construction, so it adds nothing per call.
|
|
350
|
+
const noteFfiFailure = (): void => {
|
|
351
|
+
ffiFailures++
|
|
352
|
+
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
353
|
+
ffiDisabled = true
|
|
354
|
+
}
|
|
355
|
+
}
|
|
316
356
|
|
|
317
357
|
// Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
|
|
318
358
|
// steady-state CORS case: the pre-baked template + one
|
|
319
359
|
// `access-control-allow-origin` row). Callers treat the returned array as
|
|
320
360
|
// read-only (the `Headers`/`Response` constructors copy entries), so every
|
|
321
361
|
// 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
|
-
//
|
|
362
|
+
// per-request array alloc + copy when an Origin header is present. The map is
|
|
363
|
+
// bounded by `ORIGIN_HEADER_CACHE_MAX` in baked-response.ts: with wildcard
|
|
364
|
+
// CORS the Origin is attacker-controlled, so an unbounded map would be a
|
|
365
|
+
// remote memory-exhaustion vector.
|
|
326
366
|
const originHeaderCache = new Map<string, [string, string][]>()
|
|
327
367
|
|
|
328
368
|
// ── Variant-indexed header templates (precomputed once) ──
|
|
@@ -379,6 +419,34 @@ export function createIngressHandler(
|
|
|
379
419
|
state: responseState,
|
|
380
420
|
})
|
|
381
421
|
|
|
422
|
+
// Pre-warm the CORS caches for the configured exact allow-origins
|
|
423
|
+
// (load-time-for-RPS trade): the FIRST request from each known origin then
|
|
424
|
+
// hits the baked origin-packed header block AND the cached
|
|
425
|
+
// origin-augmented header array + memoized `Headers` instead of building
|
|
426
|
+
// them. Wildcard/pattern entries (e.g. `https://*.example.com`) are skipped
|
|
427
|
+
// — the concrete resolved origin differs from the pattern string, so
|
|
428
|
+
// caching the pattern would only waste a slot.
|
|
429
|
+
if (headerPlan.cors && typeof cors?.allowOrigin === 'object') {
|
|
430
|
+
const exactOrigins: string[] = []
|
|
431
|
+
for (const origin of cors.allowOrigin) {
|
|
432
|
+
if (
|
|
433
|
+
typeof origin === 'string' &&
|
|
434
|
+
origin.length > 0 &&
|
|
435
|
+
origin.length <= 512 &&
|
|
436
|
+
!origin.includes('*')
|
|
437
|
+
) {
|
|
438
|
+
exactOrigins.push(origin)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
prewarmOriginBlocks(exactOrigins)
|
|
442
|
+
// Warm the SUCCESS variant the native pipeline actually returns for an
|
|
443
|
+
// allowed CORS JSON response (HV_JSON | HV_CORS_SIMPLE, plus HV_RATE_ACTIVE
|
|
444
|
+
// when rate limiting is on) — not HV_CORS_SIMPLE alone, which is a
|
|
445
|
+
// different cache key and left the warmed entry unreachable.
|
|
446
|
+
const successVariant = HV_JSON | HV_CORS_SIMPLE | (rateEnabled ? HV_RATE_ACTIVE : 0)
|
|
447
|
+
prewarmOriginHeaders(responseBuilders.responseHeaders, exactOrigins, successVariant)
|
|
448
|
+
}
|
|
449
|
+
|
|
382
450
|
const result = new BakedIngressResult()
|
|
383
451
|
const ctx: BakedContext = {
|
|
384
452
|
requestIdHeader: null,
|
|
@@ -399,7 +467,7 @@ export function createIngressHandler(
|
|
|
399
467
|
ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
|
|
400
468
|
ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
|
|
401
469
|
|
|
402
|
-
runtime.onRequest?.(req, requestIdStr, ip)
|
|
470
|
+
if (hasOnRequest) runtime.onRequest?.(req, requestIdStr, ip)
|
|
403
471
|
|
|
404
472
|
const ipStr = ip ?? EMPTY_IP
|
|
405
473
|
|
|
@@ -463,10 +531,7 @@ export function createIngressHandler(
|
|
|
463
531
|
)
|
|
464
532
|
ffiFailures = 0
|
|
465
533
|
} catch {
|
|
466
|
-
|
|
467
|
-
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
468
|
-
ffiDisabled = true
|
|
469
|
-
}
|
|
534
|
+
noteFfiFailure()
|
|
470
535
|
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
471
536
|
// identical — a transient ffi panic still serves the request. The
|
|
472
537
|
// packed frame respects the same url/ip minimal-processing.
|
|
@@ -485,10 +550,7 @@ export function createIngressHandler(
|
|
|
485
550
|
written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
|
|
486
551
|
ffiFailures = 0
|
|
487
552
|
} catch {
|
|
488
|
-
|
|
489
|
-
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
490
|
-
ffiDisabled = true
|
|
491
|
-
}
|
|
553
|
+
noteFfiFailure()
|
|
492
554
|
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
493
555
|
// identical — a transient ffi panic still serves the request.
|
|
494
556
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
@@ -498,9 +560,16 @@ export function createIngressHandler(
|
|
|
498
560
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
499
561
|
}
|
|
500
562
|
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
|
|
563
|
+
// Pass the WHOLE pooled buffer + the written length instead of a
|
|
564
|
+
// per-request `buffer.subarray(0, written)` view (a ~35ns allocation in
|
|
565
|
+
// Bun); the decoder bounds-checks against the length. Same DataView
|
|
566
|
+
// (cached per-ArrayBuffer, offset unchanged).
|
|
567
|
+
result.refresh(
|
|
568
|
+
handle.buffer,
|
|
569
|
+
body ?? EMPTY_BODY,
|
|
570
|
+
viewForArrayBuffer(handle.buffer.buffer, handle.buffer.byteOffset),
|
|
571
|
+
written,
|
|
572
|
+
)
|
|
504
573
|
} catch (err) {
|
|
505
574
|
result.setInternalError()
|
|
506
575
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
@@ -522,18 +591,21 @@ export function createIngressHandler(
|
|
|
522
591
|
assertSyncCallback(out, 'createIngressHandler().run()')
|
|
523
592
|
// Report observability for every outcome. Non-Response callbacks
|
|
524
593
|
// (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
|
-
|
|
594
|
+
// status so they are not silently invisible to onResponse/logging. The
|
|
595
|
+
// status is computed ONLY when something observes it.
|
|
596
|
+
if (hasOnResponse || logger !== null) {
|
|
597
|
+
const status = out instanceof Response ? out.status : safeTerminalStatus(result)
|
|
598
|
+
if (hasOnResponse) runtime.onResponse?.(req, result, status, requestIdStr)
|
|
599
|
+
if (logger !== null) {
|
|
600
|
+
logger.request({
|
|
601
|
+
requestId: requestIdStr,
|
|
602
|
+
method: req.method,
|
|
603
|
+
path: pathForLog(req),
|
|
604
|
+
status,
|
|
605
|
+
durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
|
|
606
|
+
ip: ipStr,
|
|
607
|
+
})
|
|
608
|
+
}
|
|
537
609
|
}
|
|
538
610
|
return out
|
|
539
611
|
} 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
|
+
}
|
|
@@ -66,7 +66,11 @@ export interface NativeRoute {
|
|
|
66
66
|
runFrame(frame: Uint8Array): RouteWireResult
|
|
67
67
|
/** Convenience: pack a `(query, cookie, body)` frame then {@link runFrame}. */
|
|
68
68
|
run(query: string, cookie: string, body: Uint8Array | null): RouteWireResult
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Free the native handle. Idempotent: a second call is a no-op. After it,
|
|
71
|
+
* {@link runFrame}/{@link run} throw rather than pass a freed handle into the
|
|
72
|
+
* native stack.
|
|
73
|
+
*/
|
|
70
74
|
destroy(): void
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -109,16 +113,21 @@ export function createNativeRoute(plan: NativeRoutePlan = {}): NativeRoute {
|
|
|
109
113
|
const parseQuery = plan.parseQuery === true
|
|
110
114
|
const parseCookies = plan.parseCookies === true
|
|
111
115
|
|
|
112
|
-
// Transport: bun:ffi PRIMARY on Bun; napi `Route` on Node / fallback.
|
|
116
|
+
// Transport: bun:ffi PRIMARY on Bun; napi `Route` on Node / fallback. An FFI
|
|
117
|
+
// compile failure (invalid/unsupported descriptor) ALSO falls back to napi so
|
|
118
|
+
// the route stays usable instead of throwing "no active transport". The
|
|
113
119
|
// compiled route owns exactly one handle; `destroy` frees it.
|
|
114
120
|
const bunFFI: BunFFI | null = getBunFFI()
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
let ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
|
|
122
|
+
let napiRoute = bunFFI === null || ffiHandle === 0 ? compileNapi(descriptor) : null
|
|
117
123
|
|
|
118
124
|
// Reusable output buffer for the needed-size convention (grow once, retry).
|
|
119
125
|
let out = new Uint8Array(256)
|
|
120
126
|
|
|
121
127
|
const runFrame = (frame: Uint8Array): RouteWireResult => {
|
|
128
|
+
if (ffiHandle === 0 && napiRoute === null) {
|
|
129
|
+
throw new Error('native route: already destroyed')
|
|
130
|
+
}
|
|
122
131
|
let written: number
|
|
123
132
|
if (ffiHandle !== 0 && bunFFI !== null) {
|
|
124
133
|
written = bunFFI.routeRun(ffiHandle, frame, out)
|
|
@@ -153,7 +162,10 @@ export function createNativeRoute(plan: NativeRoutePlan = {}): NativeRoute {
|
|
|
153
162
|
destroy: () => {
|
|
154
163
|
if (ffiHandle !== 0 && bunFFI !== null) {
|
|
155
164
|
bunFFI.routeDestroy(ffiHandle)
|
|
165
|
+
ffiHandle = 0
|
|
156
166
|
}
|
|
167
|
+
napiRoute?.destroy?.()
|
|
168
|
+
napiRoute = null
|
|
157
169
|
},
|
|
158
170
|
}
|
|
159
171
|
}
|
|
@@ -170,10 +182,17 @@ function compileFfi(bunFFI: BunFFI, descriptor: Uint8Array): number {
|
|
|
170
182
|
/** Compile through the napi `Route` class (Node / fallback transport). */
|
|
171
183
|
function compileNapi(
|
|
172
184
|
descriptor: Uint8Array,
|
|
173
|
-
): { run: (frame: Uint8Array, out: Uint8Array) => number } | null {
|
|
185
|
+
): { run: (frame: Uint8Array, out: Uint8Array) => number; destroy?: () => void } | null {
|
|
174
186
|
const addon = getAddon()
|
|
175
187
|
const Route = (
|
|
176
|
-
addon as {
|
|
188
|
+
addon as {
|
|
189
|
+
Route?: new (
|
|
190
|
+
d: Uint8Array,
|
|
191
|
+
) => {
|
|
192
|
+
run: (f: Uint8Array, o: Uint8Array) => number
|
|
193
|
+
destroy?: () => void
|
|
194
|
+
}
|
|
195
|
+
}
|
|
177
196
|
).Route
|
|
178
197
|
if (typeof Route !== 'function') {
|
|
179
198
|
return null // addon without the route stack (pre-rebuild) — caller falls back
|
package/src/ingress/options.ts
CHANGED
|
@@ -184,6 +184,10 @@ let trustProxyWarned = false
|
|
|
184
184
|
* `X-Forwarded-For`/`X-Real-IP`, so a client can forge its IP and bypass
|
|
185
185
|
* IP-based rate limiting. Prefer the `trustedProxies` network-list API and
|
|
186
186
|
* only enable proxy trust behind a trusted edge.
|
|
187
|
+
*
|
|
188
|
+
* Precedence: when `trustedProxies` is also set it wins, so
|
|
189
|
+
* `{ trustProxy: true, trustedProxies: { enabled: false } }` trusts NOTHING —
|
|
190
|
+
* the safer mode, not the "trust everything" this warning describes.
|
|
187
191
|
*/
|
|
188
192
|
export function warnTrustProxyDeprecated(): void {
|
|
189
193
|
if (trustProxyWarned) return
|
|
@@ -191,7 +195,27 @@ export function warnTrustProxyDeprecated(): void {
|
|
|
191
195
|
console.warn(
|
|
192
196
|
'[castrum] WARN: `trustProxy: true` is deprecated and trusts EVERY hop — ' +
|
|
193
197
|
'clients can spoof X-Forwarded-For / X-Real-IP to bypass IP-based rate ' +
|
|
194
|
-
'limiting. Use `trustedProxies: { enabled: true, networks: [...] }`
|
|
195
|
-
'only enable proxy trust behind a trusted edge.',
|
|
198
|
+
'limiting. Use `trustedProxies: { enabled: true, networks: [...] }` ' +
|
|
199
|
+
'(which takes precedence) and only enable proxy trust behind a trusted edge.',
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let rateLimitNoIpWarned = false
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Warn once (per process) when rate limiting is enabled without a `getIp`
|
|
207
|
+
* resolver. The native pipeline then resolves an empty client IP, so EVERY
|
|
208
|
+
* client shares one bucket — one attacker can exhaust it and 429 everyone.
|
|
209
|
+
*
|
|
210
|
+
* A single global bucket is a legitimate choice, so this warns rather than
|
|
211
|
+
* fails; provide `getIp` for per-client isolation.
|
|
212
|
+
*/
|
|
213
|
+
export function warnRateLimitWithoutGetIp(): void {
|
|
214
|
+
if (rateLimitNoIpWarned) return
|
|
215
|
+
rateLimitNoIpWarned = true
|
|
216
|
+
console.warn(
|
|
217
|
+
'[castrum] WARN: rate limiting is enabled without `getIp` — every client ' +
|
|
218
|
+
'shares ONE bucket, so one client can exhaust it for everyone. Provide ' +
|
|
219
|
+
'`getIp` for per-client limits (or use a global limit deliberately).',
|
|
196
220
|
)
|
|
197
221
|
}
|
|
@@ -12,14 +12,6 @@ import { writeHeaderPair } from './header-packing'
|
|
|
12
12
|
import { getHeaderBuf, MAX_SMALL_HEADER_BYTES } from './scratch'
|
|
13
13
|
import { forEachSelectedHeader, HDR_ORIGIN } from './select-headers'
|
|
14
14
|
|
|
15
|
-
// Shared per-header size guards (single source of truth in scratch.ts).
|
|
16
|
-
// Re-exported for back-compat with imports that referenced them here.
|
|
17
|
-
export {
|
|
18
|
-
MAX_COOKIE_HEADER_BYTES,
|
|
19
|
-
MAX_SMALL_HEADER_BYTES,
|
|
20
|
-
MAX_XFF_HEADER_BYTES,
|
|
21
|
-
} from './scratch'
|
|
22
|
-
|
|
23
15
|
/**
|
|
24
16
|
* Gather the request headers selected by `plan` as a packed byte block
|
|
25
17
|
* (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
|
|
@@ -161,3 +153,27 @@ function cachedOriginBlock(origin: string): Uint8Array {
|
|
|
161
153
|
ORIGIN_BLOCK_CACHE.set(origin, block)
|
|
162
154
|
return block
|
|
163
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pre-warm the origin-packed-block cache for the configured exact
|
|
159
|
+
* `allow-origin` values (load-time-for-RPS trade): the FIRST request from each
|
|
160
|
+
* known origin then skips the shared encode + pack + scratch write entirely,
|
|
161
|
+
* like every later request does. Oversized values are skipped (they map to the
|
|
162
|
+
* EMPTY block anyway) and wildcard/pattern values are skipped (they never
|
|
163
|
+
* resolve to their own literal origin), while the module-level cache stays
|
|
164
|
+
* bounded by `ORIGIN_CACHE_MAX`. No-op for any plan that never consults it.
|
|
165
|
+
*/
|
|
166
|
+
export function prewarmOriginBlocks(origins: readonly string[]): void {
|
|
167
|
+
for (const origin of origins) {
|
|
168
|
+
if (
|
|
169
|
+
typeof origin === 'string' &&
|
|
170
|
+
origin.length > 0 &&
|
|
171
|
+
origin.length <= MAX_SMALL_HEADER_BYTES &&
|
|
172
|
+
// A wildcard/pattern never resolves to its own literal origin string, so
|
|
173
|
+
// caching it would only burn a slot (the handler filters these too).
|
|
174
|
+
!origin.includes('*')
|
|
175
|
+
) {
|
|
176
|
+
cachedOriginBlock(origin)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|