castrum 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1374 -1772
- package/dist/index.js +5570 -4169
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +106 -14
- package/docs/BENCHMARKS.md +83 -47
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +8 -6
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +15 -32
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +5 -4
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +46 -26
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +36 -24
- package/src/ingress/body.ts +167 -27
- package/src/ingress/handlers.ts +91 -15
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +39 -27
- package/src/ingress/packing/gather-raw-headers.ts +85 -21
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/select-headers.ts +12 -6
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +42 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +7 -31
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +6 -6
- package/src/ingress/server.ts +45 -13
- package/src/ingress/types.ts +35 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1063 -829
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +41 -238
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +1 -1
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +2 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +253 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/{packed.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- package/src/shared/bench-classify.ts +0 -209
package/src/ingress/body.ts
CHANGED
|
@@ -1,8 +1,50 @@
|
|
|
1
1
|
// src/ingress/body.ts — Streaming request-body reading for the async ingress
|
|
2
2
|
// API.
|
|
3
3
|
|
|
4
|
+
import { isBun } from '../shared/runtime'
|
|
5
|
+
|
|
4
6
|
const EMPTY_BODY = new Uint8Array(0)
|
|
5
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Read the request body as bytes. Bun's `req.bytes()` returns a `Uint8Array`
|
|
10
|
+
* directly (no `ArrayBuffer` → view wrapper and no copy) — feature-detected so
|
|
11
|
+
* Node (which lacks `Response.bytes()` on some versions) falls back to
|
|
12
|
+
* `arrayBuffer()` + a zero-copy view. This is the hot-path body read for every
|
|
13
|
+
* write route.
|
|
14
|
+
*/
|
|
15
|
+
async function readBodyBytes(req: Request): Promise<Uint8Array> {
|
|
16
|
+
const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
|
|
17
|
+
if (typeof bytes === 'function') {
|
|
18
|
+
return bytes.call(req)
|
|
19
|
+
}
|
|
20
|
+
return new Uint8Array(await req.arrayBuffer())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bun-only synchronous peek of an already-buffered body.
|
|
25
|
+
*
|
|
26
|
+
* Under Bun a body fully received during HTTP parsing resolves `req.bytes()`
|
|
27
|
+
* IMMEDIATELY — `Bun.peek` hands back the `Uint8Array` without awaiting, so a
|
|
28
|
+
* declared-Content-Length write route can read the body with no deadline race,
|
|
29
|
+
* no watchdog entry and no microtask turn (the common small-body case).
|
|
30
|
+
*
|
|
31
|
+
* Returns:
|
|
32
|
+
* - `{ bytes }` — already buffered; the caller reads it synchronously and
|
|
33
|
+
* must NOT pay the deadline race (the body is already here).
|
|
34
|
+
* - `Promise<Uint8Array>` — the read is still pending (slow/trickling body);
|
|
35
|
+
* the caller MUST still enforce its deadline against this promise.
|
|
36
|
+
* - `false` — no `req.bytes()` on this runtime (Node); the caller falls back
|
|
37
|
+
* to the async `readBodyBytes` + race as before.
|
|
38
|
+
*/
|
|
39
|
+
function peekBufferedBody(req: Request): { bytes: Uint8Array } | Promise<Uint8Array> | false {
|
|
40
|
+
const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
|
|
41
|
+
if (typeof bytes !== 'function') return false
|
|
42
|
+
const pending = bytes.call(req)
|
|
43
|
+
if (!isBun()) return pending
|
|
44
|
+
const peeked = Bun.peek(pending)
|
|
45
|
+
return peeked instanceof Promise ? peeked : { bytes: peeked }
|
|
46
|
+
}
|
|
47
|
+
|
|
6
48
|
/** Read the request body once, enforcing the size guard when enabled. */
|
|
7
49
|
export function readRequestBodyOnce(
|
|
8
50
|
req: Request,
|
|
@@ -23,6 +65,77 @@ function bodyTooLargeError(): Error & { code?: string } {
|
|
|
23
65
|
return err
|
|
24
66
|
}
|
|
25
67
|
|
|
68
|
+
// ── Shared body-read deadline watchdog ──────────────────────────────────────
|
|
69
|
+
// One interval timer sweeps ALL in-flight body reads that carry a deadline,
|
|
70
|
+
// replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
|
|
71
|
+
// every write-route body read (that per-request timer alloc is measurable in
|
|
72
|
+
// the POST micro-bench — ~800ns of the ~2.3µs body read). Abort semantics are
|
|
73
|
+
// unchanged: a read that outlives its deadline rejects with REQUEST_TIMEOUT.
|
|
74
|
+
// Entries are removed on completion or when the deadline fires; the interval
|
|
75
|
+
// only runs while at least one read is pending.
|
|
76
|
+
const bodyWatchdog = (() => {
|
|
77
|
+
const inflight = new Set<WatchdogEntry>()
|
|
78
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
79
|
+
const SWEEP_MS = 250
|
|
80
|
+
|
|
81
|
+
function sweep() {
|
|
82
|
+
const now = Date.now()
|
|
83
|
+
for (const entry of inflight) {
|
|
84
|
+
if (entry.settled) {
|
|
85
|
+
inflight.delete(entry)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (now >= entry.deadlineWall) {
|
|
89
|
+
entry.settled = true
|
|
90
|
+
inflight.delete(entry)
|
|
91
|
+
entry.reject(timeoutError())
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (inflight.size === 0 && timer !== null) {
|
|
95
|
+
clearInterval(timer)
|
|
96
|
+
timer = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function track(entry: WatchdogEntry) {
|
|
101
|
+
inflight.add(entry)
|
|
102
|
+
if (timer === null) {
|
|
103
|
+
timer = setInterval(sweep, SWEEP_MS)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function untrack(entry: WatchdogEntry) {
|
|
108
|
+
entry.settled = true
|
|
109
|
+
inflight.delete(entry)
|
|
110
|
+
if (inflight.size === 0 && timer !== null) {
|
|
111
|
+
clearInterval(timer)
|
|
112
|
+
timer = null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { track, untrack }
|
|
117
|
+
})()
|
|
118
|
+
|
|
119
|
+
interface WatchdogEntry {
|
|
120
|
+
deadlineWall: number
|
|
121
|
+
settled: boolean
|
|
122
|
+
reject: (err: Error) => void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Create a deadline tracked by the shared watchdog (no per-read setTimeout). */
|
|
126
|
+
function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel: () => void } {
|
|
127
|
+
const entry: WatchdogEntry = {
|
|
128
|
+
deadlineWall: Date.now() + deadlineMs,
|
|
129
|
+
settled: false,
|
|
130
|
+
reject: () => {},
|
|
131
|
+
}
|
|
132
|
+
const promise = new Promise<never>((_resolve, reject) => {
|
|
133
|
+
entry.reject = reject
|
|
134
|
+
})
|
|
135
|
+
bodyWatchdog.track(entry)
|
|
136
|
+
return { promise, cancel: () => bodyWatchdog.untrack(entry) }
|
|
137
|
+
}
|
|
138
|
+
|
|
26
139
|
/**
|
|
27
140
|
* Stream-read the request body, enforcing the size guard when enabled and an
|
|
28
141
|
* optional overall deadline.
|
|
@@ -39,30 +152,16 @@ export async function readBodyWithLimit(
|
|
|
39
152
|
guard: boolean,
|
|
40
153
|
timeoutMs?: number,
|
|
41
154
|
): Promise<Uint8Array> {
|
|
42
|
-
const body = req.body
|
|
43
|
-
|
|
44
|
-
if (!body) {
|
|
45
|
-
return EMPTY_BODY
|
|
46
|
-
}
|
|
47
|
-
|
|
48
155
|
const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
|
|
49
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
50
|
-
const timeout = new Promise<never>((_resolve, reject) => {
|
|
51
|
-
if (deadline <= 0) return
|
|
52
|
-
timer = setTimeout(() => reject(timeoutError()), deadline)
|
|
53
|
-
// Don't let a pending body timeout keep the event loop alive once the
|
|
54
|
-
// request has otherwise completed (Node; a no-op under Bun).
|
|
55
|
-
timer.unref?.()
|
|
56
|
-
})
|
|
57
156
|
|
|
58
157
|
// Fast path: a declared Content-Length lets us PROVE the body fits the
|
|
59
|
-
// guard before reading, so one native `arrayBuffer()` read
|
|
60
|
-
// reader + per-chunk `Promise.race` + `concatUint8Arrays`
|
|
61
|
-
// POSTs are single-shot bodies — this removes ~4-6
|
|
62
|
-
// request). The deadline (when set) is still enforced
|
|
63
|
-
// not one per chunk.
|
|
64
|
-
// `maxRequestBodySize`, and the post-read length re-check catches
|
|
65
|
-
// that lies about Content-Length.
|
|
158
|
+
// guard before reading, so one native `bytes()`/`arrayBuffer()` read
|
|
159
|
+
// replaces the reader + per-chunk `Promise.race` + `concatUint8Arrays`
|
|
160
|
+
// churn (the bench POSTs are single-shot bodies — this removes ~4-6
|
|
161
|
+
// allocations per write request). The deadline (when set) is still enforced
|
|
162
|
+
// with a single race, not one per chunk. The read is bounded by the
|
|
163
|
+
// server's `maxRequestBodySize`, and the post-read length re-check catches
|
|
164
|
+
// a client that lies about Content-Length.
|
|
66
165
|
const declared = req.headers.get('content-length')
|
|
67
166
|
if (declared !== null) {
|
|
68
167
|
const declaredLen = Number(declared)
|
|
@@ -70,20 +169,60 @@ export async function readBodyWithLimit(
|
|
|
70
169
|
if (guard && declaredLen > maxBytes) {
|
|
71
170
|
throw bodyTooLargeError()
|
|
72
171
|
}
|
|
172
|
+
// `req.body` (the stream getter) is deliberately NOT touched on this
|
|
173
|
+
// path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
|
|
174
|
+
// body directly, avoiding a lazily-constructed stream per write request.
|
|
175
|
+
if (deadline <= 0) {
|
|
176
|
+
const bytes = await readBodyBytes(req)
|
|
177
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
178
|
+
throw bodyTooLargeError()
|
|
179
|
+
}
|
|
180
|
+
return bytes
|
|
181
|
+
}
|
|
182
|
+
// Deadline path. Under Bun, a small declared-length body is usually
|
|
183
|
+
// already buffered — `Bun.peek` returns the bytes synchronously, so we
|
|
184
|
+
// skip the deadline race + watchdog + microtask entirely for the common
|
|
185
|
+
// case. Only a genuinely pending read (slow/trickling body) pays the
|
|
186
|
+
// race; non-Bun runtimes keep the previous behavior.
|
|
187
|
+
const buffered = peekBufferedBody(req)
|
|
188
|
+
if (buffered === false) {
|
|
189
|
+
const dl = createDeadline(deadline)
|
|
190
|
+
try {
|
|
191
|
+
const bytes = await Promise.race([readBodyBytes(req), dl.promise])
|
|
192
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
193
|
+
throw bodyTooLargeError()
|
|
194
|
+
}
|
|
195
|
+
return bytes
|
|
196
|
+
} finally {
|
|
197
|
+
dl.cancel()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if ('bytes' in buffered) {
|
|
201
|
+
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
202
|
+
throw bodyTooLargeError()
|
|
203
|
+
}
|
|
204
|
+
return buffered.bytes
|
|
205
|
+
}
|
|
206
|
+
const dl = createDeadline(deadline)
|
|
73
207
|
try {
|
|
74
|
-
const
|
|
75
|
-
deadline > 0 ? await Promise.race([req.arrayBuffer(), timeout]) : await req.arrayBuffer()
|
|
76
|
-
const bytes = new Uint8Array(buf)
|
|
208
|
+
const bytes = await Promise.race([buffered, dl.promise])
|
|
77
209
|
if (guard && bytes.byteLength > maxBytes) {
|
|
78
210
|
throw bodyTooLargeError()
|
|
79
211
|
}
|
|
80
212
|
return bytes
|
|
81
213
|
} finally {
|
|
82
|
-
|
|
214
|
+
dl.cancel()
|
|
83
215
|
}
|
|
84
216
|
}
|
|
85
217
|
}
|
|
86
218
|
|
|
219
|
+
// No (usable) Content-Length: fall back to the streaming reader. `req.body`
|
|
220
|
+
// is only touched here — the declared-length fast path never needs it.
|
|
221
|
+
const body = req.body
|
|
222
|
+
if (!body) {
|
|
223
|
+
return EMPTY_BODY
|
|
224
|
+
}
|
|
225
|
+
|
|
87
226
|
const reader = body.getReader()
|
|
88
227
|
const chunks: Uint8Array[] = []
|
|
89
228
|
let total = 0
|
|
@@ -91,11 +230,12 @@ export async function readBodyWithLimit(
|
|
|
91
230
|
// already closed, so the `cancel()` in `finally` is a wasted async round-trip
|
|
92
231
|
// per request. It is still required when the loop exits early (timeout/error).
|
|
93
232
|
let completed = false
|
|
233
|
+
const dl = deadline > 0 ? createDeadline(deadline) : null
|
|
94
234
|
|
|
95
235
|
try {
|
|
96
236
|
for (;;) {
|
|
97
237
|
const { done, value } =
|
|
98
|
-
|
|
238
|
+
dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
|
|
99
239
|
|
|
100
240
|
if (done) {
|
|
101
241
|
completed = true
|
|
@@ -112,7 +252,7 @@ export async function readBodyWithLimit(
|
|
|
112
252
|
chunks.push(value)
|
|
113
253
|
}
|
|
114
254
|
} finally {
|
|
115
|
-
|
|
255
|
+
dl?.cancel()
|
|
116
256
|
// Release the underlying stream. Cancelling while a read() is still
|
|
117
257
|
// pending (the timeout case) can resolve that pending read() as `done`
|
|
118
258
|
// and RACE the timeout rejection — so reject first, cancel only AFTER
|
package/src/ingress/handlers.ts
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import { getAddon } from '../native'
|
|
40
40
|
import { getBunFFI } from '../native/ffi'
|
|
41
41
|
import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
|
|
42
|
-
import { decoder, viewForArrayBuffer } from '../shared/bytes'
|
|
42
|
+
import { decoder, encoder, viewForArrayBuffer } from '../shared/bytes'
|
|
43
43
|
import { createStructuredLogger } from '../shared/log'
|
|
44
44
|
import { generateRequestId } from '../shared/request-id'
|
|
45
45
|
import { pooledBodyResponse } from '../shared/response'
|
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
} from './constants'
|
|
55
55
|
import { BakedIngressResult } from './decode/baked-result'
|
|
56
56
|
import { buildBakedHeaderTemplates } from './headers/baked-templates'
|
|
57
|
+
import type { SecurityHeadersOptions } from './headers/hsts'
|
|
58
|
+
import { buildSecurityPairs } from './headers/hsts'
|
|
57
59
|
import {
|
|
58
60
|
assertIngressOptionValues,
|
|
59
61
|
assertKnownIngressOptions,
|
|
@@ -62,8 +64,6 @@ import {
|
|
|
62
64
|
} from './options'
|
|
63
65
|
import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
|
|
64
66
|
import { IngressInputPacker } from './packing/input-packer'
|
|
65
|
-
import { buildSecurityPairs } from './headers/hsts'
|
|
66
|
-
import type { SecurityHeadersOptions } from './headers/hsts'
|
|
67
67
|
import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './response/error-bodies'
|
|
68
68
|
import {
|
|
69
69
|
assertSyncCallback,
|
|
@@ -84,10 +84,13 @@ export type { IngressHandlerOptions } from './options'
|
|
|
84
84
|
export { ERROR_BODIES } from './response/error-bodies'
|
|
85
85
|
export type { BakedHandlerOptions } from './routes'
|
|
86
86
|
export {
|
|
87
|
+
deleteHandler,
|
|
87
88
|
echoHandler,
|
|
88
89
|
fallbackHandler,
|
|
89
90
|
headHandler,
|
|
90
91
|
jsonWriteHandler,
|
|
92
|
+
nativeResponderRoute,
|
|
93
|
+
optionsHandler,
|
|
91
94
|
readHandler,
|
|
92
95
|
} from './routes'
|
|
93
96
|
export type {
|
|
@@ -102,7 +105,6 @@ export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
|
|
|
102
105
|
// Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
|
|
103
106
|
export { createIngressServerNode } from './server-node'
|
|
104
107
|
|
|
105
|
-
const encoder = new TextEncoder()
|
|
106
108
|
const EMPTY_BODY = new Uint8Array(0)
|
|
107
109
|
const EMPTY_IP = '0.0.0.0'
|
|
108
110
|
|
|
@@ -163,6 +165,7 @@ function buildBakedSecurityEntries(
|
|
|
163
165
|
// (shared with the route factories) and re-exported here for back-compat.
|
|
164
166
|
export type { BakedContext, OptimizedIngressHandler } from './types'
|
|
165
167
|
|
|
168
|
+
/** Runtime configuration for the pre-baked handler path (`createIngressHandler`). */
|
|
166
169
|
export interface BakedIngressRuntime {
|
|
167
170
|
/** Emit an `x-request-id` header on responses. Default: false. */
|
|
168
171
|
emitRequestIdHeader?: boolean
|
|
@@ -274,6 +277,21 @@ export function createIngressHandler(
|
|
|
274
277
|
const limit = rateLimit?.limit
|
|
275
278
|
const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
|
|
276
279
|
|
|
280
|
+
// Minimal-processing flags: skip per-request JS UTF-8 encoding of the URL/IP
|
|
281
|
+
// when the native pipeline (rust/ingress/pipeline.rs) provably cannot read
|
|
282
|
+
// them — mirror its consumption exactly, and KEEP IN SYNC:
|
|
283
|
+
// - URL is consumed iff parseQuery (extract_query), emitMetadataJson
|
|
284
|
+
// (extract_path into the envelope) or https is unpinned (detect_https
|
|
285
|
+
// scans the scheme prefix). Otherwise an empty URL section is safe.
|
|
286
|
+
// - IP is consumed iff rate limiting is active (resolve_client_ip /
|
|
287
|
+
// rate_key) or a proxy-trust mode is configured (socket_is_trusted
|
|
288
|
+
// parses it). Otherwise the IP is dropped from the frame (logs/hooks
|
|
289
|
+
// still see the real `ip` — this only skips the frame encode).
|
|
290
|
+
const trustEnabled = options.trustProxy === true || options.trustedProxies?.enabled === true
|
|
291
|
+
const urlNeeded =
|
|
292
|
+
options.parseQuery === true || options.emitMetadataJson === true || options.https === undefined
|
|
293
|
+
const ipNeeded = rateEnabled || trustEnabled
|
|
294
|
+
|
|
277
295
|
// Shared with the fast path — proxy extraction is driven by trust config
|
|
278
296
|
// (trustProxy / trustedProxies), not by whether rate limiting is enabled.
|
|
279
297
|
const headerPlan: HeaderPlan = buildHeaderPlan(options)
|
|
@@ -400,6 +418,8 @@ export function createIngressHandler(
|
|
|
400
418
|
const needsRetry = (variant & HV_RATE_LIMITED) !== 0
|
|
401
419
|
|
|
402
420
|
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
421
|
+
// Frozen-readonly → mutable: callers only ever read the baked template
|
|
422
|
+
// (zero-copy steady-state path), so the widening is safe in practice.
|
|
403
423
|
return template as unknown as [string, string][]
|
|
404
424
|
}
|
|
405
425
|
|
|
@@ -480,6 +500,7 @@ export function createIngressHandler(
|
|
|
480
500
|
// terminal template (JSON + `cache-control: no-store`) directly with zero
|
|
481
501
|
// per-response array allocation/copy.
|
|
482
502
|
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
503
|
+
// Frozen-readonly → mutable: the baked terminal template is only read.
|
|
483
504
|
return terminalTemplates[v & 31] as unknown as [string, string][]
|
|
484
505
|
}
|
|
485
506
|
|
|
@@ -637,10 +658,12 @@ export function createIngressHandler(
|
|
|
637
658
|
// above, so pass it in to avoid a second `req.headers.get('origin')`
|
|
638
659
|
// native→JS string conversion on the hot path.
|
|
639
660
|
const packedHeaders = gatherRawHeadersPacked(req, headerPlan, methodKind, ctx.origin)
|
|
640
|
-
//
|
|
641
|
-
// `
|
|
642
|
-
//
|
|
643
|
-
|
|
661
|
+
// Minimal processing: when the native pipeline cannot read the URL/IP
|
|
662
|
+
// (see `urlNeeded`/`ipNeeded`), pass an empty URL / omit the IP so we
|
|
663
|
+
// skip the per-request JS UTF-8 encode entirely (the frame still carries
|
|
664
|
+
// a valid 0-length section the native bounds-checks).
|
|
665
|
+
const packUrl = urlNeeded ? req.url : ''
|
|
666
|
+
const packIp = ipNeeded ? ip : undefined
|
|
644
667
|
|
|
645
668
|
try {
|
|
646
669
|
// Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
|
|
@@ -648,14 +671,60 @@ export function createIngressHandler(
|
|
|
648
671
|
handle = outputPool.acquire(outputBufferSize)
|
|
649
672
|
currentHandle = handle
|
|
650
673
|
|
|
651
|
-
// Write into the pooled buffer — no per-request allocation.
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
674
|
+
// Write into the pooled buffer — no per-request allocation. Bun
|
|
675
|
+
// PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
|
|
676
|
+
// passes `url`/`ip` as `bun:ffi` `cstring` args — the engine transcodes
|
|
677
|
+
// the JS strings to call-scoped UTF-8 in-engine, so we skip the JS
|
|
678
|
+
// frame assembly + `Buffer.write` encode for the URL/IP (same wire
|
|
679
|
+
// format: both paths run the shared `handle_components` core). Falls
|
|
680
|
+
// back to the packed frame (FFI or napi) on any failure / stale addon.
|
|
681
|
+
// If an ffi call throws (native reported 0 = error, or a panic
|
|
682
|
+
// `panic_guard` contained), re-dispatch through napi once and, after
|
|
683
|
+
// repeated failures, permanently disable ffi for this handler.
|
|
657
684
|
let written: number
|
|
658
|
-
if (
|
|
685
|
+
if (
|
|
686
|
+
bunFFI !== null &&
|
|
687
|
+
ingressPtr !== 0 &&
|
|
688
|
+
!ffiDisabled &&
|
|
689
|
+
typeof bunFFI.ingressHandleComponents === 'function'
|
|
690
|
+
) {
|
|
691
|
+
try {
|
|
692
|
+
written = bunFFI.ingressHandleComponents(
|
|
693
|
+
ingressPtr,
|
|
694
|
+
methodKind,
|
|
695
|
+
packUrl,
|
|
696
|
+
packIp ?? '',
|
|
697
|
+
ridBytes,
|
|
698
|
+
packedHeaders,
|
|
699
|
+
body,
|
|
700
|
+
handle.buffer,
|
|
701
|
+
)
|
|
702
|
+
ffiFailures = 0
|
|
703
|
+
} catch {
|
|
704
|
+
ffiFailures++
|
|
705
|
+
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
706
|
+
ffiDisabled = true
|
|
707
|
+
}
|
|
708
|
+
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
709
|
+
// identical — a transient ffi panic still serves the request. The
|
|
710
|
+
// packed frame respects the same url/ip minimal-processing.
|
|
711
|
+
const input = inputPacker.packParts(
|
|
712
|
+
methodKind,
|
|
713
|
+
packUrl,
|
|
714
|
+
packIp,
|
|
715
|
+
ridBytes,
|
|
716
|
+
packedHeaders,
|
|
717
|
+
)
|
|
718
|
+
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
719
|
+
}
|
|
720
|
+
} else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
|
|
721
|
+
const input = inputPacker.packParts(
|
|
722
|
+
methodKind,
|
|
723
|
+
packUrl,
|
|
724
|
+
packIp,
|
|
725
|
+
ridBytes,
|
|
726
|
+
packedHeaders,
|
|
727
|
+
)
|
|
659
728
|
try {
|
|
660
729
|
written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
|
|
661
730
|
ffiFailures = 0
|
|
@@ -669,6 +738,13 @@ export function createIngressHandler(
|
|
|
669
738
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
670
739
|
}
|
|
671
740
|
} else {
|
|
741
|
+
const input = inputPacker.packParts(
|
|
742
|
+
methodKind,
|
|
743
|
+
packUrl,
|
|
744
|
+
packIp,
|
|
745
|
+
ridBytes,
|
|
746
|
+
packedHeaders,
|
|
747
|
+
)
|
|
672
748
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
673
749
|
}
|
|
674
750
|
|
package/src/ingress/health.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// The readiness probe runs the optional `check` (deps up? -> 200/503).
|
|
16
16
|
// The health probe is an alias of liveness (process up) plus optional check.
|
|
17
17
|
|
|
18
|
+
/** Outcome of a readiness check: HTTP status + plain-text body. */
|
|
18
19
|
export interface ReadinessResult {
|
|
19
20
|
status: number
|
|
20
21
|
body: string
|
package/src/ingress/index.ts
CHANGED
|
@@ -6,47 +6,59 @@
|
|
|
6
6
|
|
|
7
7
|
import { decoder } from '../shared/bytes'
|
|
8
8
|
import { generateRequestId } from '../shared/request-id'
|
|
9
|
-
import {
|
|
9
|
+
import { readRequestBodyOnce } from './body'
|
|
10
|
+
import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
|
|
11
|
+
import { internalContext, snapshotResult, syntheticContext } from './context'
|
|
10
12
|
import { createIngressFast } from './fast'
|
|
11
13
|
import { buildResponseContext } from './headers/fast-templates'
|
|
12
14
|
import { buildTerminalResponse } from './response/terminal'
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
import { assertSyncCallback, DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from './shared'
|
|
16
|
+
import type { IngressContext, IngressHandler, IngressOptions, SyncIngressHandler } from './types'
|
|
17
|
+
|
|
18
|
+
// Runtime-driven server factory: picks Bun.serve on Bun and the node:http
|
|
19
|
+
// adapter on Node via the runtime adapter (src/runtime/server.ts). This
|
|
20
|
+
// explicit re-export SHADOWS the Bun-only `createIngressServer` from
|
|
21
|
+
// './server' (re-exported via './handlers' above) so the public API serves
|
|
22
|
+
// one backend per runtime. `createIngressServerNode` remains exported for
|
|
23
|
+
// consumers who want the Node backend pinned.
|
|
24
|
+
export { createIngressServer } from '../runtime/index'
|
|
25
|
+
export { generateRequestId } from '../shared/request-id'
|
|
26
|
+
export type { TraceContext } from '../shared/trace'
|
|
27
|
+
// W3C trace context helpers (parse `traceparent` for log/hook correlation).
|
|
28
|
+
export {
|
|
29
|
+
createSpanId,
|
|
30
|
+
createTraceId,
|
|
31
|
+
parseTraceParent,
|
|
32
|
+
serializeTraceParent,
|
|
33
|
+
} from '../shared/trace'
|
|
18
34
|
// ── Re-export the full public ingress API ─────────────────────────
|
|
19
35
|
export * from './constants'
|
|
20
|
-
export * from './status'
|
|
21
36
|
export * from './errors'
|
|
22
|
-
export * from './handlers'
|
|
23
37
|
export { createIngressFast, FastIngressResult } from './fast'
|
|
24
|
-
export
|
|
38
|
+
export * from './handlers'
|
|
39
|
+
export type { CorsOptions, CorsStaticStrings } from './headers/cors'
|
|
40
|
+
export type { HeaderTemplate, ResponseBuildContext } from './headers/fast-templates'
|
|
25
41
|
export { buildResponseContext, headersForResult } from './headers/fast-templates'
|
|
26
|
-
export { generateRequestId } from '../shared/request-id'
|
|
27
|
-
export { METHOD_KIND, DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from './shared'
|
|
28
|
-
export type { HeaderPlan } from './shared'
|
|
29
|
-
export type { ResponseBuildContext, HeaderTemplate } from './headers/fast-templates'
|
|
30
|
-
export type { CorsStaticStrings, CorsOptions } from './headers/cors'
|
|
31
42
|
export type { SecurityHeadersOptions } from './headers/hsts'
|
|
32
|
-
export
|
|
43
|
+
export { healthHandler, livenessHandler, readinessHandler } from './health'
|
|
44
|
+
export type { IngressMetrics } from './metrics'
|
|
33
45
|
// ── Observability (zero-dep): metrics, health probes ─────────────
|
|
34
46
|
export { createIngressMetrics, metricsHandler } from './metrics'
|
|
35
|
-
export type {
|
|
36
|
-
export {
|
|
37
|
-
|
|
38
|
-
export {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
serializeTraceParent,
|
|
43
|
-
} from '../shared/trace'
|
|
44
|
-
export type { TraceContext } from '../shared/trace'
|
|
47
|
+
export type { IngressFastHandler, IngressFastOptions } from './options'
|
|
48
|
+
export { buildTerminalResponse } from './response/terminal'
|
|
49
|
+
export type { CreateIngressRouterOptions, IngressRouter, RouterRouteSpec } from './router'
|
|
50
|
+
export { createIngressRouter } from './router'
|
|
51
|
+
export type { HeaderPlan } from './shared'
|
|
52
|
+
export { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES, METHOD_KIND } from './shared'
|
|
53
|
+
export * from './status'
|
|
45
54
|
export type {
|
|
46
|
-
IngressOptions,
|
|
47
55
|
IngressContext,
|
|
48
|
-
SyncIngressHandler,
|
|
49
56
|
IngressHandler,
|
|
57
|
+
IngressOptions,
|
|
58
|
+
NativeRequestContext,
|
|
59
|
+
NativeResponder,
|
|
60
|
+
SyncIngressHandler,
|
|
61
|
+
TerminalStyle,
|
|
50
62
|
} from './types'
|
|
51
63
|
|
|
52
64
|
// ── Sync factory ─────────────────────────────────────────────────
|