castrum 0.7.0 → 0.9.0
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 +322 -59
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +3161 -0
- package/dist/index.js +7306 -0
- package/docs/ARCHITECTURE.md +415 -0
- package/docs/BENCHMARKS.md +307 -0
- package/docs/ENVIRONMENT.md +79 -0
- package/docs/GETTING_STARTED.md +209 -0
- package/docs/INGRESS.md +745 -0
- package/docs/REPO_MAP.md +259 -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 +42 -0
- package/docs/bun-builtins-decision-matrix.md +108 -0
- package/index.ts +51 -8
- package/package.json +73 -20
- package/src/data/json-rows.ts +13 -13
- package/src/ingress/body.ts +149 -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 +563 -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 +67 -0
- package/src/ingress/index.ts +130 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +75 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +150 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +96 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/routes/common.ts +38 -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 +37 -0
- package/src/ingress/routes/index.ts +10 -0
- package/src/ingress/routes/json-write.ts +153 -0
- package/src/ingress/routes/options.ts +36 -0
- package/src/ingress/routes/read.ts +45 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +443 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +157 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +217 -0
- package/src/integration/streaming.ts +79 -0
- package/src/integration/websocket.ts +92 -0
- package/src/loader/batch.ts +133 -0
- package/src/loader/cost.ts +104 -0
- package/src/loader/index.ts +930 -0
- package/src/loader/ops.ts +657 -0
- package/src/native/ffi.ts +1541 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +194 -0
- package/src/native/types.ts +598 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch.ts +431 -0
- package/src/rust-ffi/client.ts +88 -0
- package/src/rust-ffi/context.ts +164 -0
- package/src/rust-ffi/index.ts +40 -541
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +42 -0
- package/src/rust-ffi/scalar/crypto.ts +265 -0
- package/src/rust-ffi/scalar/factories.ts +233 -0
- package/src/rust-ffi/scalar/hashing.ts +72 -0
- package/src/rust-ffi/scalar/http.ts +206 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +450 -0
- package/src/rust-ffi/scalar/json.ts +45 -0
- package/src/rust-ffi/scalar/payload.ts +124 -0
- package/src/rust-ffi/text.ts +95 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +77 -0
- package/src/shared/adaptive.ts +68 -0
- package/src/shared/bench-classify.ts +209 -0
- package/src/shared/buffer-pool.ts +209 -0
- package/src/shared/bytes.ts +35 -5
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +74 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed.ts +350 -154
- package/src/shared/proven.ts +596 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +34 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +31 -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/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
|
@@ -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,75 @@
|
|
|
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 { writeHeaderPair } from './header-packing'
|
|
11
|
+
import { forEachSelectedHeader } from './select-headers'
|
|
12
|
+
import { getHeaderBuf } from './scratch'
|
|
13
|
+
import { decoder } from '../../shared/bytes'
|
|
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 `[name, value][]` array.
|
|
25
|
+
* `methodKind` is the native method-kind enum (see `METHOD_KIND`).
|
|
26
|
+
*/
|
|
27
|
+
export function gatherRawHeaders(
|
|
28
|
+
req: Request,
|
|
29
|
+
plan: HeaderPlan,
|
|
30
|
+
methodKind: number,
|
|
31
|
+
): [string, string][] {
|
|
32
|
+
const headers: [string, string][] = []
|
|
33
|
+
forEachSelectedHeader(req, plan, methodKind, undefined, (name, value) => {
|
|
34
|
+
headers.push([decoder.decode(name), value])
|
|
35
|
+
})
|
|
36
|
+
return headers
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Gather the request headers selected by `plan` as a packed byte block
|
|
41
|
+
* (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
|
|
42
|
+
* reusable thread-local buffer.
|
|
43
|
+
*
|
|
44
|
+
* This is the zero-intermediate-string equivalent of [`gatherRawHeaders`] for
|
|
45
|
+
* the packed-input pipeline: identical header selection and per-header size
|
|
46
|
+
* guards, but no `[string, string][]` array and no napi string marshaling.
|
|
47
|
+
* The returned view is valid only until the next call on this call site.
|
|
48
|
+
*
|
|
49
|
+
* @param originValue - When provided (non-undefined), the already-fetched
|
|
50
|
+
* `Origin` header value to pack, avoiding a second `req.headers.get('origin')`
|
|
51
|
+
* native→JS string conversion per request on the hot path (the caller, e.g.
|
|
52
|
+
* `handlers.ts::run`, already fetched it once for `ctx.origin`). When
|
|
53
|
+
* omitted, the origin is fetched here as before (back-compat). The same
|
|
54
|
+
* `MAX_SMALL_HEADER_BYTES` size guard still applies either way.
|
|
55
|
+
*/
|
|
56
|
+
export function gatherRawHeadersPacked(
|
|
57
|
+
req: Request,
|
|
58
|
+
plan: HeaderPlan,
|
|
59
|
+
methodKind: number,
|
|
60
|
+
originValue?: string | null,
|
|
61
|
+
): Uint8Array {
|
|
62
|
+
let [buf, view] = getHeaderBuf()
|
|
63
|
+
let pos = 2
|
|
64
|
+
let count = 0
|
|
65
|
+
|
|
66
|
+
const write = (name: Uint8Array, value: string): void => {
|
|
67
|
+
;[pos, buf, view] = writeHeaderPair(buf, view, pos, name, value)
|
|
68
|
+
count++
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
forEachSelectedHeader(req, plan, methodKind, originValue, write)
|
|
72
|
+
|
|
73
|
+
view.setUint16(0, count, true)
|
|
74
|
+
return buf.subarray(0, pos)
|
|
75
|
+
}
|
|
@@ -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 { METHOD_KIND, METHOD_KIND_UNKNOWN, type HeaderPlan } from '../shared'
|
|
9
|
+
import { getHeaderBuf } from './scratch'
|
|
10
|
+
import { forEachSelectedHeader } from './select-headers'
|
|
11
|
+
|
|
12
|
+
const encoder = new TextEncoder()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Write one header pair into `buf`/`view`, growing them if needed.
|
|
16
|
+
*
|
|
17
|
+
* IMPORTANT: the header block can exceed `HEADER_BUF_SIZE` (e.g. a very large
|
|
18
|
+
* cookie or origin header). When that happens `buf`/`view` are replaced by a
|
|
19
|
+
* larger buffer, so the (possibly grown) buffer + view are returned alongside
|
|
20
|
+
* the new position. Callers MUST thread the returned buffer/view back into
|
|
21
|
+
* subsequent writes — otherwise the grown bytes are written into a discarded
|
|
22
|
+
* array and the packed output is silently corrupted.
|
|
23
|
+
*/
|
|
24
|
+
export function writeHeaderPair(
|
|
25
|
+
buf: Uint8Array,
|
|
26
|
+
view: DataView,
|
|
27
|
+
pos: number,
|
|
28
|
+
name: Uint8Array,
|
|
29
|
+
value: string,
|
|
30
|
+
): [pos: number, buf: Uint8Array, view: DataView] {
|
|
31
|
+
const needed = 2 + name.length + 4 + value.length * 3
|
|
32
|
+
if (pos + needed > buf.length) {
|
|
33
|
+
const next = new Uint8Array(Math.max(buf.length * 2, pos + needed))
|
|
34
|
+
next.set(buf.subarray(0, pos))
|
|
35
|
+
buf = next
|
|
36
|
+
view = new DataView(buf.buffer)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
view.setUint16(pos, name.length, true)
|
|
40
|
+
buf.set(name, pos + 2)
|
|
41
|
+
pos += 2 + name.length
|
|
42
|
+
|
|
43
|
+
const valueLenPos = pos
|
|
44
|
+
pos += 4
|
|
45
|
+
|
|
46
|
+
const dest = buf.subarray(pos)
|
|
47
|
+
const { written } = encoder.encodeInto(value, dest)
|
|
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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/ingress/packing/input-packer.ts — Binary-packed ingress input builder.
|
|
2
|
+
//
|
|
3
|
+
// Packs the per-request inputs (method kind + url + ip + request id + packed
|
|
4
|
+
// headers) into a single growable Uint8Array in the layout the native
|
|
5
|
+
// `Ingress.handleRequestPacked` expects.
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder()
|
|
8
|
+
const EMPTY_BYTES = new Uint8Array(0)
|
|
9
|
+
const EMPTY_IP_BYTES = encoder.encode('0.0.0.0')
|
|
10
|
+
|
|
11
|
+
/** Growable packer for the fast-path packed input buffer. */
|
|
12
|
+
export class IngressInputPacker {
|
|
13
|
+
private buf: Uint8Array
|
|
14
|
+
private view: DataView
|
|
15
|
+
private pos = 0
|
|
16
|
+
|
|
17
|
+
constructor(initialSize = 65536) {
|
|
18
|
+
this.buf = new Uint8Array(Math.max(1024, initialSize))
|
|
19
|
+
this.view = new DataView(this.buf.buffer)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private ensure(additional: number): void {
|
|
23
|
+
const needed = this.pos + additional
|
|
24
|
+
if (needed <= this.buf.byteLength) return
|
|
25
|
+
|
|
26
|
+
let nextSize = this.buf.byteLength * 2
|
|
27
|
+
while (nextSize < needed) {
|
|
28
|
+
nextSize *= 2
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const next = new Uint8Array(nextSize)
|
|
32
|
+
next.set(this.buf.subarray(0, this.pos))
|
|
33
|
+
|
|
34
|
+
this.buf = next
|
|
35
|
+
this.view = new DataView(next.buffer)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private writeU8(value: number): void {
|
|
39
|
+
this.ensure(1)
|
|
40
|
+
this.buf[this.pos++] = value & 0xff
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private writeLenPrefixed(bytes: Uint8Array): void {
|
|
44
|
+
this.ensure(4 + bytes.byteLength)
|
|
45
|
+
|
|
46
|
+
this.view.setUint32(this.pos, bytes.byteLength, true)
|
|
47
|
+
this.pos += 4
|
|
48
|
+
|
|
49
|
+
this.buf.set(bytes, this.pos)
|
|
50
|
+
this.pos += bytes.byteLength
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pack the request inputs; the returned view is valid until the next pack. */
|
|
54
|
+
pack(
|
|
55
|
+
methodKind: number,
|
|
56
|
+
urlBytes: Uint8Array,
|
|
57
|
+
ipBytes: Uint8Array,
|
|
58
|
+
requestIdBytes: Uint8Array,
|
|
59
|
+
headers: Uint8Array,
|
|
60
|
+
): Uint8Array {
|
|
61
|
+
this.pos = 0
|
|
62
|
+
|
|
63
|
+
this.writeU8(methodKind)
|
|
64
|
+
this.writeLenPrefixed(urlBytes)
|
|
65
|
+
this.writeLenPrefixed(ipBytes)
|
|
66
|
+
this.writeLenPrefixed(requestIdBytes)
|
|
67
|
+
this.writeLenPrefixed(headers)
|
|
68
|
+
|
|
69
|
+
return this.buf.subarray(0, this.pos)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Encode `value` directly into the buffer, length-prefixed (zero-copy). */
|
|
73
|
+
private writeStringLenPrefixed(value: string): void {
|
|
74
|
+
// 3 bytes per UTF-16 code unit is a safe upper bound (BMP non-ASCII);
|
|
75
|
+
// surrogate pairs encode to fewer bytes per code unit.
|
|
76
|
+
this.ensure(4 + value.length * 3)
|
|
77
|
+
const valueLenPos = this.pos
|
|
78
|
+
this.pos += 4
|
|
79
|
+
const dest = this.buf.subarray(this.pos)
|
|
80
|
+
const { written } = encoder.encodeInto(value, dest)
|
|
81
|
+
this.view.setUint32(valueLenPos, written, true)
|
|
82
|
+
this.pos += written
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pack the request inputs from raw strings, encoding url/ip/requestId
|
|
87
|
+
* directly into the internal buffer via `encodeInto` — three fewer
|
|
88
|
+
* intermediate `Uint8Array` allocations + copies per request vs [`pack`].
|
|
89
|
+
* The returned view is valid until the next pack.
|
|
90
|
+
*/
|
|
91
|
+
packFromStrings(
|
|
92
|
+
methodKind: number,
|
|
93
|
+
url: string,
|
|
94
|
+
ip: string | undefined,
|
|
95
|
+
requestId: string | undefined,
|
|
96
|
+
headers: Uint8Array,
|
|
97
|
+
): Uint8Array {
|
|
98
|
+
this.pos = 0
|
|
99
|
+
|
|
100
|
+
this.writeU8(methodKind)
|
|
101
|
+
this.writeStringLenPrefixed(url)
|
|
102
|
+
if (ip && ip.length > 0) {
|
|
103
|
+
this.writeStringLenPrefixed(ip)
|
|
104
|
+
} else {
|
|
105
|
+
this.writeLenPrefixed(EMPTY_IP_BYTES)
|
|
106
|
+
}
|
|
107
|
+
if (requestId) {
|
|
108
|
+
this.writeStringLenPrefixed(requestId)
|
|
109
|
+
} else {
|
|
110
|
+
this.writeLenPrefixed(EMPTY_BYTES)
|
|
111
|
+
}
|
|
112
|
+
this.writeLenPrefixed(headers)
|
|
113
|
+
|
|
114
|
+
return this.buf.subarray(0, this.pos)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pack the request inputs with a mixed strategy: `url`/`ip` are encoded
|
|
119
|
+
* directly from strings via `encodeInto` (no intermediate `Uint8Array`),
|
|
120
|
+
* while `requestId`/`headers` are already-encoded byte slices copied in
|
|
121
|
+
* verbatim.
|
|
122
|
+
*
|
|
123
|
+
* This removes the two `encoder.encode` allocations + copies per request
|
|
124
|
+
* that [`pack`] pays for URL/IP, while keeping the pre-encoded request-id
|
|
125
|
+
* bytes untouched (no decode-to-string → re-encode round trip, which
|
|
126
|
+
* `packFromStrings` would incur when a request id was already generated as
|
|
127
|
+
* bytes). The returned view is valid until the next pack.
|
|
128
|
+
*/
|
|
129
|
+
packParts(
|
|
130
|
+
methodKind: number,
|
|
131
|
+
url: string,
|
|
132
|
+
ip: string | undefined,
|
|
133
|
+
requestId: Uint8Array,
|
|
134
|
+
headers: Uint8Array,
|
|
135
|
+
): Uint8Array {
|
|
136
|
+
this.pos = 0
|
|
137
|
+
|
|
138
|
+
this.writeU8(methodKind)
|
|
139
|
+
this.writeStringLenPrefixed(url)
|
|
140
|
+
if (ip && ip.length > 0) {
|
|
141
|
+
this.writeStringLenPrefixed(ip)
|
|
142
|
+
} else {
|
|
143
|
+
this.writeLenPrefixed(EMPTY_IP_BYTES)
|
|
144
|
+
}
|
|
145
|
+
this.writeLenPrefixed(requestId)
|
|
146
|
+
this.writeLenPrefixed(headers)
|
|
147
|
+
|
|
148
|
+
return this.buf.subarray(0, this.pos)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/ingress/packing/scratch.ts — Shared thread-local scratch header buffers.
|
|
2
|
+
//
|
|
3
|
+
// Both the fast path (`packHeaders` in header-packing.ts) and the pre-baked
|
|
4
|
+
// path (`gatherRawHeadersPacked` in gather-raw-headers.ts) pack request
|
|
5
|
+
// headers into a reusable Uint8Array. This thread-local pool isolates the
|
|
6
|
+
// buffer per call-site sequence and bounds the number of cached buffers,
|
|
7
|
+
// keeping the hot path allocation-free.
|
|
8
|
+
|
|
9
|
+
/** Scratch capacity for the reusable packed-header buffer. */
|
|
10
|
+
export const HEADER_BUF_SIZE = 8192
|
|
11
|
+
|
|
12
|
+
// ── Per-header size guards (SHARED by BOTH packing paths) ─────────
|
|
13
|
+
// A header value larger than the bound below is dropped BEFORE packing rather
|
|
14
|
+
// than forwarded. Without guards the fast path (`packHeaders`) forwarded any
|
|
15
|
+
// size, so an oversized cookie/xff/origin could push the packed block past the
|
|
16
|
+
// native `max_headers_bytes` (65536) and fail with a 500, while the pre-baked
|
|
17
|
+
// path silently dropped the same header. Both paths must share ONE policy;
|
|
18
|
+
// keep the Rust `max_headers_bytes` bound in mind when changing these.
|
|
19
|
+
/** Upper bound for the `cookie` header value. */
|
|
20
|
+
export const MAX_COOKIE_HEADER_BYTES = 8192
|
|
21
|
+
/** Upper bound for small single-value headers (origin, ACRM, ACRH, ...). */
|
|
22
|
+
export const MAX_SMALL_HEADER_BYTES = 2048
|
|
23
|
+
/** Upper bound for the `x-forwarded-for` header value. */
|
|
24
|
+
export const MAX_XFF_HEADER_BYTES = 8192
|
|
25
|
+
|
|
26
|
+
// Thread-local header buffer for per-call isolation (round-robin over a
|
|
27
|
+
// bounded pool; each buffer is only borrowed for the duration of one
|
|
28
|
+
// pack/gather call).
|
|
29
|
+
const [getHeaderBuf] = (() => {
|
|
30
|
+
const tls: [Uint8Array, DataView][] = []
|
|
31
|
+
const MAX_CACHED = 256
|
|
32
|
+
let tlsIdx = 0
|
|
33
|
+
|
|
34
|
+
function acquire(): [Uint8Array, DataView] {
|
|
35
|
+
const cached = tls[tlsIdx]
|
|
36
|
+
tlsIdx = (tlsIdx + 1) % MAX_CACHED
|
|
37
|
+
if (cached) return cached
|
|
38
|
+
|
|
39
|
+
const buf = new Uint8Array(HEADER_BUF_SIZE)
|
|
40
|
+
const view = new DataView(buf.buffer)
|
|
41
|
+
const pair: [Uint8Array, DataView] = [buf, view]
|
|
42
|
+
tls.push(pair)
|
|
43
|
+
return pair
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [acquire]
|
|
47
|
+
})()
|
|
48
|
+
|
|
49
|
+
/** Acquire the next scratch header buffer + view from the thread-local pool. */
|
|
50
|
+
export { getHeaderBuf }
|