castrum 0.9.7 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +664 -8
- package/dist/index.js +2014 -1327
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +8 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +6 -0
- package/package.json +4 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +55 -35
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +25 -5
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +2 -1
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +128 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +104 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +50 -3
- package/src/ingress/routes/common.ts +35 -11
- package/src/ingress/routes/echo.ts +60 -7
- package/src/ingress/routes/json-write.ts +32 -8
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +29 -9
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +135 -37
- package/src/ingress/server.ts +143 -36
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/integration/pipeline.ts +6 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi/types.ts +6 -1
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/native/types.ts +6 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/detect.ts +14 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +53 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +18 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +54 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/packed/parsers.ts +64 -7
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +81 -37
package/src/ingress/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ export {
|
|
|
22
22
|
serializeTraceParent,
|
|
23
23
|
} from '../shared/trace'
|
|
24
24
|
// ── Re-export the full public ingress API ─────────────────────────
|
|
25
|
+
// Request cancellation helpers (client-disconnect → 499).
|
|
26
|
+
export { ABORT_CODE, abortResponse, isAbortError } from './abort'
|
|
25
27
|
export * from './constants'
|
|
26
28
|
export * from './errors'
|
|
27
29
|
export { createIngressFast, FastIngressResult } from './fast'
|
|
@@ -30,7 +32,7 @@ export type { CorsOptions, CorsStaticStrings } from './headers/cors'
|
|
|
30
32
|
export type { HeaderTemplate, ResponseBuildContext } from './headers/fast-templates'
|
|
31
33
|
export { buildResponseContext, headersForResult } from './headers/fast-templates'
|
|
32
34
|
export type { SecurityHeadersOptions } from './headers/hsts'
|
|
33
|
-
export { healthHandler, livenessHandler, readinessHandler } from './health'
|
|
35
|
+
export { healthHandler, livenessHandler, readinessHandler, staticLiveness } from './health'
|
|
34
36
|
export type { IngressMetrics } from './metrics'
|
|
35
37
|
// ── Observability (zero-dep): metrics, health probes ─────────────
|
|
36
38
|
export { createIngressMetrics, metricsHandler } from './metrics'
|
|
@@ -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
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
HV_RATE_LIMITED,
|
|
25
25
|
} from '../constants'
|
|
26
26
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
27
|
+
import { memoizedHeaders } from '../headers/memoized-headers'
|
|
27
28
|
import { secondsFromMs } from '../shared'
|
|
28
29
|
import { safeTerminalStatus } from '../status'
|
|
29
30
|
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
@@ -31,6 +32,14 @@ import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './error-bodies
|
|
|
31
32
|
|
|
32
33
|
const EMPTY_BODY = new Uint8Array(0)
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Upper bound on the per-`(variant, origin)` memoized header arrays. Under
|
|
37
|
+
* wildcard CORS EVERY Origin is allowed, so this cache is attacker-keyed —
|
|
38
|
+
* without a cap a stream of distinct Origins grows it without bound (remote
|
|
39
|
+
* memory exhaustion). Evicts the oldest entry (Map preserves insertion order).
|
|
40
|
+
*/
|
|
41
|
+
export const ORIGIN_HEADER_CACHE_MAX = 64
|
|
42
|
+
|
|
34
43
|
/** Shared per-request state between `run()` (handlers.ts) and the builders. */
|
|
35
44
|
export interface BakedResponseState {
|
|
36
45
|
/** The pooled output buffer backing the current `run()` (null outside one). */
|
|
@@ -55,6 +64,33 @@ export interface BakedResponseBuildersDeps {
|
|
|
55
64
|
state: BakedResponseState
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Pre-warm the per-origin header-array cache AND the memoized `Headers`
|
|
69
|
+
* `WeakMap` for the configured exact allow-origins at the success `variant`.
|
|
70
|
+
*
|
|
71
|
+
* `variant` MUST be the one the success path actually returns
|
|
72
|
+
* (`HV_JSON | HV_CORS_SIMPLE`, plus `HV_RATE_ACTIVE` when rate limiting is
|
|
73
|
+
* enabled). Warming a different variant populates an unreachable cache entry
|
|
74
|
+
* and buys nothing.
|
|
75
|
+
*
|
|
76
|
+
* @param responseHeaders The handler's header builder (from this module).
|
|
77
|
+
* @param origins Exact, concrete allow-origins (wildcards excluded by caller).
|
|
78
|
+
* @param variant The success header variant to warm.
|
|
79
|
+
*/
|
|
80
|
+
export function prewarmOriginHeaders(
|
|
81
|
+
responseHeaders: (
|
|
82
|
+
variant: number,
|
|
83
|
+
requestIdHeader: string | null,
|
|
84
|
+
origin: string | null,
|
|
85
|
+
) => [string, string][],
|
|
86
|
+
origins: readonly string[],
|
|
87
|
+
variant: number,
|
|
88
|
+
): void {
|
|
89
|
+
for (const origin of origins) {
|
|
90
|
+
memoizedHeaders(responseHeaders(variant, null, origin))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
/**
|
|
59
95
|
* Build the seven response-builder methods of `OptimizedIngressHandler`
|
|
60
96
|
* (everything except `run`), bound to this handler's header templates and
|
|
@@ -73,6 +109,72 @@ export function buildBakedResponseBuilders(
|
|
|
73
109
|
state,
|
|
74
110
|
} = deps
|
|
75
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The cacheable base entries for a `(variant, origin)`: the baked template
|
|
114
|
+
* plus the reflected `access-control-allow-origin`. Memoized per origin so
|
|
115
|
+
* the Origin-present case (the common browser/bench case) stays
|
|
116
|
+
* allocation-free after the first hit for EACH distinct origin (a single-slot
|
|
117
|
+
* memo thrashed when clients alternated between allowed origins). Bounded by
|
|
118
|
+
* `ORIGIN_HEADER_CACHE_MAX`.
|
|
119
|
+
*/
|
|
120
|
+
function originBaseEntries(variant: number, origin: string): [string, string][] {
|
|
121
|
+
const key = `${variant & 31}\u0000${origin}`
|
|
122
|
+
const cached = originHeaderCache.get(key)
|
|
123
|
+
if (cached !== undefined) {
|
|
124
|
+
return cached
|
|
125
|
+
}
|
|
126
|
+
const template = headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
127
|
+
const entries = new Array<[string, string]>(template.length + 1)
|
|
128
|
+
let i = 0
|
|
129
|
+
for (const pair of template) {
|
|
130
|
+
entries[i++] = pair
|
|
131
|
+
}
|
|
132
|
+
entries[i] = ['access-control-allow-origin', origin]
|
|
133
|
+
if (originHeaderCache.size >= ORIGIN_HEADER_CACHE_MAX) {
|
|
134
|
+
const oldest = originHeaderCache.keys().next().value
|
|
135
|
+
if (oldest !== undefined) originHeaderCache.delete(oldest)
|
|
136
|
+
}
|
|
137
|
+
originHeaderCache.set(key, entries)
|
|
138
|
+
return entries
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Success-path headers as a `Headers` instance. Uses the memoized base
|
|
143
|
+
* (template + reflected origin) so the memoized-`Headers` fast path is kept
|
|
144
|
+
* even when per-request rate / request-id extras are present — a fresh array
|
|
145
|
+
* there would force a `new Headers` on every response. Extras are applied to
|
|
146
|
+
* a CLONE of the base, so the memoized instance is never mutated.
|
|
147
|
+
*/
|
|
148
|
+
function successHeaders(
|
|
149
|
+
variant: number,
|
|
150
|
+
requestIdHeader: string | null,
|
|
151
|
+
origin: string | null,
|
|
152
|
+
rateRemaining?: number,
|
|
153
|
+
rateResetSecs?: number,
|
|
154
|
+
): Headers {
|
|
155
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
156
|
+
const needsOrigin =
|
|
157
|
+
((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
158
|
+
const needsRate = (variant & HV_RATE_ACTIVE) !== 0
|
|
159
|
+
|
|
160
|
+
const base = needsOrigin
|
|
161
|
+
? memoizedHeaders(originBaseEntries(variant, origin as string))
|
|
162
|
+
: memoizedHeaders(headerTemplates[variant & 31] ?? headerTemplates[0] ?? [])
|
|
163
|
+
|
|
164
|
+
if (!needsRequestId && !needsRate) {
|
|
165
|
+
return base
|
|
166
|
+
}
|
|
167
|
+
const headers = new Headers(base)
|
|
168
|
+
if (needsRequestId) {
|
|
169
|
+
headers.set('x-request-id', requestIdHeader as string)
|
|
170
|
+
}
|
|
171
|
+
if (needsRate) {
|
|
172
|
+
headers.set('ratelimit-remaining', String(rateRemaining ?? 0))
|
|
173
|
+
headers.set('ratelimit-reset', String(rateResetSecs ?? 0))
|
|
174
|
+
}
|
|
175
|
+
return headers
|
|
176
|
+
}
|
|
177
|
+
|
|
76
178
|
function responseHeaders(
|
|
77
179
|
variant: number,
|
|
78
180
|
requestIdHeader: string | null,
|
|
@@ -97,24 +199,7 @@ export function buildBakedResponseBuilders(
|
|
|
97
199
|
}
|
|
98
200
|
|
|
99
201
|
if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
|
|
100
|
-
|
|
101
|
-
// per (variant, origin) so the Origin-present case (the common
|
|
102
|
-
// browser/bench case) stays allocation-free after the first hit for EACH
|
|
103
|
-
// distinct origin (a single-slot memo thrashed when clients alternate
|
|
104
|
-
// between allowed origins, allocating a fresh array on every switch).
|
|
105
|
-
const key = `${variant & 31}\u0000${origin as string}`
|
|
106
|
-
const cached = originHeaderCache.get(key)
|
|
107
|
-
if (cached !== undefined) {
|
|
108
|
-
return cached
|
|
109
|
-
}
|
|
110
|
-
const entries = new Array<[string, string]>(template.length + 1)
|
|
111
|
-
let i = 0
|
|
112
|
-
for (const pair of template) {
|
|
113
|
-
entries[i++] = pair
|
|
114
|
-
}
|
|
115
|
-
entries[i] = ['access-control-allow-origin', origin as string]
|
|
116
|
-
originHeaderCache.set(key, entries)
|
|
117
|
-
return entries
|
|
202
|
+
return originBaseEntries(variant, origin as string)
|
|
118
203
|
}
|
|
119
204
|
|
|
120
205
|
let extra = 0
|
|
@@ -210,13 +295,15 @@ export function buildBakedResponseBuilders(
|
|
|
210
295
|
if (preflightAllowed) {
|
|
211
296
|
return new Response(null, {
|
|
212
297
|
status: 204,
|
|
213
|
-
headers:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
298
|
+
headers: memoizedHeaders(
|
|
299
|
+
responseHeaders(
|
|
300
|
+
result.headerVariant,
|
|
301
|
+
ctx.requestIdHeader,
|
|
302
|
+
ctx.origin,
|
|
303
|
+
result.rateRemaining,
|
|
304
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
305
|
+
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
306
|
+
),
|
|
220
307
|
),
|
|
221
308
|
})
|
|
222
309
|
}
|
|
@@ -230,7 +317,7 @@ export function buildBakedResponseBuilders(
|
|
|
230
317
|
|
|
231
318
|
return new Response(body, {
|
|
232
319
|
status,
|
|
233
|
-
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
320
|
+
headers: memoizedHeaders(terminalHeaders(result.headerVariant, ctx, result)),
|
|
234
321
|
})
|
|
235
322
|
}
|
|
236
323
|
|
|
@@ -247,14 +334,16 @@ export function buildBakedResponseBuilders(
|
|
|
247
334
|
|
|
248
335
|
return new Response(body, {
|
|
249
336
|
status,
|
|
250
|
-
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
337
|
+
headers: memoizedHeaders(terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result)),
|
|
251
338
|
})
|
|
252
339
|
}
|
|
253
340
|
|
|
254
341
|
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
255
342
|
return new Response(ERROR_BODIES.internal, {
|
|
256
343
|
status: 500,
|
|
257
|
-
headers:
|
|
344
|
+
headers: memoizedHeaders(
|
|
345
|
+
terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
346
|
+
),
|
|
258
347
|
})
|
|
259
348
|
}
|
|
260
349
|
|
|
@@ -292,6 +381,7 @@ export function buildBakedResponseBuilders(
|
|
|
292
381
|
|
|
293
382
|
return {
|
|
294
383
|
responseHeaders,
|
|
384
|
+
successHeaders,
|
|
295
385
|
terminalHeaders,
|
|
296
386
|
terminalResponse,
|
|
297
387
|
errorResponse,
|
package/src/ingress/router.ts
CHANGED
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
// the deleted dead wire).
|
|
23
23
|
|
|
24
24
|
import type { BakedIngressResult } from './decode/baked-result'
|
|
25
|
-
import { type BakedIngressRuntime, createIngressHandler } from './handlers'
|
|
25
|
+
import { type BakedIngressRuntime, createIngressHandler, RATE_LIMIT_U32_MAX } from './handlers'
|
|
26
26
|
import type { NativeRoutePlan } from './native-route'
|
|
27
|
-
import type
|
|
27
|
+
import { type IngressHandlerOptions, warnRateLimitWithoutGetIp } from './options'
|
|
28
28
|
import type { PathMatch } from './path-matcher'
|
|
29
29
|
import { buildPathMatcher } from './path-matcher'
|
|
30
|
-
import type { BakedRoute, RouteHandler } from './server'
|
|
30
|
+
import type { BakedRoute, RouteHandler, StaticRoute } from './server'
|
|
31
31
|
import { buildRouteHandlers } from './server'
|
|
32
32
|
import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
33
33
|
|
|
@@ -88,6 +88,20 @@ export interface RouterRouteSpec {
|
|
|
88
88
|
/** Terminal envelope style for this route's responder (default: router-level
|
|
89
89
|
* `terminalStyle` or `'castrum'`). */
|
|
90
90
|
terminalStyle?: TerminalStyle
|
|
91
|
+
/**
|
|
92
|
+
* A prebuilt constant response served directly for GET, OUTSIDE the ingress
|
|
93
|
+
* pipeline (static route promotion). Like `raw`, it OWNS THE WHOLE SPEC: the
|
|
94
|
+
* router compiles no `IngressInner` for a `static` route, so `read`/`write`/
|
|
95
|
+
* `echo`/etc. on the same spec are ignored.
|
|
96
|
+
*
|
|
97
|
+
* This differs from `BakedRoute.static` in src/ingress/server.ts, where
|
|
98
|
+
* `static` only takes GET precedence and leaves `write`/`echo`/`delete`
|
|
99
|
+
* wired — the router cannot mirror that because a `static`-only spec has no
|
|
100
|
+
* compiled ingress to hand those methods. Do not unify the two semantics.
|
|
101
|
+
*
|
|
102
|
+
* See `BakedRoute.static` for the one-shot-body contract.
|
|
103
|
+
*/
|
|
104
|
+
static?: StaticRoute
|
|
91
105
|
/**
|
|
92
106
|
* A raw request→Response handler served directly for GET, OUTSIDE the
|
|
93
107
|
* ingress pipeline (health/metrics probes, /metrics). When set, `read`/
|
|
@@ -175,6 +189,17 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
|
|
|
175
189
|
if (options.copyBody !== undefined) baseOpts.copyBody = options.copyBody
|
|
176
190
|
|
|
177
191
|
for (const [path, spec] of Object.entries(options.routes)) {
|
|
192
|
+
if (spec.static !== undefined) {
|
|
193
|
+
// Static promotion: a prebuilt constant response owns the route. Placed
|
|
194
|
+
// verbatim so Bun.serve can serve it from its native table (a bare
|
|
195
|
+
// Response) with zero JS.
|
|
196
|
+
bakedRoutes[path] = {
|
|
197
|
+
static: spec.static,
|
|
198
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
199
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
200
|
+
}
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
178
203
|
if (spec.raw) {
|
|
179
204
|
// Raw handler: served directly, outside the pipeline.
|
|
180
205
|
bakedRoutes[path] = { read: spec.raw as RouteHandler }
|
|
@@ -199,6 +224,18 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
|
|
|
199
224
|
const handler = createIngressHandler(spec.options ?? {}, runtime)
|
|
200
225
|
compiled[path] = handler
|
|
201
226
|
|
|
227
|
+
// Rate limiting without a getIp resolver collapses every client into ONE
|
|
228
|
+
// bucket; warn once (a global bucket can be deliberate, so don't fail).
|
|
229
|
+
const routeLimit = (spec.options?.rateLimit as { limit?: number } | undefined)?.limit
|
|
230
|
+
if (
|
|
231
|
+
typeof routeLimit === 'number' &&
|
|
232
|
+
routeLimit > 0 &&
|
|
233
|
+
routeLimit !== RATE_LIMIT_U32_MAX &&
|
|
234
|
+
options.getIp === undefined
|
|
235
|
+
) {
|
|
236
|
+
warnRateLimitWithoutGetIp()
|
|
237
|
+
}
|
|
238
|
+
|
|
202
239
|
if (spec.responder) {
|
|
203
240
|
// Responder route: native decides + rejects; JS builds the 2xx.
|
|
204
241
|
bakedRoutes[path] = {
|
|
@@ -265,6 +302,16 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
|
|
|
265
302
|
if (typeof methodHandler === 'function') {
|
|
266
303
|
return (methodHandler as RouteHandler)(req, srv, matched.params)
|
|
267
304
|
}
|
|
305
|
+
if (methodHandler instanceof Response) {
|
|
306
|
+
// Static route (bare Response value). `instanceof Response` assumes the
|
|
307
|
+
// value was built with the global `Response` constructor (the one this
|
|
308
|
+
// router also uses). `clone()` tees the body without consuming the
|
|
309
|
+
// original, so every request gets a fresh, complete body. A `static`
|
|
310
|
+
// factory is a function and is handled above. The cast bridges Bun's
|
|
311
|
+
// `Response.clone()` return type (which resolves against the bundled
|
|
312
|
+
// fetch types) to this module's global `Response`.
|
|
313
|
+
return methodHandler.clone() as unknown as Response
|
|
314
|
+
}
|
|
268
315
|
// A route matched but this method isn't wired — 405, or a bare OPTIONS 204
|
|
269
316
|
// when the route answers preflights.
|
|
270
317
|
if (req.method === 'OPTIONS' && typeof matched.methods.OPTIONS === 'function') {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// src/ingress/routes/common.ts — Shared route-handler options + helpers.
|
|
2
2
|
|
|
3
|
+
import { abortResponse } from '../abort'
|
|
3
4
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
+
import { memoizedHeaders } from '../headers/memoized-headers'
|
|
4
6
|
import { secondsFromMs } from '../shared'
|
|
5
7
|
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
6
8
|
|
|
@@ -52,6 +54,10 @@ export function resolveIp(
|
|
|
52
54
|
* every pre-baked success path. A plain function call, so the hot path keeps
|
|
53
55
|
* its single init-object allocation and nothing else.
|
|
54
56
|
*
|
|
57
|
+
* The header array is wrapped in a memoized `Headers` (see
|
|
58
|
+
* `headers/memoized-headers.ts`) — identical bytes on the wire, ~4x cheaper
|
|
59
|
+
* Response construction for the recurring header sets.
|
|
60
|
+
*
|
|
55
61
|
* @param status Success status; defaults to 200.
|
|
56
62
|
*/
|
|
57
63
|
export function buildSuccessInit(
|
|
@@ -60,16 +66,27 @@ export function buildSuccessInit(
|
|
|
60
66
|
ctx: BakedContext,
|
|
61
67
|
status = 200,
|
|
62
68
|
): ResponseInit {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
const rateResetSecs = result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
|
|
70
|
+
// Prefer the `Headers`-producing path (keeps the memoized base even with
|
|
71
|
+
// per-request extras); mocks that only implement `responseHeaders` fall back.
|
|
72
|
+
const headers = ingress.successHeaders
|
|
73
|
+
? ingress.successHeaders(
|
|
74
|
+
result.headerVariant,
|
|
75
|
+
ctx.requestIdHeader,
|
|
76
|
+
ctx.origin,
|
|
77
|
+
result.rateRemaining,
|
|
78
|
+
rateResetSecs,
|
|
79
|
+
)
|
|
80
|
+
: memoizedHeaders(
|
|
81
|
+
ingress.responseHeaders(
|
|
82
|
+
result.headerVariant,
|
|
83
|
+
ctx.requestIdHeader,
|
|
84
|
+
ctx.origin,
|
|
85
|
+
result.rateRemaining,
|
|
86
|
+
rateResetSecs,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
return { status, headers }
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
/**
|
|
@@ -77,11 +94,18 @@ export function buildSuccessInit(
|
|
|
77
94
|
* runner: resolve the client IP once and dispatch through the native pipeline
|
|
78
95
|
* (`ingress.run` with a null body). Used by the read / head / fallback route
|
|
79
96
|
* factories whose responder never needs the request object.
|
|
97
|
+
*
|
|
98
|
+
* A disconnected client (`req.signal?.aborted`) short-circuits to the 499
|
|
99
|
+
* cancelled response BEFORE `ingress.run`, so no pooled output buffer is
|
|
100
|
+
* acquired for a request that can no longer be delivered.
|
|
80
101
|
*/
|
|
81
102
|
export function runBaked(
|
|
82
103
|
ingress: OptimizedIngressHandler,
|
|
83
104
|
opts: BakedHandlerOptions,
|
|
84
105
|
respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
|
|
85
106
|
): (req: Request, srv?: unknown) => Response {
|
|
86
|
-
return (req, srv) =>
|
|
107
|
+
return (req, srv) => {
|
|
108
|
+
if (req.signal?.aborted) return abortResponse()
|
|
109
|
+
return ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
|
|
110
|
+
}
|
|
87
111
|
}
|