castrum 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -368
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +2763 -0
- package/dist/index.js +8707 -0
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +507 -0
- package/docs/BENCHMARKS.md +343 -0
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +81 -0
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +192 -0
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +746 -0
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +279 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +43 -0
- package/docs/bun-builtins-decision-matrix.md +115 -0
- package/index.ts +44 -8
- package/package.json +98 -33
- package/src/ingress/body.ts +289 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +639 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +68 -0
- package/src/ingress/index.ts +142 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +139 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +164 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +102 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +79 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +27 -0
- package/src/ingress/routes/index.ts +11 -0
- package/src/ingress/routes/json-write.ts +129 -0
- package/src/ingress/routes/options.ts +26 -0
- package/src/ingress/routes/read.ts +35 -0
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +475 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +191 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +220 -0
- package/src/integration/streaming.ts +78 -0
- package/src/integration/websocket.ts +93 -0
- package/src/loader/batch.ts +137 -0
- package/src/loader/cost.ts +105 -0
- package/src/loader/index.ts +745 -0
- package/src/loader/ops.ts +657 -0
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1775 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +240 -0
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +401 -0
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +87 -0
- package/src/rust-ffi/context.ts +193 -0
- package/src/rust-ffi/index.ts +51 -541
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +28 -0
- package/src/rust-ffi/scalar/crypto.ts +346 -0
- package/src/rust-ffi/scalar/factories.ts +459 -0
- package/src/rust-ffi/scalar/hashing.ts +74 -0
- package/src/rust-ffi/scalar/http.ts +337 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +325 -0
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +61 -0
- package/src/rust-ffi/scalar/payload.ts +241 -0
- package/src/rust-ffi/text.ts +121 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +73 -0
- package/src/shared/adaptive.ts +67 -0
- package/src/shared/buffer-pool.ts +264 -0
- package/src/shared/bytes.ts +108 -9
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +75 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/packed/wire.ts +504 -0
- package/src/shared/proven.ts +289 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +29 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +15 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/data/json-rows.ts +0 -33
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
- package/src/shared/packed.ts +0 -316
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/ingress/packing/input-packer.ts — Binary-packed ingress input builder.
|
|
2
|
+
//
|
|
3
|
+
// Packs the per-request inputs (method kind + url + ip + request id + packed
|
|
4
|
+
// headers) into a single growable Uint8Array in the layout the native
|
|
5
|
+
// `Ingress.handleRequestPacked` expects.
|
|
6
|
+
|
|
7
|
+
import { encoder } from '../../shared/bytes'
|
|
8
|
+
|
|
9
|
+
const EMPTY_BYTES = new Uint8Array(0)
|
|
10
|
+
const EMPTY_IP_BYTES = encoder.encode('0.0.0.0')
|
|
11
|
+
|
|
12
|
+
/** Growable packer for the fast-path packed input buffer. */
|
|
13
|
+
export class IngressInputPacker {
|
|
14
|
+
private buf: Uint8Array
|
|
15
|
+
private view: DataView
|
|
16
|
+
/** Cached zero-copy `Buffer` view over `buf`'s backing store (invalidated
|
|
17
|
+
* when `buf` grows). `Buffer.write` needs a Buffer; caching it here avoids
|
|
18
|
+
* a per-string view/`Buffer.from` allocation on the hot pack path. */
|
|
19
|
+
private bufView: Buffer | null = null
|
|
20
|
+
private pos = 0
|
|
21
|
+
|
|
22
|
+
constructor(initialSize = 65536) {
|
|
23
|
+
this.buf = new Uint8Array(Math.max(1024, initialSize))
|
|
24
|
+
this.view = new DataView(this.buf.buffer)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private ensure(additional: number): void {
|
|
28
|
+
const needed = this.pos + additional
|
|
29
|
+
if (needed <= this.buf.byteLength) return
|
|
30
|
+
|
|
31
|
+
let nextSize = this.buf.byteLength * 2
|
|
32
|
+
while (nextSize < needed) {
|
|
33
|
+
nextSize *= 2
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const next = new Uint8Array(nextSize)
|
|
37
|
+
next.set(this.buf.subarray(0, this.pos))
|
|
38
|
+
|
|
39
|
+
this.buf = next
|
|
40
|
+
this.view = new DataView(next.buffer)
|
|
41
|
+
this.bufView = null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private writeU8(value: number): void {
|
|
45
|
+
this.ensure(1)
|
|
46
|
+
this.buf[this.pos++] = value & 0xff
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private writeLenPrefixed(bytes: Uint8Array): void {
|
|
50
|
+
this.ensure(4 + bytes.byteLength)
|
|
51
|
+
|
|
52
|
+
this.view.setUint32(this.pos, bytes.byteLength, true)
|
|
53
|
+
this.pos += 4
|
|
54
|
+
|
|
55
|
+
this.buf.set(bytes, this.pos)
|
|
56
|
+
this.pos += bytes.byteLength
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pack the request inputs; the returned view is valid until the next pack. */
|
|
60
|
+
pack(
|
|
61
|
+
methodKind: number,
|
|
62
|
+
urlBytes: Uint8Array,
|
|
63
|
+
ipBytes: Uint8Array,
|
|
64
|
+
requestIdBytes: Uint8Array,
|
|
65
|
+
headers: Uint8Array,
|
|
66
|
+
): Uint8Array {
|
|
67
|
+
this.pos = 0
|
|
68
|
+
|
|
69
|
+
this.writeU8(methodKind)
|
|
70
|
+
this.writeLenPrefixed(urlBytes)
|
|
71
|
+
this.writeLenPrefixed(ipBytes)
|
|
72
|
+
this.writeLenPrefixed(requestIdBytes)
|
|
73
|
+
this.writeLenPrefixed(headers)
|
|
74
|
+
|
|
75
|
+
return this.buf.subarray(0, this.pos)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Encode `value` directly into the buffer, length-prefixed (zero-copy). */
|
|
79
|
+
private writeStringLenPrefixed(value: string): void {
|
|
80
|
+
// 3 bytes per UTF-16 code unit is a safe upper bound (BMP non-ASCII);
|
|
81
|
+
// surrogate pairs encode to fewer bytes per code unit.
|
|
82
|
+
this.ensure(4 + value.length * 3)
|
|
83
|
+
const valueLenPos = this.pos
|
|
84
|
+
this.pos += 4
|
|
85
|
+
// Reused `Buffer` view over this buffer: `Buffer.write` encodes the string
|
|
86
|
+
// in place at the current position and returns bytes written — no
|
|
87
|
+
// `{read, written}` object, no `Buffer.from`/subarray per string (those
|
|
88
|
+
// were per-request allocations on this hot path).
|
|
89
|
+
let view = this.bufView
|
|
90
|
+
if (view === null) {
|
|
91
|
+
view = Buffer.from(this.buf.buffer, this.buf.byteOffset, this.buf.byteLength)
|
|
92
|
+
this.bufView = view
|
|
93
|
+
}
|
|
94
|
+
const written = view.write(value, this.pos, 'utf8')
|
|
95
|
+
this.view.setUint32(valueLenPos, written, true)
|
|
96
|
+
this.pos += written
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Pack the request inputs from raw strings, encoding url/ip/requestId
|
|
101
|
+
* directly into the internal buffer via `encodeInto` — three fewer
|
|
102
|
+
* intermediate `Uint8Array` allocations + copies per request vs [`pack`].
|
|
103
|
+
* The returned view is valid until the next pack.
|
|
104
|
+
*/
|
|
105
|
+
packFromStrings(
|
|
106
|
+
methodKind: number,
|
|
107
|
+
url: string,
|
|
108
|
+
ip: string | undefined,
|
|
109
|
+
requestId: string | undefined,
|
|
110
|
+
headers: Uint8Array,
|
|
111
|
+
): Uint8Array {
|
|
112
|
+
this.pos = 0
|
|
113
|
+
|
|
114
|
+
this.writeU8(methodKind)
|
|
115
|
+
this.writeStringLenPrefixed(url)
|
|
116
|
+
if (ip && ip.length > 0) {
|
|
117
|
+
this.writeStringLenPrefixed(ip)
|
|
118
|
+
} else {
|
|
119
|
+
this.writeLenPrefixed(EMPTY_IP_BYTES)
|
|
120
|
+
}
|
|
121
|
+
if (requestId) {
|
|
122
|
+
this.writeStringLenPrefixed(requestId)
|
|
123
|
+
} else {
|
|
124
|
+
this.writeLenPrefixed(EMPTY_BYTES)
|
|
125
|
+
}
|
|
126
|
+
this.writeLenPrefixed(headers)
|
|
127
|
+
|
|
128
|
+
return this.buf.subarray(0, this.pos)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pack the request inputs with a mixed strategy: `url`/`ip` are encoded
|
|
133
|
+
* directly from strings via `encodeInto` (no intermediate `Uint8Array`),
|
|
134
|
+
* while `requestId`/`headers` are already-encoded byte slices copied in
|
|
135
|
+
* verbatim.
|
|
136
|
+
*
|
|
137
|
+
* This removes the two `encoder.encode` allocations + copies per request
|
|
138
|
+
* that [`pack`] pays for URL/IP, while keeping the pre-encoded request-id
|
|
139
|
+
* bytes untouched (no decode-to-string → re-encode round trip, which
|
|
140
|
+
* `packFromStrings` would incur when a request id was already generated as
|
|
141
|
+
* bytes). The returned view is valid until the next pack.
|
|
142
|
+
*/
|
|
143
|
+
packParts(
|
|
144
|
+
methodKind: number,
|
|
145
|
+
url: string,
|
|
146
|
+
ip: string | undefined,
|
|
147
|
+
requestId: Uint8Array,
|
|
148
|
+
headers: Uint8Array,
|
|
149
|
+
): Uint8Array {
|
|
150
|
+
this.pos = 0
|
|
151
|
+
|
|
152
|
+
this.writeU8(methodKind)
|
|
153
|
+
this.writeStringLenPrefixed(url)
|
|
154
|
+
if (ip && ip.length > 0) {
|
|
155
|
+
this.writeStringLenPrefixed(ip)
|
|
156
|
+
} else {
|
|
157
|
+
this.writeLenPrefixed(EMPTY_IP_BYTES)
|
|
158
|
+
}
|
|
159
|
+
this.writeLenPrefixed(requestId)
|
|
160
|
+
this.writeLenPrefixed(headers)
|
|
161
|
+
|
|
162
|
+
return this.buf.subarray(0, this.pos)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/ingress/packing/scratch.ts — Shared thread-local scratch header buffers.
|
|
2
|
+
//
|
|
3
|
+
// Both the fast path (`packHeaders` in header-packing.ts) and the pre-baked
|
|
4
|
+
// path (`gatherRawHeadersPacked` in gather-raw-headers.ts) pack request
|
|
5
|
+
// headers into a reusable Uint8Array. This thread-local pool isolates the
|
|
6
|
+
// buffer per call-site sequence and bounds the number of cached buffers,
|
|
7
|
+
// keeping the hot path allocation-free.
|
|
8
|
+
|
|
9
|
+
/** Scratch capacity for the reusable packed-header buffer. */
|
|
10
|
+
export const HEADER_BUF_SIZE = 8192
|
|
11
|
+
|
|
12
|
+
// ── Per-header size guards (SHARED by BOTH packing paths) ─────────
|
|
13
|
+
// A header value larger than the bound below is dropped BEFORE packing rather
|
|
14
|
+
// than forwarded. Without guards the fast path (`packHeaders`) forwarded any
|
|
15
|
+
// size, so an oversized cookie/xff/origin could push the packed block past the
|
|
16
|
+
// native `max_headers_bytes` (65536) and fail with a 500, while the pre-baked
|
|
17
|
+
// path silently dropped the same header. Both paths must share ONE policy;
|
|
18
|
+
// keep the Rust `max_headers_bytes` bound in mind when changing these.
|
|
19
|
+
/** Upper bound for the `cookie` header value. */
|
|
20
|
+
export const MAX_COOKIE_HEADER_BYTES = 8192
|
|
21
|
+
/** Upper bound for small single-value headers (origin, ACRM, ACRH, ...). */
|
|
22
|
+
export const MAX_SMALL_HEADER_BYTES = 2048
|
|
23
|
+
/** Upper bound for the `x-forwarded-for` header value. */
|
|
24
|
+
export const MAX_XFF_HEADER_BYTES = 8192
|
|
25
|
+
|
|
26
|
+
// Thread-local header buffer for per-call isolation (round-robin over a
|
|
27
|
+
// bounded pool; each buffer is only borrowed for the duration of one
|
|
28
|
+
// pack/gather call).
|
|
29
|
+
const [getHeaderBuf] = (() => {
|
|
30
|
+
const tls: [Uint8Array, DataView][] = []
|
|
31
|
+
const MAX_CACHED = 256
|
|
32
|
+
let tlsIdx = 0
|
|
33
|
+
|
|
34
|
+
function acquire(): [Uint8Array, DataView] {
|
|
35
|
+
const cached = tls[tlsIdx]
|
|
36
|
+
tlsIdx = (tlsIdx + 1) % MAX_CACHED
|
|
37
|
+
if (cached) return cached
|
|
38
|
+
|
|
39
|
+
const buf = new Uint8Array(HEADER_BUF_SIZE)
|
|
40
|
+
const view = new DataView(buf.buffer)
|
|
41
|
+
const pair: [Uint8Array, DataView] = [buf, view]
|
|
42
|
+
tls.push(pair)
|
|
43
|
+
return pair
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [acquire]
|
|
47
|
+
})()
|
|
48
|
+
|
|
49
|
+
/** Acquire the next scratch header buffer + view from the thread-local pool. */
|
|
50
|
+
export { getHeaderBuf }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/ingress/packing/select-headers.ts — Shared header-selection iterator.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for "which headers does a HeaderPlan select" across
|
|
4
|
+
// the TWO packing paths (a string-array reference variant lives test-local in
|
|
5
|
+
// header-packing.test.ts):
|
|
6
|
+
// - `packHeaders` (fast path, ./header-packing.ts)
|
|
7
|
+
// - `gatherRawHeadersPacked` (pre-baked packed path, ./gather-raw-headers.ts)
|
|
8
|
+
//
|
|
9
|
+
// Both apply the SAME selection rules and the SAME per-header size guards
|
|
10
|
+
// (an oversized value is dropped rather than forwarded, so the packed block can
|
|
11
|
+
// never exceed the native `max_headers_bytes` (65536) and 500). This module
|
|
12
|
+
// owns that logic plus the pre-encoded header-name bytes, so a rule or size
|
|
13
|
+
// change cannot drift between the paths.
|
|
14
|
+
|
|
15
|
+
import { encoder } from '../../shared/bytes'
|
|
16
|
+
import { type HeaderPlan, METHOD_KIND } from '../shared'
|
|
17
|
+
import { MAX_COOKIE_HEADER_BYTES, MAX_SMALL_HEADER_BYTES, MAX_XFF_HEADER_BYTES } from './scratch'
|
|
18
|
+
|
|
19
|
+
// ── Header name constants (pre-encoded) ────────────────────────
|
|
20
|
+
/** Pre-encoded `cookie` header name. */
|
|
21
|
+
export const HDR_COOKIE = encoder.encode('cookie')
|
|
22
|
+
/** Pre-encoded `origin` header name. */
|
|
23
|
+
export const HDR_ORIGIN = encoder.encode('origin')
|
|
24
|
+
/** Pre-encoded `access-control-request-method` header name. */
|
|
25
|
+
export const HDR_ACRM = encoder.encode('access-control-request-method')
|
|
26
|
+
/** Pre-encoded `access-control-request-headers` header name. */
|
|
27
|
+
export const HDR_ACRH = encoder.encode('access-control-request-headers')
|
|
28
|
+
/** Pre-encoded `x-forwarded-for` header name. */
|
|
29
|
+
export const HDR_XFF = encoder.encode('x-forwarded-for')
|
|
30
|
+
/** Pre-encoded `x-real-ip` header name. */
|
|
31
|
+
export const HDR_XRI = encoder.encode('x-real-ip')
|
|
32
|
+
/** Pre-encoded `x-forwarded-proto` header name. */
|
|
33
|
+
export const HDR_XFP = encoder.encode('x-forwarded-proto')
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Visit every request header selected by `plan` (cookie, origin + CORS
|
|
37
|
+
* preflight method/headers, proxy XFF/XRI, proto XFP), applying the shared
|
|
38
|
+
* per-header size guards from scratch.ts. Callers supply `visit(name, value)`
|
|
39
|
+
* with the pre-encoded header-name bytes; no intermediate array is allocated.
|
|
40
|
+
*
|
|
41
|
+
* @param originValue - When provided (non-undefined), the already-fetched
|
|
42
|
+
* `Origin` header value, avoiding a second `req.headers.get('origin')`
|
|
43
|
+
* native→JS conversion on the hot path (the caller, e.g. `handlers.ts::run`,
|
|
44
|
+
* already fetched it once for `ctx.origin`). When omitted, the origin is
|
|
45
|
+
* fetched here. The same `MAX_SMALL_HEADER_BYTES` guard applies either way.
|
|
46
|
+
* @param visit - Invoked once per selected header with the pre-encoded name
|
|
47
|
+
* bytes and the raw value string.
|
|
48
|
+
*/
|
|
49
|
+
export function forEachSelectedHeader(
|
|
50
|
+
req: Request,
|
|
51
|
+
plan: HeaderPlan,
|
|
52
|
+
methodKind: number,
|
|
53
|
+
originValue: string | null | undefined,
|
|
54
|
+
visit: (name: Uint8Array, value: string) => void,
|
|
55
|
+
): void {
|
|
56
|
+
const h = req.headers
|
|
57
|
+
|
|
58
|
+
if (plan.cookie) {
|
|
59
|
+
const v = h.get('cookie')
|
|
60
|
+
if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
|
|
61
|
+
visit(HDR_COOKIE, v)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (plan.cors) {
|
|
66
|
+
const originV = originValue !== undefined ? originValue : h.get('origin')
|
|
67
|
+
if (originV !== null && originV.length <= MAX_SMALL_HEADER_BYTES) {
|
|
68
|
+
visit(HDR_ORIGIN, originV)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (methodKind === METHOD_KIND.OPTIONS) {
|
|
72
|
+
const acrm = h.get('access-control-request-method')
|
|
73
|
+
if (acrm !== null && acrm.length <= MAX_SMALL_HEADER_BYTES) {
|
|
74
|
+
visit(HDR_ACRM, acrm)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const acrh = h.get('access-control-request-headers')
|
|
78
|
+
if (acrh !== null && acrh.length <= MAX_SMALL_HEADER_BYTES) {
|
|
79
|
+
visit(HDR_ACRH, acrh)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (plan.proxy) {
|
|
85
|
+
const xff = h.get('x-forwarded-for')
|
|
86
|
+
if (xff !== null && xff.length <= MAX_XFF_HEADER_BYTES) {
|
|
87
|
+
visit(HDR_XFF, xff)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const xri = h.get('x-real-ip')
|
|
91
|
+
if (xri !== null && xri.length <= MAX_SMALL_HEADER_BYTES) {
|
|
92
|
+
visit(HDR_XRI, xri)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (plan.proto) {
|
|
97
|
+
const xfp = h.get('x-forwarded-proto')
|
|
98
|
+
if (xfp !== null && xfp.length <= MAX_SMALL_HEADER_BYTES) {
|
|
99
|
+
visit(HDR_XFP, xfp)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/ingress/response/error-bodies.ts — Pre-encoded static error bodies
|
|
2
|
+
// (pre-baked path).
|
|
3
|
+
//
|
|
4
|
+
// The pre-baked path emits the benchmark wire format:
|
|
5
|
+
// {"ok":false,"error":{"code":"...","message":"..."}}
|
|
6
|
+
// These bodies are encoded once at module load so error responses never
|
|
7
|
+
// re-allocate or re-serialize on the hot path.
|
|
8
|
+
|
|
9
|
+
import { encoder } from '../../shared/bytes'
|
|
10
|
+
import {
|
|
11
|
+
ERR_CODE_CORS_PREFLIGHT,
|
|
12
|
+
ERR_CODE_RATE_LIMITED,
|
|
13
|
+
ERR_CODE_BODY_TOO_LARGE,
|
|
14
|
+
ERR_CODE_INVALID_JSON,
|
|
15
|
+
ERR_CODE_SCHEMA_VALIDATION,
|
|
16
|
+
ERR_CODE_BAD_REQUEST,
|
|
17
|
+
ERR_CODE_REQUEST_TOO_LARGE,
|
|
18
|
+
ERR_CODE_INTERNAL,
|
|
19
|
+
} from '../constants'
|
|
20
|
+
|
|
21
|
+
function staticErrorBody(code: string, message: string): Uint8Array {
|
|
22
|
+
return encoder.encode(`{"ok":false,"error":{"code":"${code}","message":"${message}"}}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pre-encoded static error bodies, keyed by error-code string name. */
|
|
26
|
+
export const ERROR_BODIES: Record<string, Uint8Array> = {
|
|
27
|
+
not_found: staticErrorBody('not_found', 'Not found'),
|
|
28
|
+
unsupported_media_type: staticErrorBody(
|
|
29
|
+
'unsupported_media_type',
|
|
30
|
+
'Content-Type must be application/json',
|
|
31
|
+
),
|
|
32
|
+
body_too_large: staticErrorBody('body_too_large', 'Request body is too large'),
|
|
33
|
+
request_timeout: staticErrorBody('request_timeout', 'Request body read timed out'),
|
|
34
|
+
invalid_json: staticErrorBody('invalid_json', 'Invalid JSON body'),
|
|
35
|
+
schema_validation_failed: staticErrorBody(
|
|
36
|
+
'schema_validation_failed',
|
|
37
|
+
'Request body failed schema validation',
|
|
38
|
+
),
|
|
39
|
+
cors_preflight_not_allowed: staticErrorBody(
|
|
40
|
+
'cors_preflight_not_allowed',
|
|
41
|
+
'CORS preflight not allowed',
|
|
42
|
+
),
|
|
43
|
+
bad_request: staticErrorBody('bad_request', 'Bad request'),
|
|
44
|
+
request_too_large: staticErrorBody('request_too_large', 'Request too large'),
|
|
45
|
+
rate_limited: staticErrorBody('rate_limited', 'Too Many Requests'),
|
|
46
|
+
internal: staticErrorBody('internal_error', 'Internal server error'),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Error bodies indexed by numeric native error code. */
|
|
50
|
+
export const ERROR_CODE_BODIES: (Uint8Array | undefined)[] = []
|
|
51
|
+
ERROR_CODE_BODIES[ERR_CODE_CORS_PREFLIGHT] = ERROR_BODIES.cors_preflight_not_allowed
|
|
52
|
+
ERROR_CODE_BODIES[ERR_CODE_RATE_LIMITED] = ERROR_BODIES.rate_limited
|
|
53
|
+
ERROR_CODE_BODIES[ERR_CODE_BODY_TOO_LARGE] = ERROR_BODIES.body_too_large
|
|
54
|
+
ERROR_CODE_BODIES[ERR_CODE_INVALID_JSON] = ERROR_BODIES.invalid_json
|
|
55
|
+
ERROR_CODE_BODIES[ERR_CODE_SCHEMA_VALIDATION] = ERROR_BODIES.schema_validation_failed
|
|
56
|
+
ERROR_CODE_BODIES[ERR_CODE_BAD_REQUEST] = ERROR_BODIES.bad_request
|
|
57
|
+
ERROR_CODE_BODIES[ERR_CODE_REQUEST_TOO_LARGE] = ERROR_BODIES.request_too_large
|
|
58
|
+
ERROR_CODE_BODIES[ERR_CODE_INTERNAL] = ERROR_BODIES.internal
|
|
59
|
+
|
|
60
|
+
const RATE_LIMIT_BODY_PREFIX = encoder.encode(
|
|
61
|
+
'{"ok":false,"error":{"code":"rate_limited","message":"Too Many Requests","retry_after_ms":',
|
|
62
|
+
)
|
|
63
|
+
const RATE_LIMIT_BODY_SUFFIX = encoder.encode('}}')
|
|
64
|
+
|
|
65
|
+
/** Build a rate-limited error body with the retry-after value inlined. */
|
|
66
|
+
export function rateLimitedBody(retryAfterMs: number): Uint8Array {
|
|
67
|
+
const digits = encoder.encode(String(Math.max(0, Math.floor(retryAfterMs))))
|
|
68
|
+
const out = new Uint8Array(
|
|
69
|
+
RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength + RATE_LIMIT_BODY_SUFFIX.byteLength,
|
|
70
|
+
)
|
|
71
|
+
out.set(RATE_LIMIT_BODY_PREFIX, 0)
|
|
72
|
+
out.set(digits, RATE_LIMIT_BODY_PREFIX.byteLength)
|
|
73
|
+
out.set(RATE_LIMIT_BODY_SUFFIX, RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength)
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/ingress/response/terminal.ts — Terminal (error) response builder (fast
|
|
2
|
+
// path).
|
|
3
|
+
//
|
|
4
|
+
// Builds the fast-path terminal response: {"error":{code,status,message,
|
|
5
|
+
// requestId}} with x-ratelimit-* headers. This is the fast-path wire format;
|
|
6
|
+
// the pre-baked path has its own builders (see ./handlers.ts) — do not unify
|
|
7
|
+
// them (see AGENTS.md).
|
|
8
|
+
|
|
9
|
+
import { headersForResult, type ResponseBuildContext } from '../headers/fast-templates'
|
|
10
|
+
import { safeTerminalStatus } from '../status'
|
|
11
|
+
import { errorCodeName, errorMessage } from '../errors'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a terminal response for a result, or `null` when the result is not
|
|
15
|
+
* terminal (the caller should serve the body instead).
|
|
16
|
+
*/
|
|
17
|
+
export function buildTerminalResponse(
|
|
18
|
+
ctx: ResponseBuildContext,
|
|
19
|
+
r: {
|
|
20
|
+
readonly terminal: boolean
|
|
21
|
+
readonly isPreflight: boolean
|
|
22
|
+
readonly corsAllowed: boolean
|
|
23
|
+
readonly errorCode: number
|
|
24
|
+
readonly status: number
|
|
25
|
+
readonly headerVariant: number
|
|
26
|
+
readonly https: boolean
|
|
27
|
+
readonly rateLimit: number
|
|
28
|
+
readonly rateRemaining: number
|
|
29
|
+
readonly rateResetMs: number
|
|
30
|
+
readonly retryAfterMs: number
|
|
31
|
+
},
|
|
32
|
+
req: Request,
|
|
33
|
+
requestId: string,
|
|
34
|
+
): Response | null {
|
|
35
|
+
if (!r.terminal) return null
|
|
36
|
+
|
|
37
|
+
const preflightAllowed = r.isPreflight && r.corsAllowed
|
|
38
|
+
const headers = headersForResult(ctx, r, req, requestId)
|
|
39
|
+
|
|
40
|
+
if (preflightAllowed) {
|
|
41
|
+
return new Response(null, { status: 204, headers })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const status = safeTerminalStatus(r)
|
|
45
|
+
|
|
46
|
+
headers.set('content-type', 'application/json; charset=utf-8')
|
|
47
|
+
headers.set('cache-control', 'no-store')
|
|
48
|
+
|
|
49
|
+
const payload = JSON.stringify({
|
|
50
|
+
error: {
|
|
51
|
+
code: errorCodeName(r.errorCode),
|
|
52
|
+
status,
|
|
53
|
+
message: errorMessage(status, r.errorCode),
|
|
54
|
+
requestId: requestId || undefined,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return new Response(payload, { status, headers })
|
|
59
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// src/ingress/router.ts — createIngressRouter: per-route compiled ingress.
|
|
2
|
+
//
|
|
3
|
+
// The "one super solution" over the existing ingress core. Instead of one
|
|
4
|
+
// global `IngressInner` serving every path (with a superset of options), each
|
|
5
|
+
// route in the table carries its OWN `IngressHandlerOptions` and compiles a
|
|
6
|
+
// DEDICATED native `IngressInner` (via `createIngressHandler`) — so the native
|
|
7
|
+
// pipeline prunes to exactly that route's stages (parseCookies, parseQuery,
|
|
8
|
+
// schema, cors, rateLimit, limits...), and `buildHeaderPlan` (inside
|
|
9
|
+
// `createIngressHandler`) produces a per-route header plan that reads ZERO
|
|
10
|
+
// headers for routes that need none. All routes share the same wire format
|
|
11
|
+
// (the existing packed `handle_request_packed`), the shared process-wide rate
|
|
12
|
+
// limiter budgets, and pre-warm on construction (`warmOnCreate`).
|
|
13
|
+
//
|
|
14
|
+
// This is the completion of what `rust/route.rs` (now deleted — it was dead,
|
|
15
|
+
// external-project wire) attempted: per-route native stage pruning, WITHOUT a
|
|
16
|
+
// second/third wire format. The route factories (`readHandler`,
|
|
17
|
+
// `jsonWriteHandler`, ...) and the dispatch machinery (`buildRouteHandlers` /
|
|
18
|
+
// `buildPathMatcher`) are reused unchanged. The per-route native-compile idea
|
|
19
|
+
// is ALSO exposed as the standalone per-route stack in
|
|
20
|
+
// `rust/ingress/native_route.rs` (the `castrum_route_*`/napi `Route` surface
|
|
21
|
+
// consumed by `@ignex/native`'s `createNativeRoute` — the LIVE successor to
|
|
22
|
+
// the deleted dead wire).
|
|
23
|
+
|
|
24
|
+
import type { BakedIngressResult } from './decode/baked-result'
|
|
25
|
+
import { type BakedIngressRuntime, createIngressHandler } from './handlers'
|
|
26
|
+
import type { IngressHandlerOptions } from './options'
|
|
27
|
+
import type { BakedRoute, PathMatch, RouteHandler } from './server'
|
|
28
|
+
import { buildPathMatcher, buildRouteHandlers } from './server'
|
|
29
|
+
import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
30
|
+
|
|
31
|
+
/** Per-route spec for {@link createIngressRouter}. */
|
|
32
|
+
export interface RouterRouteSpec {
|
|
33
|
+
/**
|
|
34
|
+
* Per-route ingress options — compiled into a DEDICATED native `IngressInner`
|
|
35
|
+
* for this route (parseCookies, parseQuery, schema, cors, rateLimit, limits,
|
|
36
|
+
* requireJsonBody, ...). This is what makes the pipeline prune per route.
|
|
37
|
+
*/
|
|
38
|
+
options?: IngressHandlerOptions
|
|
39
|
+
/** Wire GET + HEAD read handlers over this route's compiled handler. */
|
|
40
|
+
read?: boolean
|
|
41
|
+
/** Wire POST/PUT/PATCH JSON-write handlers over this route's compiled handler. */
|
|
42
|
+
write?: boolean
|
|
43
|
+
/** Wire a POST echo handler over this route's compiled handler. */
|
|
44
|
+
echo?: boolean
|
|
45
|
+
/** Wire a GET read handler (cookies-style) over this route's compiled handler. */
|
|
46
|
+
cookies?: boolean
|
|
47
|
+
/** Wire a DELETE read-style handler over this route's compiled handler. */
|
|
48
|
+
delete?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* A JS responder route: the native pipeline decides + rejects (terminal
|
|
51
|
+
* response in {@link terminalStyle}); on success the responder builds the
|
|
52
|
+
* 2xx body (async OK) from a decoded snapshot. Wired for `methods`
|
|
53
|
+
* (default `['GET']`). When set, the standard `read`/`write`/etc. flags for
|
|
54
|
+
* this route are ignored.
|
|
55
|
+
*/
|
|
56
|
+
responder?: {
|
|
57
|
+
handler: NativeResponder
|
|
58
|
+
/** HTTP methods to wire (default `['GET']`). */
|
|
59
|
+
methods?: ReadonlyArray<string>
|
|
60
|
+
/** Read the body for native validation (default false — framework owns it). */
|
|
61
|
+
readBody?: boolean
|
|
62
|
+
}
|
|
63
|
+
/** Terminal envelope style for this route's responder (default: router-level
|
|
64
|
+
* `terminalStyle` or `'castrum'`). */
|
|
65
|
+
terminalStyle?: TerminalStyle
|
|
66
|
+
/**
|
|
67
|
+
* A raw request→Response handler served directly for GET, OUTSIDE the
|
|
68
|
+
* ingress pipeline (health/metrics probes, /metrics). When set, `read`/
|
|
69
|
+
* `write`/etc. for this route are ignored.
|
|
70
|
+
*/
|
|
71
|
+
raw?: RouteHandler
|
|
72
|
+
/** Overrides `maxBodyBytes` for this route's write/echo handlers. */
|
|
73
|
+
maxBodyBytes?: number
|
|
74
|
+
/** Overrides `bodyTimeoutMs` for this route's write/echo handlers. */
|
|
75
|
+
bodyTimeoutMs?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Options for {@link createIngressRouter}. */
|
|
79
|
+
export interface CreateIngressRouterOptions {
|
|
80
|
+
/** Route table: path → per-route spec. */
|
|
81
|
+
routes: Record<string, RouterRouteSpec>
|
|
82
|
+
/** Shared runtime hooks (onRequest/onResponse/onError, security headers,
|
|
83
|
+
* output buffer size) applied to EVERY compiled route handler. */
|
|
84
|
+
runtime?: BakedIngressRuntime
|
|
85
|
+
/** Shared `getIp` resolver for route handlers. */
|
|
86
|
+
getIp?: (req: Request, srv: unknown) => string | undefined
|
|
87
|
+
/** Shared `copyBody` default for route handlers. */
|
|
88
|
+
copyBody?: boolean
|
|
89
|
+
/** Default terminal envelope style for responder routes (`'castrum'`). */
|
|
90
|
+
terminalStyle?: TerminalStyle
|
|
91
|
+
/** Pre-warm every compiled route at construction (JIT the packed pipeline +
|
|
92
|
+
* the FFI ingress call before the first real request). Default: false. */
|
|
93
|
+
warmOnCreate?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A compiled per-route router. */
|
|
97
|
+
export interface IngressRouter {
|
|
98
|
+
/** The per-route compiled handlers keyed by path (for custom route wiring). */
|
|
99
|
+
routeHandlers: Record<string, OptimizedIngressHandler>
|
|
100
|
+
/** The route → method → handler map (compatible with Bun.serve `routes` and
|
|
101
|
+
* `createIngressServerNode`). */
|
|
102
|
+
routes: Record<string, Record<string, unknown>>
|
|
103
|
+
/** The path matcher (`:param`/`*` dynamic routes). */
|
|
104
|
+
match: (pathname: string) => PathMatch | undefined
|
|
105
|
+
/** A fetch-style dispatcher: match path + method → the route's handler. */
|
|
106
|
+
fetch: (req: Request, srv?: unknown) => Response | Promise<Response>
|
|
107
|
+
/** Pre-warm every compiled route (JIT the packed pipeline + FFI call). */
|
|
108
|
+
prewarm: () => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compile a route table into per-route native ingress instances.
|
|
113
|
+
*
|
|
114
|
+
* Each route with a pipeline-backed spec (`read`/`write`/`echo`/`cookies`/
|
|
115
|
+
* `delete`) compiles its OWN `createIngressHandler(options)` — a dedicated
|
|
116
|
+
* `IngressInner` pruned to that route's stages + a per-route header plan.
|
|
117
|
+
* Routes that need nothing (a bare `read` with no cookies/query/cors) therefore
|
|
118
|
+
* gather zero headers and run a near-empty native pipeline. All routes share
|
|
119
|
+
* the same wire format and the shared process-wide rate-limiter budgets.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* const router = createIngressRouter({
|
|
124
|
+
* warmOnCreate: true,
|
|
125
|
+
* runtime: ingressMetrics?.runtime,
|
|
126
|
+
* routes: {
|
|
127
|
+
* '/health': { read: true, options: { parseCookies: false, parseQuery: false } },
|
|
128
|
+
* '/api/users': {
|
|
129
|
+
* read: true,
|
|
130
|
+
* write: true,
|
|
131
|
+
* options: { parseCookies: true, parseQuery: true, schema: USER_SCHEMA_BYTES },
|
|
132
|
+
* },
|
|
133
|
+
* '/metrics': { raw: metricsHandler(ingressMetrics) },
|
|
134
|
+
* },
|
|
135
|
+
* })
|
|
136
|
+
* const server = Bun.serve({ routes: router.routes }) // or router.fetch
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function createIngressRouter(options: CreateIngressRouterOptions): IngressRouter {
|
|
140
|
+
const runtime = options.runtime ?? {}
|
|
141
|
+
|
|
142
|
+
// Per-route compiled handlers + the BakedRoute table the shared wiring reads.
|
|
143
|
+
const compiled: Record<string, OptimizedIngressHandler> = {}
|
|
144
|
+
const bakedRoutes: Record<string, BakedRoute> = {}
|
|
145
|
+
const baseOpts: {
|
|
146
|
+
getIp?: (req: Request, srv: unknown) => string | undefined
|
|
147
|
+
copyBody?: boolean
|
|
148
|
+
} = {}
|
|
149
|
+
if (options.getIp !== undefined) baseOpts.getIp = options.getIp
|
|
150
|
+
if (options.copyBody !== undefined) baseOpts.copyBody = options.copyBody
|
|
151
|
+
|
|
152
|
+
for (const [path, spec] of Object.entries(options.routes)) {
|
|
153
|
+
if (spec.raw) {
|
|
154
|
+
// Raw handler: served directly, outside the pipeline.
|
|
155
|
+
bakedRoutes[path] = { read: spec.raw as RouteHandler }
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
// Compile a dedicated native instance for this route's options.
|
|
159
|
+
const handler = createIngressHandler(spec.options ?? {}, runtime)
|
|
160
|
+
compiled[path] = handler
|
|
161
|
+
|
|
162
|
+
if (spec.responder) {
|
|
163
|
+
// Responder route: native decides + rejects; JS builds the 2xx.
|
|
164
|
+
bakedRoutes[path] = {
|
|
165
|
+
responder: {
|
|
166
|
+
ingress: handler,
|
|
167
|
+
handler: spec.responder.handler,
|
|
168
|
+
methods: spec.responder.methods,
|
|
169
|
+
terminalStyle: spec.terminalStyle ?? options.terminalStyle,
|
|
170
|
+
readBody: spec.responder.readBody,
|
|
171
|
+
},
|
|
172
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
173
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
174
|
+
}
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const routeSpec: BakedRoute = {
|
|
179
|
+
read: spec.read ? handler : undefined,
|
|
180
|
+
write: spec.write ? handler : undefined,
|
|
181
|
+
echo: spec.echo ? handler : undefined,
|
|
182
|
+
cookies: spec.cookies ? handler : undefined,
|
|
183
|
+
delete: spec.delete ? handler : undefined,
|
|
184
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
185
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
186
|
+
}
|
|
187
|
+
bakedRoutes[path] = routeSpec
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { routes } = buildRouteHandlers({ ...baseOpts, routes: bakedRoutes })
|
|
191
|
+
const matcher = buildPathMatcher(routes)
|
|
192
|
+
|
|
193
|
+
const prewarm = (): void => {
|
|
194
|
+
// Run one probe GET through each compiled handler so the packed pipeline,
|
|
195
|
+
// header plan and FFI ingress call are JIT-warmed before the first real
|
|
196
|
+
// request. `run` never throws for a bad request (native failures become a
|
|
197
|
+
// 500 via setInternalError), so this is safe on any handler.
|
|
198
|
+
for (const handler of Object.values(compiled)) {
|
|
199
|
+
const probe = new Request('http://localhost:0/prewarm', { method: 'GET' })
|
|
200
|
+
handler.run<unknown>(
|
|
201
|
+
probe,
|
|
202
|
+
'127.0.0.1',
|
|
203
|
+
null,
|
|
204
|
+
(_result: BakedIngressResult, _ctx: BakedContext) => 0,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (options.warmOnCreate === true) {
|
|
210
|
+
prewarm()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const fetch = (req: Request, srv?: unknown): Response | Promise<Response> => {
|
|
214
|
+
let pathname: string
|
|
215
|
+
try {
|
|
216
|
+
pathname = new URL(req.url).pathname
|
|
217
|
+
} catch {
|
|
218
|
+
return new Response('Bad Request', { status: 400 })
|
|
219
|
+
}
|
|
220
|
+
const matched = matcher(pathname)
|
|
221
|
+
if (!matched) {
|
|
222
|
+
return new Response('Not Found', { status: 404 })
|
|
223
|
+
}
|
|
224
|
+
const methodHandler = matched.methods[req.method]
|
|
225
|
+
if (typeof methodHandler === 'function') {
|
|
226
|
+
return (methodHandler as RouteHandler)(req, srv, matched.params)
|
|
227
|
+
}
|
|
228
|
+
// A route matched but this method isn't wired — 405, or a bare OPTIONS 204
|
|
229
|
+
// when the route answers preflights.
|
|
230
|
+
if (req.method === 'OPTIONS' && typeof matched.methods.OPTIONS === 'function') {
|
|
231
|
+
return (matched.methods.OPTIONS as RouteHandler)(req, srv, matched.params)
|
|
232
|
+
}
|
|
233
|
+
return new Response('Method Not Allowed', { status: 405 })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { routeHandlers: compiled, routes, match: matcher, fetch, prewarm }
|
|
237
|
+
}
|