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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -368
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +2763 -0
  5. package/dist/index.js +8707 -0
  6. package/docs/API.md +337 -0
  7. package/docs/ARCHITECTURE.md +507 -0
  8. package/docs/BENCHMARKS.md +343 -0
  9. package/docs/CASE_STUDY.md +240 -0
  10. package/docs/ENVIRONMENT.md +81 -0
  11. package/docs/FFI_BUN_GUIDE.md +327 -0
  12. package/docs/GETTING_STARTED.md +192 -0
  13. package/docs/INGRESS-ROUTER.md +81 -0
  14. package/docs/INGRESS.md +746 -0
  15. package/docs/NATIVE-ROUTE.md +53 -0
  16. package/docs/REPO_MAP.md +279 -0
  17. package/docs/adr/0001-two-wire-formats.md +39 -0
  18. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  19. package/docs/adr/0003-bun-builtins-delegation.md +43 -0
  20. package/docs/bun-builtins-decision-matrix.md +115 -0
  21. package/index.ts +44 -8
  22. package/package.json +98 -33
  23. package/src/ingress/body.ts +289 -0
  24. package/src/ingress/constants.ts +220 -48
  25. package/src/ingress/context.ts +136 -0
  26. package/src/ingress/decode/baked-result.ts +121 -0
  27. package/src/ingress/decode/fast-result.ts +160 -0
  28. package/src/ingress/decode/packed-sections.ts +56 -0
  29. package/src/ingress/decode/result-base.ts +172 -0
  30. package/src/ingress/errors.ts +72 -0
  31. package/src/ingress/fast.ts +152 -1021
  32. package/src/ingress/handlers.ts +639 -1001
  33. package/src/ingress/headers/baked-templates.ts +102 -0
  34. package/src/ingress/headers/cors.ts +44 -0
  35. package/src/ingress/headers/fast-templates.ts +238 -0
  36. package/src/ingress/headers/hsts.ts +83 -0
  37. package/src/ingress/health.ts +68 -0
  38. package/src/ingress/index.ts +142 -405
  39. package/src/ingress/metrics.ts +132 -0
  40. package/src/ingress/options.ts +197 -0
  41. package/src/ingress/packing/gather-raw-headers.ts +139 -0
  42. package/src/ingress/packing/header-packing.ts +75 -0
  43. package/src/ingress/packing/input-packer.ts +164 -0
  44. package/src/ingress/packing/scratch.ts +50 -0
  45. package/src/ingress/packing/select-headers.ts +102 -0
  46. package/src/ingress/response/error-bodies.ts +75 -0
  47. package/src/ingress/response/terminal.ts +59 -0
  48. package/src/ingress/router.ts +237 -0
  49. package/src/ingress/routes/common.ts +79 -0
  50. package/src/ingress/routes/delete.ts +14 -0
  51. package/src/ingress/routes/echo.ts +107 -0
  52. package/src/ingress/routes/fallback.ts +23 -0
  53. package/src/ingress/routes/head.ts +27 -0
  54. package/src/ingress/routes/index.ts +11 -0
  55. package/src/ingress/routes/json-write.ts +129 -0
  56. package/src/ingress/routes/options.ts +26 -0
  57. package/src/ingress/routes/read.ts +35 -0
  58. package/src/ingress/routes/responder.ts +159 -0
  59. package/src/ingress/server-node.ts +376 -0
  60. package/src/ingress/server.ts +475 -0
  61. package/src/ingress/shared.ts +132 -0
  62. package/src/ingress/status.ts +97 -0
  63. package/src/ingress/types.ts +191 -0
  64. package/src/integration/batch.ts +58 -0
  65. package/src/integration/index.ts +29 -0
  66. package/src/integration/pipeline.ts +220 -0
  67. package/src/integration/streaming.ts +78 -0
  68. package/src/integration/websocket.ts +93 -0
  69. package/src/loader/batch.ts +137 -0
  70. package/src/loader/cost.ts +105 -0
  71. package/src/loader/index.ts +745 -0
  72. package/src/loader/ops.ts +657 -0
  73. package/src/loader/state.ts +84 -0
  74. package/src/loader/types.ts +145 -0
  75. package/src/native/ffi/constants.ts +58 -0
  76. package/src/native/ffi/selftest.ts +729 -0
  77. package/src/native/ffi/types.ts +474 -0
  78. package/src/native/ffi.ts +1775 -0
  79. package/src/native/index.ts +8 -207
  80. package/src/native/loader.ts +240 -0
  81. package/src/native/types/instances.ts +274 -0
  82. package/src/native/types.ts +401 -0
  83. package/src/runtime/builtins.ts +131 -0
  84. package/src/runtime/codec.ts +147 -0
  85. package/src/runtime/detect.ts +36 -0
  86. package/src/runtime/index.ts +28 -0
  87. package/src/runtime/native.ts +35 -0
  88. package/src/runtime/server.ts +31 -0
  89. package/src/runtime/transport.ts +64 -0
  90. package/src/runtime/types.ts +173 -0
  91. package/src/runtime/uuid.ts +45 -0
  92. package/src/runtime/websocket.ts +30 -0
  93. package/src/rust-ffi/addon.ts +10 -0
  94. package/src/rust-ffi/batch/build.ts +155 -0
  95. package/src/rust-ffi/batch/index.ts +8 -0
  96. package/src/rust-ffi/batch/types.ts +92 -0
  97. package/src/rust-ffi/client.ts +87 -0
  98. package/src/rust-ffi/context.ts +193 -0
  99. package/src/rust-ffi/index.ts +51 -541
  100. package/src/rust-ffi/into.ts +21 -0
  101. package/src/rust-ffi/options.ts +113 -0
  102. package/src/rust-ffi/packed.ts +253 -0
  103. package/src/rust-ffi/proven.ts +28 -0
  104. package/src/rust-ffi/scalar/crypto.ts +346 -0
  105. package/src/rust-ffi/scalar/factories.ts +459 -0
  106. package/src/rust-ffi/scalar/hashing.ts +74 -0
  107. package/src/rust-ffi/scalar/http.ts +337 -0
  108. package/src/rust-ffi/scalar/index.ts +31 -0
  109. package/src/rust-ffi/scalar/interface.ts +325 -0
  110. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  111. package/src/rust-ffi/scalar/json.ts +61 -0
  112. package/src/rust-ffi/scalar/payload.ts +241 -0
  113. package/src/rust-ffi/text.ts +121 -0
  114. package/src/selection.json +194 -0
  115. package/src/selection.ts +73 -0
  116. package/src/shared/adaptive.ts +67 -0
  117. package/src/shared/buffer-pool.ts +264 -0
  118. package/src/shared/bytes.ts +108 -9
  119. package/src/shared/codec.ts +78 -0
  120. package/src/shared/env.ts +20 -0
  121. package/src/shared/log.ts +75 -0
  122. package/src/shared/metrics.ts +247 -0
  123. package/src/shared/packed/index.ts +10 -0
  124. package/src/shared/packed/parsers.ts +31 -0
  125. package/src/shared/packed/schema.ts +23 -0
  126. package/src/shared/packed/wire.ts +504 -0
  127. package/src/shared/proven.ts +289 -0
  128. package/src/shared/request-id.ts +67 -0
  129. package/src/shared/response.ts +104 -0
  130. package/src/shared/runtime.ts +29 -0
  131. package/src/shared/trace.ts +81 -0
  132. package/src/shared/uuid.ts +15 -0
  133. package/Cargo.toml +0 -73
  134. package/bench.ts +0 -3
  135. package/rust/batch.rs +0 -195
  136. package/rust/cookie_parser.rs +0 -70
  137. package/rust/cors.rs +0 -263
  138. package/rust/hashing.rs +0 -164
  139. package/rust/headers.rs +0 -234
  140. package/rust/hmac_sha256.rs +0 -113
  141. package/rust/http_parser.rs +0 -132
  142. package/rust/ingress.rs +0 -616
  143. package/rust/ingress_constants.rs +0 -89
  144. package/rust/ip_trust.rs +0 -185
  145. package/rust/json_ops.rs +0 -66
  146. package/rust/json_patch_ops.rs +0 -25
  147. package/rust/json_schema.rs +0 -151
  148. package/rust/json_ser.rs +0 -429
  149. package/rust/lib.rs +0 -48
  150. package/rust/method.rs +0 -141
  151. package/rust/mime_lookup.rs +0 -76
  152. package/rust/output.rs +0 -238
  153. package/rust/proxy.rs +0 -90
  154. package/rust/query_parser.rs +0 -141
  155. package/rust/random_token.rs +0 -52
  156. package/rust/rate_limit.rs +0 -203
  157. package/rust/terminal.rs +0 -95
  158. package/rust/unit_tests.rs +0 -843
  159. package/rust/url_codec.rs +0 -133
  160. package/rust/util.rs +0 -537
  161. package/rust/validation.rs +0 -107
  162. package/rust/websocket.rs +0 -23
  163. package/src/baseline/index.ts +0 -12
  164. package/src/baseline/tasks/cookie.ts +0 -22
  165. package/src/baseline/tasks/hashing.ts +0 -18
  166. package/src/baseline/tasks/hmac.ts +0 -38
  167. package/src/baseline/tasks/http.ts +0 -62
  168. package/src/baseline/tasks/json-patch.ts +0 -12
  169. package/src/baseline/tasks/json.ts +0 -50
  170. package/src/baseline/tasks/mime.ts +0 -5
  171. package/src/baseline/tasks/query.ts +0 -14
  172. package/src/baseline/tasks/token.ts +0 -15
  173. package/src/baseline/tasks/url.ts +0 -11
  174. package/src/baseline/tasks/validation.ts +0 -19
  175. package/src/baseline/tasks/websocket.ts +0 -14
  176. package/src/bench/assert.ts +0 -70
  177. package/src/bench/checks.ts +0 -227
  178. package/src/bench/checksum.ts +0 -27
  179. package/src/bench/comparisons.ts +0 -70
  180. package/src/bench/concurrent-worker.ts +0 -135
  181. package/src/bench/fixtures.ts +0 -233
  182. package/src/bench/index.ts +0 -2
  183. package/src/bench/measure.ts +0 -177
  184. package/src/bench/now.ts +0 -3
  185. package/src/bench/report.ts +0 -79
  186. package/src/bench/run.ts +0 -57
  187. package/src/bench/tasks/complex.ts +0 -232
  188. package/src/bench/tasks/concurrent.ts +0 -213
  189. package/src/bench/tasks/cookie.ts +0 -44
  190. package/src/bench/tasks/hashing.ts +0 -33
  191. package/src/bench/tasks/hmac.ts +0 -36
  192. package/src/bench/tasks/http.ts +0 -50
  193. package/src/bench/tasks/index.ts +0 -52
  194. package/src/bench/tasks/json-patch.ts +0 -21
  195. package/src/bench/tasks/json.ts +0 -33
  196. package/src/bench/tasks/mime.ts +0 -21
  197. package/src/bench/tasks/query.ts +0 -44
  198. package/src/bench/tasks/stress.ts +0 -142
  199. package/src/bench/tasks/token.ts +0 -20
  200. package/src/bench/tasks/url.ts +0 -34
  201. package/src/bench/tasks/validation.ts +0 -57
  202. package/src/bench/tasks/websocket.ts +0 -21
  203. package/src/bench/types.ts +0 -60
  204. package/src/data/json-rows.ts +0 -33
  205. package/src/ingress/packed-input.ts +0 -59
  206. package/src/shared/json.ts +0 -18
  207. 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
+ }