castrum 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -368
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +2763 -0
- package/dist/index.js +8707 -0
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +507 -0
- package/docs/BENCHMARKS.md +343 -0
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +81 -0
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +192 -0
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +746 -0
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +279 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +43 -0
- package/docs/bun-builtins-decision-matrix.md +115 -0
- package/index.ts +44 -8
- package/package.json +98 -33
- package/src/ingress/body.ts +289 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +639 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +68 -0
- package/src/ingress/index.ts +142 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +139 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +164 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +102 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +79 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +27 -0
- package/src/ingress/routes/index.ts +11 -0
- package/src/ingress/routes/json-write.ts +129 -0
- package/src/ingress/routes/options.ts +26 -0
- package/src/ingress/routes/read.ts +35 -0
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +475 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +191 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +220 -0
- package/src/integration/streaming.ts +78 -0
- package/src/integration/websocket.ts +93 -0
- package/src/loader/batch.ts +137 -0
- package/src/loader/cost.ts +105 -0
- package/src/loader/index.ts +745 -0
- package/src/loader/ops.ts +657 -0
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1775 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +240 -0
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +401 -0
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +87 -0
- package/src/rust-ffi/context.ts +193 -0
- package/src/rust-ffi/index.ts +51 -541
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +28 -0
- package/src/rust-ffi/scalar/crypto.ts +346 -0
- package/src/rust-ffi/scalar/factories.ts +459 -0
- package/src/rust-ffi/scalar/hashing.ts +74 -0
- package/src/rust-ffi/scalar/http.ts +337 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +325 -0
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +61 -0
- package/src/rust-ffi/scalar/payload.ts +241 -0
- package/src/rust-ffi/text.ts +121 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +73 -0
- package/src/shared/adaptive.ts +67 -0
- package/src/shared/buffer-pool.ts +264 -0
- package/src/shared/bytes.ts +108 -9
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +75 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/packed/wire.ts +504 -0
- package/src/shared/proven.ts +289 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +29 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +15 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/data/json-rows.ts +0 -33
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
- package/src/shared/packed.ts +0 -316
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// src/ingress/metrics.ts — Ingress observability wiring (zero-dep).
|
|
2
|
+
//
|
|
3
|
+
// `createIngressMetrics()` builds a {@link MetricsRegistry} and returns the
|
|
4
|
+
// `onRequest`/`onResponse`/`onError` hooks to pass into `createIngressHandler`'s
|
|
5
|
+
// runtime, so every request is counted + latency-histogrammed. Expose the
|
|
6
|
+
// numbers at a `/metrics` endpoint with {@link metricsHandler} (Prometheus text
|
|
7
|
+
// format), served by both `createIngressServer` (Bun) and
|
|
8
|
+
// `createIngressServerNode` (Node).
|
|
9
|
+
//
|
|
10
|
+
// const m = createIngressMetrics();
|
|
11
|
+
// const ingress = createIngressHandler(options, { ...m.runtime });
|
|
12
|
+
// const srv = createIngressServer({ routes: {
|
|
13
|
+
// "/health": { read: livenessHandler() },
|
|
14
|
+
// "/metrics": { read: metricsHandler(m) },
|
|
15
|
+
// }});
|
|
16
|
+
|
|
17
|
+
import { createMetrics, DEFAULT_BUCKETS, type MetricsRegistry } from '../shared/metrics'
|
|
18
|
+
import type { BakedIngressResult } from './decode/baked-result'
|
|
19
|
+
import type { BakedIngressRuntime } from './handlers'
|
|
20
|
+
|
|
21
|
+
/** The runtime hooks + registry for ingress metrics. */
|
|
22
|
+
export interface IngressMetrics {
|
|
23
|
+
registry: MetricsRegistry
|
|
24
|
+
/** Pass as the runtime to `createIngressHandler`. */
|
|
25
|
+
runtime: Pick<BakedIngressRuntime, 'onRequest' | 'onResponse' | 'onError'>
|
|
26
|
+
/** Render the Prometheus exposition. */
|
|
27
|
+
render(): string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build ingress request/error/latency metrics wired to the pre-baked handler
|
|
32
|
+
* runtime hooks. Metric names follow the Prometheus `castrum_http_*` scheme.
|
|
33
|
+
*/
|
|
34
|
+
export function createIngressMetrics(): IngressMetrics {
|
|
35
|
+
const registry = createMetrics()
|
|
36
|
+
|
|
37
|
+
const requestsTotal = registry.counter(
|
|
38
|
+
'castrum_http_requests_total',
|
|
39
|
+
'Total ingress requests processed.',
|
|
40
|
+
['method', 'status'],
|
|
41
|
+
)
|
|
42
|
+
const requestDuration = registry.histogram(
|
|
43
|
+
'castrum_http_request_duration_seconds',
|
|
44
|
+
'Ingress request latency in seconds.',
|
|
45
|
+
DEFAULT_BUCKETS,
|
|
46
|
+
['method', 'status'],
|
|
47
|
+
)
|
|
48
|
+
const errorsTotal = registry.counter(
|
|
49
|
+
'castrum_http_errors_total',
|
|
50
|
+
'Total internal/terminal errors.',
|
|
51
|
+
['code'],
|
|
52
|
+
)
|
|
53
|
+
const rateLimitedTotal = registry.counter(
|
|
54
|
+
'castrum_http_rate_limited_total',
|
|
55
|
+
'Total requests rejected by the rate limiter (429).',
|
|
56
|
+
)
|
|
57
|
+
const inFlight = registry.gauge(
|
|
58
|
+
'castrum_http_in_flight_requests',
|
|
59
|
+
'Requests currently being processed.',
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const statusLabel = (status: number): string => String(status)
|
|
63
|
+
|
|
64
|
+
// Per-request in-flight accounting keyed by the request id (also surfaced in
|
|
65
|
+
// logs/metrics). This makes the gauge EXACTLY-ONCE per request, which matters
|
|
66
|
+
// because the baked handler can fire both `onError` and `onResponse` for the
|
|
67
|
+
// same request (native pipeline failure), or only `onError` (a throwing route
|
|
68
|
+
// callback). Without it the gauge either double-decrements or leaks, and the
|
|
69
|
+
// latency histogram reads 0 (result.startTime was never set anywhere).
|
|
70
|
+
// NOTE: error-ended requests (onError fired) are counted via requestsTotal +
|
|
71
|
+
// errorsTotal but not latency-sampled — the duration histogram only observes
|
|
72
|
+
// requests that complete through onResponse.
|
|
73
|
+
const active = new Map<string, number>()
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
registry,
|
|
77
|
+
runtime: {
|
|
78
|
+
onRequest(_req, requestId, _ip) {
|
|
79
|
+
if (requestId) {
|
|
80
|
+
active.set(requestId, performance.now())
|
|
81
|
+
}
|
|
82
|
+
inFlight.inc()
|
|
83
|
+
},
|
|
84
|
+
onResponse(req, result: BakedIngressResult, status, requestId) {
|
|
85
|
+
const method = req.method ?? 'unknown'
|
|
86
|
+
const statusKey = statusLabel(status)
|
|
87
|
+
requestsTotal.inc({ method, status: statusKey })
|
|
88
|
+
// Only a request that has not already ended (via onError) records its
|
|
89
|
+
// latency and decrements the gauge here.
|
|
90
|
+
const started = requestId ? active.get(requestId) : undefined
|
|
91
|
+
if (started !== undefined) {
|
|
92
|
+
active.delete(requestId)
|
|
93
|
+
inFlight.dec()
|
|
94
|
+
requestDuration.observe(Math.max(performance.now() - started, 0) / 1000, {
|
|
95
|
+
method,
|
|
96
|
+
status: statusKey,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
if (result.rateLimited) rateLimitedTotal.inc()
|
|
100
|
+
},
|
|
101
|
+
onError(_req, requestId, error) {
|
|
102
|
+
// Ends the request's gauge accounting exactly once (a later onResponse
|
|
103
|
+
// for the same request id becomes a no-op for the gauge). When only
|
|
104
|
+
// onError fires (throwing callback), this is what un-leaks inFlight.
|
|
105
|
+
if (requestId && active.delete(requestId)) {
|
|
106
|
+
inFlight.dec()
|
|
107
|
+
}
|
|
108
|
+
errorsTotal.inc({ code: error?.name ?? 'internal' })
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
render() {
|
|
112
|
+
return registry.render()
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Route handler exposing the registry in Prometheus text format
|
|
119
|
+
* (`content-type: text/plain; version=0.0.4`). Serve it as a `read` route:
|
|
120
|
+
*
|
|
121
|
+
* createIngressServer({ routes: { "/metrics": { read: metricsHandler(m) } } })
|
|
122
|
+
*/
|
|
123
|
+
export function metricsHandler(metrics: IngressMetrics) {
|
|
124
|
+
return (_req: Request): Response =>
|
|
125
|
+
new Response(metrics.render(), {
|
|
126
|
+
status: 200,
|
|
127
|
+
headers: {
|
|
128
|
+
'content-type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
129
|
+
'cache-control': 'no-store',
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/ingress/options.ts — IngressFast option types + fail-fast validation.
|
|
2
|
+
//
|
|
3
|
+
// Options are forwarded to the native addon as a plain object; a misspelled
|
|
4
|
+
// key would otherwise be silently ignored. Validation against the known key
|
|
5
|
+
// set makes misconfiguration fail loudly at construction time.
|
|
6
|
+
|
|
7
|
+
/** Options accepted by `createIngressFast` / `createIngress`. */
|
|
8
|
+
export interface IngressFastOptions {
|
|
9
|
+
trustProxy?: boolean
|
|
10
|
+
trustedProxies?: { enabled?: boolean; networks?: string[] }
|
|
11
|
+
parseCookies?: boolean
|
|
12
|
+
parseQuery?: boolean
|
|
13
|
+
requireJsonBody?: boolean
|
|
14
|
+
schema?: Uint8Array
|
|
15
|
+
cors?: CorsOptions
|
|
16
|
+
rateLimit?: { limit?: number; windowMs?: number; maxEntries?: number }
|
|
17
|
+
/**
|
|
18
|
+
* Structured security-header options (CSP/HSTS) — honored by the fast/async
|
|
19
|
+
* paths (`createIngressFast` throws on it; `createIngress` applies it via
|
|
20
|
+
* `buildResponseContext`). The PRE-BAKED path (`createIngressHandler`)
|
|
21
|
+
* instead takes raw `securityHeaders: [name, value][]` pairs. The two paths
|
|
22
|
+
* intentionally use different option shapes (see docs/INGRESS.md).
|
|
23
|
+
*/
|
|
24
|
+
security?: SecurityHeadersOptions
|
|
25
|
+
https?: boolean
|
|
26
|
+
maxBodyBytes?: number
|
|
27
|
+
enableSecurityHeaders?: boolean
|
|
28
|
+
enableRequestIds?: boolean
|
|
29
|
+
enableBodySizeGuard?: boolean
|
|
30
|
+
emitMetadataJson?: boolean
|
|
31
|
+
readBody?: boolean
|
|
32
|
+
outputBufferSize?: number
|
|
33
|
+
/**
|
|
34
|
+
* Overall deadline (ms) for reading a request body in the async
|
|
35
|
+
* `createIngress` path. 0 disables. Default: `DEFAULT_BODY_TIMEOUT_MS` (30s).
|
|
36
|
+
* Ignored by the sync `createIngressFast` (it does not read bodies).
|
|
37
|
+
*/
|
|
38
|
+
bodyTimeoutMs?: number
|
|
39
|
+
/**
|
|
40
|
+
* Invoked when the native pipeline throws (the request becomes a 500).
|
|
41
|
+
* Native failures are otherwise silent in the fast path. Never throws.
|
|
42
|
+
*/
|
|
43
|
+
onError?: (error: Error) => void
|
|
44
|
+
/**
|
|
45
|
+
* When true, run one probe GET at construction so the `run()` closure,
|
|
46
|
+
* packed pipeline and FFI ingress call are JIT-warmed before the first real
|
|
47
|
+
* request (cuts cold-invocation tail latency in serverless-style
|
|
48
|
+
* deployments). Opt-in; default false. Not forwarded to the native addon.
|
|
49
|
+
*/
|
|
50
|
+
warmOnCreate?: boolean
|
|
51
|
+
limits?: {
|
|
52
|
+
maxUrlBytes?: number
|
|
53
|
+
maxQueryBytes?: number
|
|
54
|
+
maxCookieBytes?: number
|
|
55
|
+
maxHeadersBytes?: number
|
|
56
|
+
maxHeaders?: number
|
|
57
|
+
maxPairs?: number
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The zero-alloc sync handler returned by `createIngressFast`. */
|
|
62
|
+
export interface IngressFastHandler {
|
|
63
|
+
run<T>(
|
|
64
|
+
req: Request,
|
|
65
|
+
ip: string | undefined,
|
|
66
|
+
body: Uint8Array | null,
|
|
67
|
+
requestId: string,
|
|
68
|
+
fn: (result: FastIngressResult) => T,
|
|
69
|
+
): T
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Options accepted by `createIngressHandler` (path 2).
|
|
74
|
+
*
|
|
75
|
+
* Same native option surface as the fast path, minus `onError` — path 2
|
|
76
|
+
* reports native failures through `BakedIngressRuntime.onError` instead of the
|
|
77
|
+
* options bag.
|
|
78
|
+
*/
|
|
79
|
+
export type IngressHandlerOptions = Omit<IngressFastOptions, 'onError'>
|
|
80
|
+
|
|
81
|
+
import type { CorsOptions } from './headers/cors'
|
|
82
|
+
import type { SecurityHeadersOptions } from './headers/hsts'
|
|
83
|
+
import type { FastIngressResult } from './decode/fast-result'
|
|
84
|
+
|
|
85
|
+
const KNOWN_INGRESS_OPTION_KEYS: ReadonlySet<string> = new Set([
|
|
86
|
+
'trustProxy',
|
|
87
|
+
'trustedProxies',
|
|
88
|
+
'parseCookies',
|
|
89
|
+
'parseQuery',
|
|
90
|
+
'requireJsonBody',
|
|
91
|
+
'schema',
|
|
92
|
+
'cors',
|
|
93
|
+
'rateLimit',
|
|
94
|
+
'security',
|
|
95
|
+
'https',
|
|
96
|
+
'maxBodyBytes',
|
|
97
|
+
'enableSecurityHeaders',
|
|
98
|
+
'enableRequestIds',
|
|
99
|
+
'enableBodySizeGuard',
|
|
100
|
+
'emitMetadataJson',
|
|
101
|
+
'readBody',
|
|
102
|
+
'outputBufferSize',
|
|
103
|
+
'bodyTimeoutMs',
|
|
104
|
+
'onError',
|
|
105
|
+
'onRequest',
|
|
106
|
+
'onResponse',
|
|
107
|
+
'limits',
|
|
108
|
+
'warmOnCreate',
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Throw if `options` contains a key the ingress pipeline does not know.
|
|
113
|
+
*
|
|
114
|
+
* `label` names the calling factory in the error so a typo is easy to map
|
|
115
|
+
* back to the call site (both `createIngressFast` and `createIngressHandler`
|
|
116
|
+
* validate the same option surface).
|
|
117
|
+
*/
|
|
118
|
+
export function assertKnownIngressOptions(
|
|
119
|
+
options: IngressFastOptions,
|
|
120
|
+
label = 'createIngressFast',
|
|
121
|
+
): void {
|
|
122
|
+
for (const key of Object.keys(options)) {
|
|
123
|
+
if (!KNOWN_INGRESS_OPTION_KEYS.has(key)) {
|
|
124
|
+
throw new TypeError(
|
|
125
|
+
`${label}: unknown option '${key}'. ` +
|
|
126
|
+
`Known options: ${[...KNOWN_INGRESS_OPTION_KEYS].sort().join(', ')}`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Numeric options that must be finite and non-negative (0 is meaningful, e.g.
|
|
133
|
+
* `rateLimit.limit: 0` disables the limiter). Dotted = nested option. */
|
|
134
|
+
const NON_NEGATIVE_NUMBER_OPTIONS: ReadonlyArray<readonly [path: string, root: string]> = [
|
|
135
|
+
['maxBodyBytes', 'maxBodyBytes'],
|
|
136
|
+
['bodyTimeoutMs', 'bodyTimeoutMs'],
|
|
137
|
+
['outputBufferSize', 'outputBufferSize'],
|
|
138
|
+
['rateLimit.limit', 'rateLimit'],
|
|
139
|
+
['rateLimit.windowMs', 'rateLimit'],
|
|
140
|
+
['rateLimit.maxEntries', 'rateLimit'],
|
|
141
|
+
['limits.maxUrlBytes', 'limits'],
|
|
142
|
+
['limits.maxQueryBytes', 'limits'],
|
|
143
|
+
['limits.maxCookieBytes', 'limits'],
|
|
144
|
+
['limits.maxHeadersBytes', 'limits'],
|
|
145
|
+
['limits.maxHeaders', 'limits'],
|
|
146
|
+
['limits.maxPairs', 'limits'],
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
function assertNonNegativeNumber(value: unknown, path: string, label: string): void {
|
|
150
|
+
if (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) {
|
|
151
|
+
throw new TypeError(
|
|
152
|
+
`${label}: option '${path}' must be a finite, non-negative number (got ${value}).`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fail-fast on non-finite / negative numeric option values. `assertKnownIngressOptions`
|
|
159
|
+
* only checks the key set; a misconfigured negative value (e.g. `maxBodyBytes: -1`)
|
|
160
|
+
* would otherwise silently corrupt the pipeline (a napi marshal error or an
|
|
161
|
+
* always-rejecting limit).
|
|
162
|
+
*/
|
|
163
|
+
export function assertIngressOptionValues(
|
|
164
|
+
options: IngressFastOptions,
|
|
165
|
+
label = 'createIngressFast',
|
|
166
|
+
): void {
|
|
167
|
+
for (const [path, root] of NON_NEGATIVE_NUMBER_OPTIONS) {
|
|
168
|
+
if (path.includes('.')) {
|
|
169
|
+
const [, inner] = path.split('.')
|
|
170
|
+
const obj = (options as Record<string, unknown>)[root] as Record<string, unknown> | undefined
|
|
171
|
+
assertNonNegativeNumber(obj?.[inner ?? ''], path, label)
|
|
172
|
+
} else {
|
|
173
|
+
assertNonNegativeNumber((options as Record<string, unknown>)[path], path, label)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let trustProxyWarned = false
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Warn once (per process) when the legacy `trustProxy: true` boolean is used.
|
|
182
|
+
*
|
|
183
|
+
* `trustProxy: true` makes the pipeline trust EVERY hop of
|
|
184
|
+
* `X-Forwarded-For`/`X-Real-IP`, so a client can forge its IP and bypass
|
|
185
|
+
* IP-based rate limiting. Prefer the `trustedProxies` network-list API and
|
|
186
|
+
* only enable proxy trust behind a trusted edge.
|
|
187
|
+
*/
|
|
188
|
+
export function warnTrustProxyDeprecated(): void {
|
|
189
|
+
if (trustProxyWarned) return
|
|
190
|
+
trustProxyWarned = true
|
|
191
|
+
console.warn(
|
|
192
|
+
'[castrum] WARN: `trustProxy: true` is deprecated and trusts EVERY hop — ' +
|
|
193
|
+
'clients can spoof X-Forwarded-For / X-Real-IP to bypass IP-based rate ' +
|
|
194
|
+
'limiting. Use `trustedProxies: { enabled: true, networks: [...] }` and ' +
|
|
195
|
+
'only enable proxy trust behind a trusted edge.',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// src/ingress/packing/gather-raw-headers.ts — Raw header gathering (pre-baked
|
|
2
|
+
// path).
|
|
3
|
+
//
|
|
4
|
+
// The pre-baked path passes headers to the native addon as a plain
|
|
5
|
+
// [name, value][] array (the addon packs them internally in Rust). This module
|
|
6
|
+
// extracts exactly the headers selected by a HeaderPlan, with per-header size
|
|
7
|
+
// guards so oversized values are dropped rather than forwarded.
|
|
8
|
+
|
|
9
|
+
import type { HeaderPlan } from '../shared'
|
|
10
|
+
import { METHOD_KIND } from '../shared'
|
|
11
|
+
import { writeHeaderPair } from './header-packing'
|
|
12
|
+
import { forEachSelectedHeader, HDR_ORIGIN } from './select-headers'
|
|
13
|
+
import { getHeaderBuf, MAX_SMALL_HEADER_BYTES } from './scratch'
|
|
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
|
+
/**
|
|
24
|
+
* Gather the request headers selected by `plan` as a packed byte block
|
|
25
|
+
* (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
|
|
26
|
+
* reusable thread-local buffer.
|
|
27
|
+
*
|
|
28
|
+
* This is the zero-intermediate-string equivalent of the legacy string-array
|
|
29
|
+
* header gathering (a test-local reference in header-packing.test.ts) for the
|
|
30
|
+
* packed-input pipeline: identical header selection and per-header size
|
|
31
|
+
* guards, but no `[string, string][]` array and no napi string marshaling.
|
|
32
|
+
* The returned view is valid only until the next call on this call site.
|
|
33
|
+
*
|
|
34
|
+
* A plan that selects NO headers (no cookie/cors/proxy/proto) short-circuits
|
|
35
|
+
* to a shared empty block — skipping the scratch-buffer round-robin, the
|
|
36
|
+
* `setUint16` and the per-call `subarray` entirely (the common minimal-route
|
|
37
|
+
* case).
|
|
38
|
+
*
|
|
39
|
+
* A CORS-ONLY plan on a non-preflight request (the dominant minimal-route
|
|
40
|
+
* case, e.g. the benchmark server) produces a block that depends ONLY on the
|
|
41
|
+
* `Origin` header value — which is constant per deployment. Such blocks are
|
|
42
|
+
* cached keyed by origin, so the per-request UTF-8 encode of the origin (the
|
|
43
|
+
* largest single JS packing cost) and the scratch write are skipped entirely;
|
|
44
|
+
* the cache is bounded (FIFO eviction) so multi-origin deployments stay safe.
|
|
45
|
+
*
|
|
46
|
+
* @param originValue - When provided (non-undefined), the already-fetched
|
|
47
|
+
* `Origin` header value to pack, avoiding a second `req.headers.get('origin')`
|
|
48
|
+
* native→JS string conversion per request on the hot path (the caller, e.g.
|
|
49
|
+
* `handlers.ts::run`, already fetched it once for `ctx.origin`). When
|
|
50
|
+
* omitted, the origin is fetched here as before (back-compat). The same
|
|
51
|
+
* `MAX_SMALL_HEADER_BYTES` size guard still applies either way.
|
|
52
|
+
*/
|
|
53
|
+
export function gatherRawHeadersPacked(
|
|
54
|
+
req: Request,
|
|
55
|
+
plan: HeaderPlan,
|
|
56
|
+
methodKind: number,
|
|
57
|
+
originValue?: string | null,
|
|
58
|
+
): Uint8Array {
|
|
59
|
+
if (!plan.cookie && !plan.cors && !plan.proxy && !plan.proto) {
|
|
60
|
+
return EMPTY_HEADERS_BLOCK
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// CORS-only fast path: the only selected header is the (typically constant)
|
|
64
|
+
// Origin, so the whole packed block is a pure function of the origin string.
|
|
65
|
+
// Skip the per-request encode + scratch write by returning the cached block.
|
|
66
|
+
// Preflight (OPTIONS) is excluded — it also packs ACRM/ACRH and must take
|
|
67
|
+
// the general path. The MAX_SMALL_HEADER_BYTES guard mirrors the general
|
|
68
|
+
// path (an oversized origin is dropped → empty block, not cached).
|
|
69
|
+
if (plan.cors && !plan.cookie && !plan.proxy && !plan.proto && methodKind !== METHOD_KIND.OPTIONS) {
|
|
70
|
+
const origin = originValue !== undefined ? originValue : req.headers.get('origin')
|
|
71
|
+
if (origin === null || origin.length > MAX_SMALL_HEADER_BYTES) {
|
|
72
|
+
return EMPTY_HEADERS_BLOCK
|
|
73
|
+
}
|
|
74
|
+
return cachedOriginBlock(origin)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let [buf, view] = getHeaderBuf()
|
|
78
|
+
let pos = 2
|
|
79
|
+
let count = 0
|
|
80
|
+
|
|
81
|
+
const write = (name: Uint8Array, value: string): void => {
|
|
82
|
+
;[pos, buf, view] = writeHeaderPair(buf, view, pos, name, value)
|
|
83
|
+
count++
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
forEachSelectedHeader(req, plan, methodKind, originValue, write)
|
|
87
|
+
|
|
88
|
+
// No selected header was actually present (e.g. a route with cookie+cors in
|
|
89
|
+
// its plan but a request that sends neither) — return the shared empty block
|
|
90
|
+
// instead of touching the scratch buffer again (setUint16 + subarray) on the
|
|
91
|
+
// hot path.
|
|
92
|
+
if (count === 0) {
|
|
93
|
+
return EMPTY_HEADERS_BLOCK
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
view.setUint16(0, count, true)
|
|
97
|
+
return buf.subarray(0, pos)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Shared `[0, 0]` (zero headers) block — returned when a plan selects nothing.
|
|
101
|
+
* Callers treat the result as read-only (the packers copy it verbatim). */
|
|
102
|
+
const EMPTY_HEADERS_BLOCK = new Uint8Array(2)
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Cached packed blocks for the CORS-only case, keyed by origin string.
|
|
106
|
+
* Bounded (FIFO eviction) — a multi-origin deployment never grows it
|
|
107
|
+
* unboundedly, and a single-origin deployment hits a stable single entry.
|
|
108
|
+
* Module state is per-thread in Bun workers, so no cross-thread aliasing.
|
|
109
|
+
*/
|
|
110
|
+
const ORIGIN_BLOCK_CACHE = new Map<string, Uint8Array>()
|
|
111
|
+
const ORIGIN_CACHE_MAX = 8
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Return (building and caching on first use) the packed header block for a
|
|
115
|
+
* CORS-only plan whose only selected header is `origin`.
|
|
116
|
+
*
|
|
117
|
+
* The block is byte-identical to what the general path would produce
|
|
118
|
+
* (`[u16 1][u16 name_len]['origin'][u32 val_len][value]`), built once with the
|
|
119
|
+
* shared `writeHeaderPair` and copied out of the scratch buffer into a stable
|
|
120
|
+
* allocation — never aliased to the round-robin scratch, so it stays valid
|
|
121
|
+
* across subsequent gathers.
|
|
122
|
+
*/
|
|
123
|
+
function cachedOriginBlock(origin: string): Uint8Array {
|
|
124
|
+
const hit = ORIGIN_BLOCK_CACHE.get(origin)
|
|
125
|
+
if (hit !== undefined) return hit
|
|
126
|
+
|
|
127
|
+
let [buf, view] = getHeaderBuf()
|
|
128
|
+
let pos = 2
|
|
129
|
+
;[pos, buf, view] = writeHeaderPair(buf, view, pos, HDR_ORIGIN, origin)
|
|
130
|
+
view.setUint16(0, 1, true)
|
|
131
|
+
const block = buf.slice(0, pos)
|
|
132
|
+
|
|
133
|
+
if (ORIGIN_BLOCK_CACHE.size >= ORIGIN_CACHE_MAX) {
|
|
134
|
+
const firstKey = ORIGIN_BLOCK_CACHE.keys().next().value
|
|
135
|
+
if (firstKey !== undefined) ORIGIN_BLOCK_CACHE.delete(firstKey)
|
|
136
|
+
}
|
|
137
|
+
ORIGIN_BLOCK_CACHE.set(origin, block)
|
|
138
|
+
return block
|
|
139
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/ingress/packing/header-packing.ts — Binary header packing (fast path).
|
|
2
|
+
//
|
|
3
|
+
// Packs the request headers selected by a HeaderPlan directly into a reusable
|
|
4
|
+
// Uint8Array with no intermediate strings. A bounded thread-local pool of
|
|
5
|
+
// scratch buffers keeps this allocation-free on the hot path while isolating
|
|
6
|
+
// the buffers per call-site sequence.
|
|
7
|
+
|
|
8
|
+
import { encodeUtf8Into } from '../../shared/codec'
|
|
9
|
+
import { type HeaderPlan, METHOD_KIND, METHOD_KIND_UNKNOWN } from '../shared'
|
|
10
|
+
import { getHeaderBuf } from './scratch'
|
|
11
|
+
import { forEachSelectedHeader } from './select-headers'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write one header pair into `buf`/`view`, growing them if needed.
|
|
15
|
+
*
|
|
16
|
+
* IMPORTANT: the header block can exceed `HEADER_BUF_SIZE` (e.g. a very large
|
|
17
|
+
* cookie or origin header). When that happens `buf`/`view` are replaced by a
|
|
18
|
+
* larger buffer, so the (possibly grown) buffer + view are returned alongside
|
|
19
|
+
* the new position. Callers MUST thread the returned buffer/view back into
|
|
20
|
+
* subsequent writes — otherwise the grown bytes are written into a discarded
|
|
21
|
+
* array and the packed output is silently corrupted.
|
|
22
|
+
*/
|
|
23
|
+
export function writeHeaderPair(
|
|
24
|
+
buf: Uint8Array,
|
|
25
|
+
view: DataView,
|
|
26
|
+
pos: number,
|
|
27
|
+
name: Uint8Array,
|
|
28
|
+
value: string,
|
|
29
|
+
): [pos: number, buf: Uint8Array, view: DataView] {
|
|
30
|
+
const needed = 2 + name.length + 4 + value.length * 3
|
|
31
|
+
if (pos + needed > buf.length) {
|
|
32
|
+
const next = new Uint8Array(Math.max(buf.length * 2, pos + needed))
|
|
33
|
+
next.set(buf.subarray(0, pos))
|
|
34
|
+
buf = next
|
|
35
|
+
view = new DataView(buf.buffer)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
view.setUint16(pos, name.length, true)
|
|
39
|
+
buf.set(name, pos + 2)
|
|
40
|
+
pos += 2 + name.length
|
|
41
|
+
|
|
42
|
+
const valueLenPos = pos
|
|
43
|
+
pos += 4
|
|
44
|
+
|
|
45
|
+
// Number-returning codec write directly at `pos` (no `{read, written}`
|
|
46
|
+
// object, no intermediate `subarray` — both were per-header allocations).
|
|
47
|
+
const written = encodeUtf8Into(value, buf, pos)
|
|
48
|
+
|
|
49
|
+
view.setUint32(valueLenPos, written, true)
|
|
50
|
+
pos += written
|
|
51
|
+
|
|
52
|
+
return [pos, buf, view]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pack the headers selected by `plan` into a reusable Uint8Array. */
|
|
56
|
+
export function packHeaders(req: Request, plan: HeaderPlan): Uint8Array {
|
|
57
|
+
let [buf, view] = getHeaderBuf()
|
|
58
|
+
let pos = 2
|
|
59
|
+
let count = 0
|
|
60
|
+
|
|
61
|
+
// Selection rules + per-header size guards live in forEachSelectedHeader
|
|
62
|
+
// (single source of truth shared with the pre-baked path): an oversized
|
|
63
|
+
// value is dropped rather than forwarded so the packed block can never
|
|
64
|
+
// exceed the native `max_headers_bytes` (65536) and 500.
|
|
65
|
+
const write = (name: Uint8Array, value: string): void => {
|
|
66
|
+
;[pos, buf, view] = writeHeaderPair(buf, view, pos, name, value)
|
|
67
|
+
count++
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const methodKind = METHOD_KIND[req.method] ?? METHOD_KIND_UNKNOWN
|
|
71
|
+
forEachSelectedHeader(req, plan, methodKind, undefined, write)
|
|
72
|
+
|
|
73
|
+
view.setUint16(0, count, true)
|
|
74
|
+
return buf.subarray(0, pos)
|
|
75
|
+
}
|