castrum 0.9.8 → 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 +164 -4
- package/dist/index.js +434 -70
- package/docs/ENVIRONMENT.md +7 -0
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +5 -0
- package/package.json +2 -1
- package/rust/ffi/task.rs +19 -18
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +4 -2
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/handlers.ts +1 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/router.ts +36 -1
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/echo.ts +10 -0
- package/src/ingress/routes/json-write.ts +11 -0
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +25 -5
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +102 -23
- package/src/ingress/server.ts +140 -32
- package/src/integration/pipeline.ts +6 -0
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/types.ts +6 -1
- package/src/native/types.ts +6 -0
- package/src/runtime/detect.ts +14 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/scalar/factories.ts +6 -0
- package/src/rust-ffi/scalar/interface.ts +14 -0
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +10 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +52 -9
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
# Elysia → castrum adoption plan (performance, security, stability)
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
|
4
|
+
> `superpowers:subagent-driven-development` (recommended) or
|
|
5
|
+
> `superpowers:executing-plans` to implement this plan task-by-task. Steps use
|
|
6
|
+
> checkbox (`- [ ]`) syntax for tracking.
|
|
7
|
+
|
|
8
|
+
**Goal:** Adopt the transferable ideas from Elysia v2 (`~/poc/elysia`) that
|
|
9
|
+
close real gaps in castrum — a native-first Rust/Bun HTTP engine — without
|
|
10
|
+
touching the two ingress wire formats or the hot-path FFI surface.
|
|
11
|
+
|
|
12
|
+
**Architecture:** castrum is already *ahead* of Elysia on the data plane
|
|
13
|
+
(native packed parsing, pooled/zero-copy output, Bun native `routes`). Elysia's
|
|
14
|
+
value is in three places castrum under-invests: (1) **fault containment and
|
|
15
|
+
information-leak control** around escaped user code, (2) **bounded, flushable
|
|
16
|
+
process-wide caches** and cancellation, and (3) **precomputed static responses**
|
|
17
|
+
that never enter JS. This plan ports those three, plus the testing methodology
|
|
18
|
+
that proves them.
|
|
19
|
+
|
|
20
|
+
**Tech Stack:** TypeScript (Bun-first, Node fallback), Rust cdylib via napi-rs +
|
|
21
|
+
`bun:ffi`, Biome, `bun test`, `cargo test`, Bun.serve.
|
|
22
|
+
|
|
23
|
+
**Spec:** this document. Design source: read-only audit of `~/poc/elysia`
|
|
24
|
+
(v2.0.0-beta.16) and `~/poc/castrum` (v0.9.8 working tree). Every claim about
|
|
25
|
+
either repo is cited `file:line`. Items I could not verify from source are
|
|
26
|
+
marked `[verify]`.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Global Constraints
|
|
31
|
+
|
|
32
|
+
Copied from `AGENTS.md` / `RULES.md`; every task includes them implicitly.
|
|
33
|
+
|
|
34
|
+
- **Do NOT change the ingress wire formats** (`docs/adr/0001-two-wire-formats.md`):
|
|
35
|
+
success/error body shapes and `ratelimit-*` / `x-ratelimit-*` header names are
|
|
36
|
+
frozen. New error responses must reuse an existing shape.
|
|
37
|
+
- **Do NOT change or remove hot-path APIs** (AGENTS.md "Do NOT"); no signature
|
|
38
|
+
churn on `handleRequestPacked`, `handleRequestFullSync*`, scalar `rust.*`.
|
|
39
|
+
- **One numeric layout source**: `rust/ingress/output.rs` → `src/ingress/constants.ts`.
|
|
40
|
+
- **Runtime seam**: `typeof Bun` lives only in `src/runtime/detect.ts`; env reads
|
|
41
|
+
go through `src/shared/env.ts` (`resolveEnvVar`).
|
|
42
|
+
- **Bun-first**: never slow the Bun path for Node; Node falls back, never diverges
|
|
43
|
+
in observable HTTP behavior.
|
|
44
|
+
- **Purity boundary**: pure modules must not import `src/native/` or
|
|
45
|
+
`src/shared/buffer-pool.ts` (AGENTS.md "Purity & side-effect policy").
|
|
46
|
+
- **Every new `castrum_*` export** routes fallible cores through `panic_guard`,
|
|
47
|
+
is self-tested at bind time, is added to the dlopen map + `ffi/types.ts` + the
|
|
48
|
+
parity test, and uses `u64_fast` for byte counts
|
|
49
|
+
(`docs/FFI_BUN_GUIDE.md`, `docs/HOW_TO_ADD_AN_OP.md`).
|
|
50
|
+
- **New public exports** need JSDoc (`bun run check:jsdoc`), and `rust.*` ops need
|
|
51
|
+
a `PROVEN_SELECTION` + `selection.json` entry (`test/unit/contract/proven.test.ts`).
|
|
52
|
+
- **Green before start**: `bun run typecheck && bun run lint && bun run check:clean`
|
|
53
|
+
and `cargo test`. Record `bun --version` and the test counts.
|
|
54
|
+
- Baseline note: an existing defect-fix plan lives at
|
|
55
|
+
`docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md`. That plan is
|
|
56
|
+
**not** duplicated here; where an item overlaps it is referenced, not repeated.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Context — what Elysia does that makes it powerful
|
|
61
|
+
|
|
62
|
+
Elysia is a JS-first framework whose speed comes from moving work **off the
|
|
63
|
+
request path** and its safety from **bounded state + conservative fallback**.
|
|
64
|
+
The table maps each mechanism to castrum's current state.
|
|
65
|
+
|
|
66
|
+
| Elysia mechanism (file:line) | What it buys | castrum today | Verdict |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| AOT build plugin emits per-route JS, stubs out JIT/TypeBox/sucrose (`src/plugin/aot/core.ts:473-627`, `src/compile/aot.ts`) | No runtime codegen; small bundle; fast cold start | Routes compile once at startup (`src/ingress/server.ts:237-339`); native engine, no runtime codegen | **Already have** (different form) |
|
|
69
|
+
| Static routes promoted to `Bun.serve` native table so Bun serves them without JS (`src/adapter/bun/index.ts:184-306`, `compile/handler/index.ts:354-428`) | Removes JS from the request path for static responses | Every route runs the ingress pipeline via JS handler (`src/ingress/routes/read.ts:19-34`) | **Adopt** (C1) |
|
|
70
|
+
| Response ownership / `skipClone` to avoid `Response.clone()` (`src/adapter/skip-clone.ts`, `response-ownership.ts:1-10`) | Avoids double-buffering and tee bookkeeping | castrum never clones; pools output buffers (`src/shared/response.ts`, `handlers.ts:501`) | **Already have** |
|
|
71
|
+
| `parse-query.ts` single-pass scanner with schema-derived channels (`src/parse-query.ts:19-183`) | Allocation-light URL parsing | Rust zero-alloc packed parsers (`rust/http/query_parser.rs`) | **Already have (stronger)** |
|
|
72
|
+
| Validator cache dedupes identical schemas, 1-slot fast path, GC timer (`src/type/validator/validator-cache.ts:263-489`) | N routes with 1 schema compile once | Compiles per instance/route, no cross-route cache (`rust/ingress/api.rs:81-95`, `router.ts:177-200`) | **Adopt** (C2) |
|
|
73
|
+
| Immutable default-headers sentinel; skip `Headers` materialization (`src/adapter/default-headers.ts:1`, `adapter/utils.ts:15-20`) | Fewer allocations per response | Memoized `Headers` + origin cache, no clone when no extras (`baked-response.ts:148-176`, `routes/common.ts:62-89`) | **Already have** |
|
|
74
|
+
| Bounded caches + generational eviction + `flushMemory()` + `Bun.gc()` (`src/memory.ts:7-14`, `src/utils.ts:42-53`) | Stops slow heap growth; explicit maintenance | Strong pool bounds, but **no global flush API**, unbounded TS metrics/MIME/task queue | **Adopt** (B2/B3) |
|
|
75
|
+
| Production error masking, owned-vs-foreign errors, double-fault containment (`src/error.ts:981-1006`, `handler/error.ts:144-153`, `handler/fetch.ts:84-117`) | 5xx never leaks internals; error hook can't crash server | Bun path has **no server-level trap**; escaped errors hit Bun default (`server.ts:347-401`); `onError` is Node-only (`server.ts:115-120`) | **Adopt** (A1) |
|
|
76
|
+
| Prototype-pollution defenses: `dangerousKeys`, null-prototype objects, `assignOwn` (`src/utils.ts:498-504`, `adapter/web-standard/utils.ts:133`, `parse-query.ts:25`) | Attacker-controlled keys stay inert | `nativeResponderRoute` does `JSON.parse` into plain objects (`routes/responder.ts:71-81`); audit needed | **Adopt** (A2) |
|
|
77
|
+
| Request `AbortSignal` checked between lifecycle stages (`handler/fetch.ts:263-287`) | Cancels work when client disconnects | No `Request.signal` use on the HTTP path | **Adopt** (B1) |
|
|
78
|
+
| WebSocket in-flight cap 256 / backpressure / per-message error isolation / drain (`src/ws/route.ts:1126,1213-1220`, `:212-285`) | Bounds memory, isolates errors, clean shutdown | No WS server runtime; handshake helper only (`src/integration/websocket.ts:49-93`) | **Adopt separately** (D1) |
|
|
79
|
+
| Differential/metamorphic lane harness byte-compares JIT/precompile/socket/AOT (`test/differential/lanes.ts:217-239`) | Catches lane divergence | proptest + pool-parity, no cross-transport lane harness | **Adopt** (B4) |
|
|
80
|
+
| D1 proof-baseline: noise floors + injected-regression self-test (`bench/d1/run.ts:1395-1569`, `README.md`) | Performance claims are statistically gated | ad-hoc benches (`bench/http/run-bench.ts`) | **Adopt later** (D3) |
|
|
81
|
+
| Cookie signing with rotation/name-binding/constant-time (`src/cookie/crypto.ts:145-227`) | Safe signed cookies | Primitives exist, not wired into ingress (`rust/crypto/cookie_sign.rs`) | **Adopt separately** (D2) |
|
|
82
|
+
|
|
83
|
+
**Explicitly NOT ported** (and why): AOT `new Function` codegen (castrum is
|
|
84
|
+
native — no runtime JS codegen to eliminate); Memoirist trie matching (Bun's
|
|
85
|
+
native router already matches; Node keeps `path-matcher.ts`); response clone
|
|
86
|
+
protocol (castrum never clones); `parse-query` JS scanner (native parser is
|
|
87
|
+
faster); TypeBox lazy-JIT threshold (native schema compiles once, eagerly — the
|
|
88
|
+
right choice for latency); sucrose source analysis (no JS handler analysis
|
|
89
|
+
needed).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Phase A — Security / fault containment
|
|
94
|
+
|
|
95
|
+
### Task 1 (A1): Server-level error trap for escaped handlers (Bun + Node parity)
|
|
96
|
+
|
|
97
|
+
**Why:** `nativeResponderRoute` (`src/ingress/routes/responder.ts:157`) and
|
|
98
|
+
`nativeRouteHandler` (`src/ingress/routes/native.ts:129`) call the user
|
|
99
|
+
`responder(snapshot)` with **no try/catch**, and a raw `spec.read` function is
|
|
100
|
+
wired straight into Bun (`src/ingress/server.ts:261`) and Node
|
|
101
|
+
(`server-node.ts:207-211`). A sync throw or async rejection therefore escapes:
|
|
102
|
+
on Node it hits the adapter catch-all (`server-node.ts:220-243`, generic 500);
|
|
103
|
+
on Bun it hits **Bun's default handler**, which is not castrum-controlled and
|
|
104
|
+
may surface stack/internal detail — exactly the class of leak Elysia prevents
|
|
105
|
+
with `src/handler/error.ts:144-153`.
|
|
106
|
+
|
|
107
|
+
**Files:**
|
|
108
|
+
- Create: `src/ingress/server-error.ts`
|
|
109
|
+
- Modify: `src/ingress/server.ts` (`BuildRouteHandlersOptions` + the three escape
|
|
110
|
+
sites at `:257-261`, `:290-301`, `:303-319`; forward `onError`)
|
|
111
|
+
- Modify: `src/ingress/server-node.ts` (reuse the shared error handler)
|
|
112
|
+
- Test: `test/unit/ingress/server-error.test.ts`
|
|
113
|
+
|
|
114
|
+
**Interfaces:**
|
|
115
|
+
- Produces:
|
|
116
|
+
- `createServerErrorHandler(opts: ServerErrorOptions): (error: unknown, req?: Request) => Response`
|
|
117
|
+
- `ServerErrorOptions = { onError?: (info: { error: Error; request?: Request }) => void; logger?: (line: string) => void }`
|
|
118
|
+
- `guardRouteHandler(handler: RouteHandler, onServerError: (e: unknown, req?: Request) => Response): RouteHandler`
|
|
119
|
+
- `internalErrorBody` = the existing `ERROR_BODIES.internal` bytes (reuse; do
|
|
120
|
+
not define a new wire shape).
|
|
121
|
+
- Consumes: `ERROR_BODIES.internal` (`src/ingress/response/error-bodies.ts:46`),
|
|
122
|
+
`memoizedHeaders` (`src/ingress/headers/memoized-headers.ts`).
|
|
123
|
+
|
|
124
|
+
- [ ] **Step 1: Write the failing tests**
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// test/unit/ingress/server-error.test.ts
|
|
128
|
+
import { expect, test } from 'bun:test'
|
|
129
|
+
import { createServerErrorHandler, guardRouteHandler } from '../../../src/ingress/server-error'
|
|
130
|
+
|
|
131
|
+
test('sync throw becomes a generic 500 with no thrown text', async () => {
|
|
132
|
+
const handler = guardRouteHandler(
|
|
133
|
+
() => { throw new Error('secret-db-password') },
|
|
134
|
+
createServerErrorHandler({}),
|
|
135
|
+
)
|
|
136
|
+
const res = await handler(new Request('http://x/'))
|
|
137
|
+
expect(res.status).toBe(500)
|
|
138
|
+
const body = await res.text()
|
|
139
|
+
expect(body).toBe('{"ok":false,"error":{"code":"internal_error","message":"Internal server error"}}')
|
|
140
|
+
expect(body).not.toContain('secret-db-password')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('async rejection becomes a generic 500', async () => {
|
|
144
|
+
const handler = guardRouteHandler(
|
|
145
|
+
async () => { throw new Error('secret-2') },
|
|
146
|
+
createServerErrorHandler({}),
|
|
147
|
+
)
|
|
148
|
+
const res = await handler(new Request('http://x/'))
|
|
149
|
+
expect(res.status).toBe(500)
|
|
150
|
+
expect(await res.text()).not.toContain('secret-2')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('onError sees the real error, runs once, and cannot break the response', async () => {
|
|
154
|
+
let calls = 0
|
|
155
|
+
const onServerError = createServerErrorHandler({
|
|
156
|
+
onError: (info) => { calls++; expect(info.error.message).toBe('boom'); throw new Error('hook-broke') },
|
|
157
|
+
})
|
|
158
|
+
const handler = guardRouteHandler(() => { throw new Error('boom') }, onServerError)
|
|
159
|
+
const res = await handler(new Request('http://x/'))
|
|
160
|
+
expect(res.status).toBe(500)
|
|
161
|
+
expect(calls).toBe(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('success path is unaffected (no wrapping overhead semantics change)', async () => {
|
|
165
|
+
const handler = guardRouteHandler(() => new Response('ok'), createServerErrorHandler({}))
|
|
166
|
+
const res = await handler(new Request('http://x/'))
|
|
167
|
+
expect(res.status).toBe(200)
|
|
168
|
+
expect(await res.text()).toBe('ok')
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 2: Run to confirm failure**
|
|
173
|
+
|
|
174
|
+
Run: `bun test test/unit/ingress/server-error.test.ts`
|
|
175
|
+
Expected: FAIL — module `src/ingress/server-error.ts` does not exist.
|
|
176
|
+
|
|
177
|
+
- [ ] **Step 3: Implement `src/ingress/server-error.ts`**
|
|
178
|
+
|
|
179
|
+
Reuse the pre-encoded internal body; **never** put `error.message`/stack in the
|
|
180
|
+
response (wire format + info-leak rule). The guard must handle both sync and
|
|
181
|
+
async returns:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// src/ingress/server-error.ts — request-handler fault containment.
|
|
185
|
+
import { ERROR_BODIES } from './response/error-bodies'
|
|
186
|
+
import { memoizedHeaders } from './headers/memoized-headers'
|
|
187
|
+
import type { RouteHandler } from './server'
|
|
188
|
+
|
|
189
|
+
export interface ServerErrorOptions {
|
|
190
|
+
onError?: (info: { error: Error; request?: Request }) => void
|
|
191
|
+
logger?: (line: string) => void
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function toError(value: unknown): Error {
|
|
195
|
+
return value instanceof Error ? value : new Error(String(value))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Build a masked-500 responder. Never throws; the onError hook is contained. */
|
|
199
|
+
export function createServerErrorHandler(
|
|
200
|
+
opts: ServerErrorOptions,
|
|
201
|
+
): (error: unknown, req?: Request) => Response {
|
|
202
|
+
const headers = memoizedHeaders([['content-type', 'application/json; charset=utf-8']])
|
|
203
|
+
return (error, req) => {
|
|
204
|
+
const err = toError(error)
|
|
205
|
+
if (opts.onError) {
|
|
206
|
+
try { opts.onError({ error: err, request: req }) } catch { /* swallowed by design */ }
|
|
207
|
+
}
|
|
208
|
+
if (opts.logger) {
|
|
209
|
+
try { opts.logger(JSON.stringify({ level: 'error', msg: 'unhandled_handler_error', error: err.message })) } catch {}
|
|
210
|
+
}
|
|
211
|
+
return new Response(ERROR_BODIES.internal, { status: 500, headers })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Wrap a route handler so sync throws and async rejections are contained. */
|
|
216
|
+
export function guardRouteHandler(
|
|
217
|
+
handler: RouteHandler,
|
|
218
|
+
onServerError: (error: unknown, req?: Request) => Response,
|
|
219
|
+
): RouteHandler {
|
|
220
|
+
return (req, srv, params) => {
|
|
221
|
+
try {
|
|
222
|
+
const out = handler(req, srv, params)
|
|
223
|
+
return out instanceof Promise ? out.catch((e) => onServerError(e, req)) : out
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return onServerError(e, req)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- [ ] **Step 4: Wire it into `server.ts`**
|
|
232
|
+
|
|
233
|
+
Add `onError` and `logger` to `BuildRouteHandlersOptions` (`server.ts:226-229`),
|
|
234
|
+
build `const onServerError = createServerErrorHandler({ onError, logger })`, and
|
|
235
|
+
apply `guardRouteHandler` **only to the escape-capable handlers**: raw
|
|
236
|
+
`spec.read` (line 261), the `responder` route (line 292-300) and the `native`
|
|
237
|
+
route (line 307-318). Built-in factories (`readHandler`, `jsonWriteHandler`,
|
|
238
|
+
`echoHandler`, `deleteHandler`, `optionsHandler`, `fallbackHandler`) already
|
|
239
|
+
contain their own error handling — do **not** wrap them (keeps the hot path
|
|
240
|
+
unchanged). Then pass `onError`/`logger` from `createIngressServer` into
|
|
241
|
+
`buildRouteHandlers` (currently omitted, `server.ts:369`).
|
|
242
|
+
|
|
243
|
+
- [ ] **Step 5: Route Node through the same handler**
|
|
244
|
+
|
|
245
|
+
In `server-node.ts`, replace the inline catch-all body construction
|
|
246
|
+
(`:220-243`) with `createServerErrorHandler({ onError })` so both runtimes emit
|
|
247
|
+
the same generic 500 and invoke the same hook. (The adapter still must
|
|
248
|
+
`res.destroy()` when headers were already sent; keep that branch.)
|
|
249
|
+
|
|
250
|
+
- [ ] **Step 6: Verify + commit**
|
|
251
|
+
|
|
252
|
+
Run:
|
|
253
|
+
```bash
|
|
254
|
+
bun test test/unit/ingress/server-error.test.ts
|
|
255
|
+
bun run typecheck && bun run lint && bun run check:jsdoc
|
|
256
|
+
bun run bench:http:smoke
|
|
257
|
+
git add src/ingress/server-error.ts src/ingress/server.ts src/ingress/server-node.ts test/unit/ingress/server-error.test.ts
|
|
258
|
+
git commit -m "feat(ingress): contain escaped handler errors with a masked 500 on Bun and Node"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
### Task 2 (A2): Prototype-pollution audit + guards on JS object materialization
|
|
264
|
+
|
|
265
|
+
**Why:** Elysia treats `__proto__`/`constructor`/`prototype` as inert data and
|
|
266
|
+
builds null-prototype containers (`src/parse-query.ts:25`,
|
|
267
|
+
`adapter/web-standard/utils.ts:133`, `utils.ts:498-504`). castrum's native
|
|
268
|
+
parsers return packed pairs, but JS turns them into plain objects in the
|
|
269
|
+
responder bridge (`routes/responder.ts:71-81` `JSON.parse` → plain object;
|
|
270
|
+
`:134-142` casts to `Record`). A query/cookie/body key of `__proto__` must not
|
|
271
|
+
mutate an object prototype or reach a responder that then merges it into config.
|
|
272
|
+
|
|
273
|
+
**Files:**
|
|
274
|
+
- Modify: `src/ingress/routes/responder.ts` (`parseSection`, snapshot assembly)
|
|
275
|
+
- Modify: `src/ingress/routes/native.ts` (`pairsToRecord`) and audit every
|
|
276
|
+
`Object.fromEntries`/`JSON.parse` on request-derived data
|
|
277
|
+
- Modify: any `pairsToRecord` helper it calls
|
|
278
|
+
- Test: `test/unit/ingress/prototype-pollution.test.ts`
|
|
279
|
+
|
|
280
|
+
**Interfaces:**
|
|
281
|
+
- Produces: `safeRecord(): Record<string, unknown>` (null-prototype) and
|
|
282
|
+
`assignOwn(target, key, value)` used by all request-derived object builders.
|
|
283
|
+
`__proto__`/`constructor`/`prototype` are assigned as **own** data (or skipped)
|
|
284
|
+
but never as accessors on the prototype chain.
|
|
285
|
+
|
|
286
|
+
- [ ] **Step 1: Write failing tests**
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
// test/unit/ingress/prototype-pollution.test.ts
|
|
290
|
+
import { expect, test } from 'bun:test'
|
|
291
|
+
import { parseQueryPairsSafe } from '../../../src/ingress/routes/responder' // export for test
|
|
292
|
+
|
|
293
|
+
test('__proto__ query key is inert own data', () => {
|
|
294
|
+
const rec = parseQueryPairsSafe('__proto__=polluted&a=1')
|
|
295
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
296
|
+
expect(rec.a).toBe('1')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('constructor key does not overwrite the constructor', () => {
|
|
300
|
+
const rec = parseQueryPairsSafe('constructor=evil')
|
|
301
|
+
expect(typeof rec.constructor).not.toBe('string' === 'x' ? 'x' : 'function')
|
|
302
|
+
})
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
- [ ] **Step 2: Run — FAIL (helper absent).** `bun test test/unit/ingress/prototype-pollution.test.ts`
|
|
306
|
+
|
|
307
|
+
- [ ] **Step 3: Implement + audit**
|
|
308
|
+
|
|
309
|
+
Use `Object.create(null)` for the record root and assign via
|
|
310
|
+
`Object.defineProperty(rec, key, { value, enumerable: true, configurable: true, writable: true })`
|
|
311
|
+
(or skip `dangerousKeys`), so a `__proto__` key becomes own data. Grep
|
|
312
|
+
`Object.fromEntries`, `JSON.parse`, and object literals fed by
|
|
313
|
+
`req.headers`/`parseSection`/`pairsToRecord` and route them through the helper.
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 4: Verify + commit**
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
bun test test/unit/ingress/prototype-pollution.test.ts
|
|
319
|
+
bun run typecheck && bun run lint
|
|
320
|
+
git add -A src/ingress/routes test/unit/ingress/prototype-pollution.test.ts
|
|
321
|
+
git commit -m "fix(ingress): make request-derived object keys prototype-safe"
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
### Task 3 (A3): ReDoS / resource-limit regression test for schema `pattern`
|
|
327
|
+
|
|
328
|
+
**Why:** Elysia pins a linear-time `${t.Numeric}` check against a 200 KB attack
|
|
329
|
+
because a naive numeric regex can backtrack catastrophically
|
|
330
|
+
(`test/regression/security.test.ts:6-27`). castrum's `fast_schema` evaluates
|
|
331
|
+
**schema-author-supplied** `pattern`/`patternProperties` via `fancy-regex`
|
|
332
|
+
(`AGENTS.md` "Ingress schema (fast path)") against **attacker-controlled**
|
|
333
|
+
input. A pathological pattern could burn CPU. This is a resource-exhaustion
|
|
334
|
+
class defect; the test encodes intent even if the fix is "document the trust
|
|
335
|
+
boundary + cap input".
|
|
336
|
+
|
|
337
|
+
**Files:**
|
|
338
|
+
- Test: `test/unit/ingress/regex-dos.test.ts`
|
|
339
|
+
- Docs: `SECURITY.md` ("Schema `pattern` is trusted author input") if no code
|
|
340
|
+
change is warranted.
|
|
341
|
+
|
|
342
|
+
- [ ] **Step 1: Write the test** — build an `IngressSchema` with a
|
|
343
|
+
backtracking-prone `pattern` (e.g. `^(a+)+$`) and validate a 50 KB
|
|
344
|
+
non-matching string under a wall-clock bound; assert it completes under a
|
|
345
|
+
budget (e.g. 50 ms) **or** that the schema was rejected/marked unsupported.
|
|
346
|
+
- [ ] **Step 2: Run.**
|
|
347
|
+
- [ ] **Step 3: Decide the fix from evidence**: if it backtracks, add a schema
|
|
348
|
+
compile-time pattern-size/`fancy-regex` backtrack limit (or route the pattern
|
|
349
|
+
to the `jsonschema` fallback which has its own limits) and add a compile-time
|
|
350
|
+
guard. If it is already linear, document the trust boundary in `SECURITY.md`
|
|
351
|
+
and keep the test as a regression pin.
|
|
352
|
+
- [ ] **Step 4: Verify + commit.**
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Phase B — Stability / reliability
|
|
357
|
+
|
|
358
|
+
### Task 4 (B1): Plumb `Request.signal` cancellation through the request path
|
|
359
|
+
|
|
360
|
+
**Why:** Elysia checks `context['~sig'].aborted` between lifecycle stages and
|
|
361
|
+
returns early (`handler/fetch.ts:263-287`), so a disconnected client stops work.
|
|
362
|
+
castrum never reads `Request.signal`; a slow body read or a long pipeline keeps
|
|
363
|
+
running after the client is gone, and pooled buffers stay held.
|
|
364
|
+
|
|
365
|
+
**Files:**
|
|
366
|
+
- Modify: `src/ingress/body.ts` (`readBodyWithLimit`, `:139-268`) — race the read
|
|
367
|
+
against `req.signal` and reject with a cancellation error; remove the abort
|
|
368
|
+
listener on settle.
|
|
369
|
+
- Modify: `src/ingress/handlers.ts` (`run()`, `:456-650`) — check
|
|
370
|
+
`req.signal?.aborted` before the native call and after it, releasing the pooled
|
|
371
|
+
handle on the abort path exactly like the existing early-throw path
|
|
372
|
+
(`:622-630`).
|
|
373
|
+
- Modify: `src/ingress/routes/responder.ts` / `native.ts` — treat cancellation
|
|
374
|
+
as no response (or a 499-style no-op) rather than a 500.
|
|
375
|
+
- Test: `test/unit/ingress/abort.test.ts`
|
|
376
|
+
|
|
377
|
+
**Interfaces:**
|
|
378
|
+
- Produces: `isAbortError(err): boolean`; body read rejects with
|
|
379
|
+
`{ code: 'REQUEST_ABORTED' }`; `run()` returns/throws for an aborted signal.
|
|
380
|
+
|
|
381
|
+
- [ ] **Step 1: Write failing tests** — an `AbortController` aborted before and
|
|
382
|
+
during a streamed body read; assert the read rejects promptly and the pooled
|
|
383
|
+
buffer is released (reuse `test/property/pool-parity.test.ts` assertions).
|
|
384
|
+
- [ ] **Step 2: Run — FAIL.**
|
|
385
|
+
- [ ] **Step 3: Implement** the listener race in `body.ts` + the two
|
|
386
|
+
`req.signal.aborted` checks in `handlers.ts`. Keep the shared watchdog
|
|
387
|
+
(`body.ts:76-137`) untouched — add an abort listener that removes itself.
|
|
388
|
+
- [ ] **Step 4: Verify** `bun test test/unit/ingress test/property` + `bun run bench:http:smoke`.
|
|
389
|
+
- [ ] **Step 5: Commit.**
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
### Task 5 (B2): Bounded caches + public `flushMemory()`
|
|
394
|
+
|
|
395
|
+
**Why:** Elysia ships `flushMemory()` (clear caches + `Bun.gc()`) and bounds
|
|
396
|
+
every cache with generational eviction (`src/memory.ts:7-14`,
|
|
397
|
+
`src/utils.ts:42-53`). castrum has **no memory-flush API** and three unbounded
|
|
398
|
+
structures:
|
|
399
|
+
- TS metrics registry: `values`/`counts`/`sums` Maps grow per distinct label set
|
|
400
|
+
(`src/shared/metrics.ts:73-93`, no cap).
|
|
401
|
+
- `mimeStrCache` (`src/rust-ffi/text.ts:71`) and `mimeByText`
|
|
402
|
+
(`src/rust-ffi/context.ts:113`) keyed by caller extension.
|
|
403
|
+
- Task runtime work queue is explicitly unbounded
|
|
404
|
+
(`rust/task/runtime.rs:193-206`) — handled in B3.
|
|
405
|
+
|
|
406
|
+
**Files:**
|
|
407
|
+
- Create: `src/shared/memory.ts` (public `flushMemory(opts?)`)
|
|
408
|
+
- Modify: `src/shared/metrics.ts` — add `MAX_SERIES_PER_METRIC` (default 10_000)
|
|
409
|
+
with an eviction policy + a `castrum_metrics_series_dropped_total` counter so
|
|
410
|
+
drops are observable rather than silent.
|
|
411
|
+
- Modify: `src/rust-ffi/text.ts`, `src/rust-ffi/context.ts` — bound the MIME maps
|
|
412
|
+
(LRU 1024) and export `clearMimeCaches()`.
|
|
413
|
+
- Modify: `src/shared/buffer-pool.ts` — add `shrink()` (drop free buffers).
|
|
414
|
+
- Modify: `src/loader/index.ts` — expose the existing `clear()` for the flush.
|
|
415
|
+
- Modify: `index.ts` (public export) + `test/unit/contract/proven.test.ts` if a
|
|
416
|
+
`rust.*` surface changes.
|
|
417
|
+
- Test: `test/unit/shared/memory.test.ts`, `test/unit/shared/metrics.test.ts`
|
|
418
|
+
|
|
419
|
+
**Interfaces:**
|
|
420
|
+
- Produces:
|
|
421
|
+
- `flushMemory(options?: { gc?: boolean }): void` — clears loader LRU, MIME
|
|
422
|
+
caches, buffer-pool free lists, and (after C2) the native schema cache;
|
|
423
|
+
optionally `Bun.gc()` / `globalThis.gc?.()`. **Never clears metrics** (an
|
|
424
|
+
observability registry is not a cache).
|
|
425
|
+
- `clearMimeCaches(): void` (impure boundary module).
|
|
426
|
+
- Consumes: `loader.clear()` (`src/loader/index.ts:578`), `BufferPool.shrink()`.
|
|
427
|
+
|
|
428
|
+
- [ ] **Step 1: Write failing tests** — (a) metrics cardinality cap drops and
|
|
429
|
+
counts; (b) `flushMemory()` clears caches and is idempotent/safe with no addon.
|
|
430
|
+
- [ ] **Step 2: Run — FAIL.**
|
|
431
|
+
- [ ] **Step 3: Implement** the cap in `metrics.ts` (evict-oldest-half on the
|
|
432
|
+
per-entry `values` map, mirroring Elysia `evictOldestHalf`), the MIME LRUs, the
|
|
433
|
+
pool `shrink()`, and `flushMemory`.
|
|
434
|
+
- [ ] **Step 4: Verify** `bun test test/unit/shared test/unit/rust-ffi` + `bun run check:jsdoc` + `bun run check:clean`.
|
|
435
|
+
- [ ] **Step 5: Commit.**
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
### Task 6 (B3): Bound the task runtime work queue (admission control)
|
|
440
|
+
|
|
441
|
+
**Why:** `rust/task/runtime.rs:193-206` documents "the queue is unbounded;
|
|
442
|
+
admission/backpressure is a later milestone", and the JS `inflight` Map grows
|
|
443
|
+
with submissions (`src/task/runtime.ts:226`). Under load this is a memory-growth
|
|
444
|
+
path. Elysia bounds analogous in-flight work with a hard cap + close/error
|
|
445
|
+
(`src/ws/route.ts:1126,1213-1220`).
|
|
446
|
+
|
|
447
|
+
**Files:** `rust/task/runtime.rs`, `rust/task/mod.rs`, `rust/ffi/task.rs`,
|
|
448
|
+
`src/task/runtime.ts`; tests `rust/task/tests.rs`,
|
|
449
|
+
`test/unit/task/runtime.test.ts`.
|
|
450
|
+
|
|
451
|
+
**Interfaces:** `submit()` returns a distinct "rejected/overloaded" outcome when
|
|
452
|
+
the queue depth exceeds `CASTRUM_TASK_QUEUE_MAX` (default e.g. 4096); JS rejects
|
|
453
|
+
the promise with a typed `OVERLOADED` error. Existing `submit` return-type
|
|
454
|
+
change must be additive (keep the `bool`-returning C ABI shape or add a new
|
|
455
|
+
symbol — `[verify]` against `docs/FFI_BUN_GUIDE.md` §14 ABI rule).
|
|
456
|
+
|
|
457
|
+
- [ ] **Step 1: Failing test** — submit > cap without draining, assert the
|
|
458
|
+
excess reject with `OVERLOADED` and queue depth is bounded.
|
|
459
|
+
- [ ] **Step 2–5:** implement ring bound + atomic depth counter; verify
|
|
460
|
+
`cargo test task && bun test test/unit/task`; commit.
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
### Task 7 (B4): Differential transport-lane test harness
|
|
465
|
+
|
|
466
|
+
**Why:** Elysia's strongest assurance mechanism is running one corpus through
|
|
467
|
+
every lane (JIT / precompile / real socket / AOT-reconstruct) and byte-comparing
|
|
468
|
+
(`test/differential/lanes.ts:217-239`). castrum has proptest + pool-parity but
|
|
469
|
+
no single harness proving **the same request produces identical bytes** across
|
|
470
|
+
`bun:ffi` vs `napi`, path-1 (`fast.ts`) vs path-2 (`handlers.ts`), Bun vs Node,
|
|
471
|
+
and pooled vs zero-copy.
|
|
472
|
+
|
|
473
|
+
**Files:**
|
|
474
|
+
- Create: `test/differential/lanes.ts` — lane definitions:
|
|
475
|
+
`['ffi','napi']` (transport), `['fast','baked']` (wire format — compare
|
|
476
|
+
normalized shape, **not** raw bytes, since the formats intentionally differ),
|
|
477
|
+
`['copy','zero-copy']`, `['bun','node']`.
|
|
478
|
+
- Create: `test/differential/corpus.ts` — ~40 requests (valid/invalid/edge:
|
|
479
|
+
empty body, oversized, bad JSON, bad query encodings, duplicate headers,
|
|
480
|
+
`__proto__`, abort).
|
|
481
|
+
- Create: `test/differential/differential.test.ts`.
|
|
482
|
+
- Modify: `package.json` test script if a separate gate is desired (keep it out
|
|
483
|
+
of the default `test` path if it requires Node's `dist/` — mirror the
|
|
484
|
+
`test:node` split, `AGENTS.md` Testing).
|
|
485
|
+
|
|
486
|
+
**Interfaces:** `runLane(lane, corpus): Promise<LaneResult>` where each result
|
|
487
|
+
normalizes status + headers-of-interest + body; the harness asserts cross-lane
|
|
488
|
+
equality and pins known-divergent fields explicitly.
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 1: Write the harness + a deliberate divergence test** (inject a
|
|
491
|
+
wrong status in one lane; assert the harness fails).
|
|
492
|
+
- [ ] **Step 2: Run — expect the deliberate failure, then fix the injection.**
|
|
493
|
+
- [ ] **Step 3: Add the corpus; make all lanes agree.**
|
|
494
|
+
- [ ] **Step 4: Commit.**
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## Phase C — Performance
|
|
499
|
+
|
|
500
|
+
### Task 8 (C1): Static route promotion (serve prebuilt `Response`s from Bun's native table)
|
|
501
|
+
|
|
502
|
+
**Why:** Elysia pre-maps static `Response`/`Error`/HTML routes and hands them to
|
|
503
|
+
`Bun.serve.routes`, so Bun serves them **without entering JS**
|
|
504
|
+
(`src/adapter/bun/index.ts:184-306`, `compile/handler/index.ts:354-428`).
|
|
505
|
+
castrum already uses `Bun.serve.routes` but every value is a JS handler that
|
|
506
|
+
runs the ingress pipeline (`src/ingress/server.ts:261-333`). Health probes and
|
|
507
|
+
other constant responses (`src/ingress/health.ts:35-67`) can skip JS entirely.
|
|
508
|
+
|
|
509
|
+
**Files:**
|
|
510
|
+
- Modify: `src/ingress/server.ts` — extend `BakedRoute` with
|
|
511
|
+
`static?: Response | (() => Response)` and wire it directly into
|
|
512
|
+
`methods[GET]` when present, bypassing `readHandler`. `[verify]` that Bun
|
|
513
|
+
accepts a static `Response` (not only a handler) as a route value for the
|
|
514
|
+
current Bun floor; if not, keep the handler but return a cached instance.
|
|
515
|
+
- Modify: `src/ingress/health.ts` — expose `staticLiveness()` returning a
|
|
516
|
+
prebuilt `Response` so `/livez` needs no JS.
|
|
517
|
+
- Modify: `src/ingress/router.ts` if it should honor `static`.
|
|
518
|
+
- Test: `test/unit/ingress/static-route.test.ts`, plus
|
|
519
|
+
`bench/http/load.ts` shape guard (`bun run bench:http:smoke`).
|
|
520
|
+
|
|
521
|
+
**Interfaces:** `BakedRoute.static?: Response | (() => Response)`; when present,
|
|
522
|
+
`read`/`write`/`echo` etc. for that path/method are ignored for GET and the
|
|
523
|
+
value is placed in the route table verbatim. Fallback: if Bun rejects a `Response`
|
|
524
|
+
value, wrap in `() => staticResponse`.
|
|
525
|
+
|
|
526
|
+
- [ ] **Step 1: Failing test** — a server with `{ '/livez': { static: prebuilt } }`
|
|
527
|
+
responds 200 with the prebuilt bytes and does **not** run a probe handler
|
|
528
|
+
(assert via a side-effect counter / a Response identity check).
|
|
529
|
+
- [ ] **Step 2–4:** implement; verify `bun run bench:http:smoke` shows no shape
|
|
530
|
+
failures; commit.
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
### Task 9 (C2): Process-wide schema compile cache (dedupe identical schemas)
|
|
535
|
+
|
|
536
|
+
**Why:** Elysia's `TypeBoxValidatorCache` compiles a schema once and shares it
|
|
537
|
+
across routes/apps (`src/type/validator/validator-cache.ts:263-489`), including
|
|
538
|
+
a GC timer. castrum compiles an `IngressSchema` per `Ingress`/`NativeRoute`
|
|
539
|
+
(`rust/ingress/api.rs:81-95`, `router.ts:177-200`), so N routes with one schema
|
|
540
|
+
pay N compiles at startup and N copies at runtime. This is the one performance
|
|
541
|
+
mechanism with a clear, bounded win.
|
|
542
|
+
|
|
543
|
+
**Files (FFI task — follow `docs/HOW_TO_ADD_AN_OP.md` + `castrum-rust-ffi` skill):**
|
|
544
|
+
- Create: `rust/ingress/schema_cache.rs` (bounded `LruCache<SchemaKey, Arc<IngressSchema>>`,
|
|
545
|
+
cap e.g. 128; `SchemaKey = (canonical-json hash, flags)`), re-export from
|
|
546
|
+
`rust/ingress/mod.rs`.
|
|
547
|
+
- Modify: `rust/ingress/api.rs` (`Ingress::new`) and
|
|
548
|
+
`rust/ingress/native_route.rs` (`NativeRoute::compile`) to call
|
|
549
|
+
`get_or_compile`.
|
|
550
|
+
- Modify: `rust/ffi/` — add `castrum_schema_cache_clear` (must be `panic_guard`ed,
|
|
551
|
+
self-tested, added to the dlopen map + `ffi/types.ts` + parity test).
|
|
552
|
+
- Modify: `src/rust-ffi/scalar/factories.ts` (+ `src/native/ffi.ts` bindings) to
|
|
553
|
+
expose `rust.clearSchemaCache()`.
|
|
554
|
+
- Modify: `src/shared/memory.ts` (`flushMemory` calls it).
|
|
555
|
+
- Tests: `rust/ingress/schema_cache.rs` unit tests, `test/unit/native/ffi-symbol-parity.test.ts`,
|
|
556
|
+
`test/unit/ingress/schema-cache.test.ts`.
|
|
557
|
+
|
|
558
|
+
**Interfaces:** `schema_cache::get_or_compile(bytes, flags) -> Result<Arc<IngressSchema>, SchemaError>`;
|
|
559
|
+
`rust.clearSchemaCache(): void`.
|
|
560
|
+
|
|
561
|
+
**Safety gate (must hold before sharing):** `IngressSchema` must be immutable
|
|
562
|
+
after compile (`Arc` shared across routes/threads). `[verify]` there is no
|
|
563
|
+
interior mutability in `IngressSchema`, `fast_schema::FastNode`, or the
|
|
564
|
+
`jsonschema::Validator` it holds; if any exists, cache the compiled pieces only
|
|
565
|
+
and recompile the mutable shell, or abandon C2.
|
|
566
|
+
|
|
567
|
+
- [ ] **Step 1: Rust unit test** — compiling the same bytes twice returns the
|
|
568
|
+
same `Arc` pointer (`Arc::ptr_eq`), and `clear` empties the cache.
|
|
569
|
+
- [ ] **Step 2:** implement Rust cache + clear.
|
|
570
|
+
- [ ] **Step 3: FFI wiring test** — parity test sees the new symbol; bind-time
|
|
571
|
+
self-test passes (`CASTRUM_FFI_MODE=ffi bun test test/unit/native`).
|
|
572
|
+
- [ ] **Step 4: JS parity test** — two `createIngressHandler` instances with the
|
|
573
|
+
same schema produce identical behavior to distinct-schema handlers.
|
|
574
|
+
- [ ] **Step 5:** `cargo test`, `bun run build`, `bun run bench:startup`,
|
|
575
|
+
`bun run bench:http:smoke`; commit.
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
## Phase D — Larger efforts (separate plans)
|
|
580
|
+
|
|
581
|
+
These are real Elysia strengths but each is its own subsystem; write a dedicated
|
|
582
|
+
plan per item using `writing-plans` before implementation.
|
|
583
|
+
|
|
584
|
+
- **D1 — WebSocket server lifecycle.** Wire a `Bun.serve({ websocket })` runtime
|
|
585
|
+
into `createIngressServer` with: max message size, an in-flight message cap
|
|
586
|
+
(Elysia uses 256, `src/ws/route.ts:1126`), backpressure-aware send
|
|
587
|
+
(`:212-285`), per-message error isolation, upgrade-time validation parity with
|
|
588
|
+
HTTP, strictest-wins option merging (`:1084-1124`), and drain-on-shutdown.
|
|
589
|
+
castrum currently ships only the handshake helper
|
|
590
|
+
(`src/integration/websocket.ts:49-93`) and no Bun `websocket` config
|
|
591
|
+
(`src/ingress/server.ts:347-401`).
|
|
592
|
+
- **D2 — Ingress cookie signing/verification + CSRF gate.** Add an opt-in
|
|
593
|
+
`cookies: { secrets, verify: 'lazy'|'eager' }` to `IngressOptions` that reuses
|
|
594
|
+
the native cookie-signer (`rust/crypto/cookie_sign.rs`) and mirrors Elysia's
|
|
595
|
+
rotation/legacy/name-binding semantics (`src/cookie/crypto.ts:145-227`).
|
|
596
|
+
castrum has the primitives but no ingress integration
|
|
597
|
+
(`rust/ingress/pipeline.rs:454-476`).
|
|
598
|
+
- **D3 — Benchmark proof-baseline with noise floors + self-test injection.**
|
|
599
|
+
Port the *methodology* of Elysia D1 (`bench/d1/run.ts:1395-1569`,
|
|
600
|
+
`README.md`) to castrum's HTTP/startup benches: paired blocks, percentile
|
|
601
|
+
bootstrap, machine-pinned baselines, and injected regressions that prove each
|
|
602
|
+
gate can fail. This turns castrum's ad-hoc benches into statistically gated
|
|
603
|
+
claims.
|
|
604
|
+
- **D4 — `isProduction()` seam (only if/when verbose dev diagnostics are added).**
|
|
605
|
+
Elysia gates error detail on it (`src/universal/is-production.ts:3-4`). Not
|
|
606
|
+
needed for A1 because castrum's error bodies are static by wire-format rule;
|
|
607
|
+
add it when a dev-only diagnostic path is designed, not before.
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
## Self-Review
|
|
612
|
+
|
|
613
|
+
- **Spec coverage:** every Elysia mechanism in the Context table is either mapped
|
|
614
|
+
to a task (A1, A2, A3, B1, B2, B3, B4, C1, C2) or explicitly deferred (D1–D4)
|
|
615
|
+
or explicitly rejected with a reason. No silent gaps.
|
|
616
|
+
- **Placeholder scan:** no TBD/"handle errors"/"similar to Task N" — each task
|
|
617
|
+
names files, interfaces, and test intent. C2 is flagged with a hard safety gate
|
|
618
|
+
rather than a speculative implementation.
|
|
619
|
+
- **Type consistency:** `RouteHandler`, `BakedRoute`, `BuildRouteHandlersOptions`,
|
|
620
|
+
`OptimizedIngressHandler.run` signatures match the current source
|
|
621
|
+
(`server.ts:30-94`, `:226-229`); `createServerErrorHandler`/`guardRouteHandler`
|
|
622
|
+
names are used consistently from A1 onward; `flushMemory`/`clearMimeCaches`
|
|
623
|
+
names are consistent between B2 and C2.
|
|
624
|
+
- **Constraint check:** no task changes a wire format, a hot-path signature, or a
|
|
625
|
+
layout constant; A1 reuses the frozen internal error body; C2 is gated on
|
|
626
|
+
proving `IngressSchema` immutability before sharing.
|
|
627
|
+
|
|
628
|
+
## Suggested order
|
|
629
|
+
|
|
630
|
+
A1 → B2 → A2 → B1 → C1 → A3 → B4 → B3 → C2 → D*.
|
|
631
|
+
|
|
632
|
+
Rationale: A1 and B2 are the highest value per unit risk (containment + bounded
|
|
633
|
+
memory). C1 is a contained perf win. C2 is the largest perf change and carries an
|
|
634
|
+
FFI/ABI cost, so it comes after the safety net (A1/B2/B4) exists.
|
package/index.ts
CHANGED
|
@@ -41,6 +41,11 @@ export { decoder, encoder } from './src/shared/bytes'
|
|
|
41
41
|
export type { Counter, Gauge, Histogram, MetricsRegistry } from './src/shared/metrics'
|
|
42
42
|
// Zero-dep metrics registry (counters/gauges/histograms + Prometheus render).
|
|
43
43
|
export { createMetrics, DEFAULT_BUCKETS } from './src/shared/metrics'
|
|
44
|
+
export type { FlushMemoryOptions } from './src/shared/memory'
|
|
45
|
+
// Explicit memory-flush escape hatch (drops cached state + optional GC).
|
|
46
|
+
// Deliberately does NOT clear the metrics registry (an observability registry
|
|
47
|
+
// is not a cache).
|
|
48
|
+
export { flushMemory } from './src/shared/memory'
|
|
44
49
|
export type { Pair, ParsedHttpRequestPacked } from './src/shared/packed'
|
|
45
50
|
export {
|
|
46
51
|
packBatch,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castrum",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
|
|
6
6
|
"type": "module",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"test:coverage": "node scripts/check-coverage.mjs",
|
|
75
75
|
"setup": "bun install && bun run build",
|
|
76
76
|
"test:node": "node --test test/integration/node-smoke.test.mjs test/integration/node-enterprise.test.mjs",
|
|
77
|
+
"test:differential": "bun test test/differential",
|
|
77
78
|
"verify:install": "node scripts/verify-install.mjs",
|
|
78
79
|
"build:debug": "napi build --platform",
|
|
79
80
|
"lint": "bunx biome check .",
|