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.
Files changed (102) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1374 -1772
  4. package/dist/index.js +5570 -4169
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +106 -14
  7. package/docs/BENCHMARKS.md +83 -47
  8. package/docs/CASE_STUDY.md +240 -0
  9. package/docs/ENVIRONMENT.md +8 -6
  10. package/docs/FFI_BUN_GUIDE.md +327 -0
  11. package/docs/GETTING_STARTED.md +15 -32
  12. package/docs/INGRESS-ROUTER.md +81 -0
  13. package/docs/INGRESS.md +5 -4
  14. package/docs/NATIVE-ROUTE.md +53 -0
  15. package/docs/REPO_MAP.md +46 -26
  16. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  17. package/docs/bun-builtins-decision-matrix.md +25 -18
  18. package/index.ts +9 -16
  19. package/package.json +36 -24
  20. package/src/ingress/body.ts +167 -27
  21. package/src/ingress/handlers.ts +91 -15
  22. package/src/ingress/health.ts +1 -0
  23. package/src/ingress/index.ts +39 -27
  24. package/src/ingress/packing/gather-raw-headers.ts +85 -21
  25. package/src/ingress/packing/header-packing.ts +5 -5
  26. package/src/ingress/packing/input-packer.ts +17 -3
  27. package/src/ingress/packing/select-headers.ts +12 -6
  28. package/src/ingress/router.ts +237 -0
  29. package/src/ingress/routes/common.ts +42 -1
  30. package/src/ingress/routes/fallback.ts +2 -2
  31. package/src/ingress/routes/head.ts +3 -13
  32. package/src/ingress/routes/index.ts +5 -4
  33. package/src/ingress/routes/json-write.ts +7 -31
  34. package/src/ingress/routes/options.ts +2 -12
  35. package/src/ingress/routes/read.ts +3 -13
  36. package/src/ingress/routes/responder.ts +159 -0
  37. package/src/ingress/server-node.ts +6 -6
  38. package/src/ingress/server.ts +45 -13
  39. package/src/ingress/types.ts +35 -1
  40. package/src/integration/pipeline.ts +3 -0
  41. package/src/integration/streaming.ts +2 -3
  42. package/src/integration/websocket.ts +4 -3
  43. package/src/loader/batch.ts +4 -0
  44. package/src/loader/cost.ts +1 -0
  45. package/src/loader/index.ts +43 -228
  46. package/src/loader/state.ts +84 -0
  47. package/src/loader/types.ts +145 -0
  48. package/src/native/ffi/constants.ts +58 -0
  49. package/src/native/ffi/selftest.ts +729 -0
  50. package/src/native/ffi/types.ts +474 -0
  51. package/src/native/ffi.ts +1063 -829
  52. package/src/native/loader.ts +52 -6
  53. package/src/native/types/instances.ts +274 -0
  54. package/src/native/types.ts +41 -238
  55. package/src/runtime/builtins.ts +131 -0
  56. package/src/runtime/codec.ts +147 -0
  57. package/src/runtime/detect.ts +36 -0
  58. package/src/runtime/index.ts +28 -0
  59. package/src/runtime/native.ts +35 -0
  60. package/src/runtime/server.ts +31 -0
  61. package/src/runtime/transport.ts +64 -0
  62. package/src/runtime/types.ts +173 -0
  63. package/src/runtime/uuid.ts +45 -0
  64. package/src/runtime/websocket.ts +30 -0
  65. package/src/rust-ffi/addon.ts +1 -1
  66. package/src/rust-ffi/batch/build.ts +155 -0
  67. package/src/rust-ffi/batch/index.ts +8 -0
  68. package/src/rust-ffi/batch/types.ts +92 -0
  69. package/src/rust-ffi/client.ts +5 -6
  70. package/src/rust-ffi/context.ts +39 -10
  71. package/src/rust-ffi/index.ts +28 -17
  72. package/src/rust-ffi/into.ts +21 -0
  73. package/src/rust-ffi/options.ts +1 -1
  74. package/src/rust-ffi/packed.ts +2 -2
  75. package/src/rust-ffi/proven.ts +16 -30
  76. package/src/rust-ffi/scalar/crypto.ts +188 -107
  77. package/src/rust-ffi/scalar/factories.ts +253 -27
  78. package/src/rust-ffi/scalar/hashing.ts +43 -41
  79. package/src/rust-ffi/scalar/http.ts +208 -77
  80. package/src/rust-ffi/scalar/index.ts +3 -3
  81. package/src/rust-ffi/scalar/interface.ts +88 -213
  82. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  83. package/src/rust-ffi/scalar/json.ts +28 -12
  84. package/src/rust-ffi/scalar/payload.ts +159 -42
  85. package/src/rust-ffi/text.ts +31 -5
  86. package/src/selection.ts +27 -31
  87. package/src/shared/adaptive.ts +1 -2
  88. package/src/shared/buffer-pool.ts +72 -17
  89. package/src/shared/bytes.ts +75 -6
  90. package/src/shared/codec.ts +78 -0
  91. package/src/shared/env.ts +4 -4
  92. package/src/shared/log.ts +1 -0
  93. package/src/shared/packed/index.ts +10 -0
  94. package/src/shared/packed/parsers.ts +31 -0
  95. package/src/shared/packed/schema.ts +23 -0
  96. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  97. package/src/shared/proven.ts +189 -496
  98. package/src/shared/runtime.ts +20 -25
  99. package/src/shared/uuid.ts +12 -28
  100. package/src/data/json-rows.ts +0 -33
  101. package/src/rust-ffi/batch.ts +0 -431
  102. package/src/shared/bench-classify.ts +0 -209
@@ -7,10 +7,10 @@
7
7
  // guards so oversized values are dropped rather than forwarded.
8
8
 
9
9
  import type { HeaderPlan } from '../shared'
10
+ import { METHOD_KIND } from '../shared'
10
11
  import { writeHeaderPair } from './header-packing'
11
- import { forEachSelectedHeader } from './select-headers'
12
- import { getHeaderBuf } from './scratch'
13
- import { decoder } from '../../shared/bytes'
12
+ import { forEachSelectedHeader, HDR_ORIGIN } from './select-headers'
13
+ import { getHeaderBuf, MAX_SMALL_HEADER_BYTES } from './scratch'
14
14
 
15
15
  // Shared per-header size guards (single source of truth in scratch.ts).
16
16
  // Re-exported for back-compat with imports that referenced them here.
@@ -20,32 +20,29 @@ export {
20
20
  MAX_XFF_HEADER_BYTES,
21
21
  } from './scratch'
22
22
 
23
- /**
24
- * Gather the request headers selected by `plan` as a `[name, value][]` array.
25
- * `methodKind` is the native method-kind enum (see `METHOD_KIND`).
26
- */
27
- export function gatherRawHeaders(
28
- req: Request,
29
- plan: HeaderPlan,
30
- methodKind: number,
31
- ): [string, string][] {
32
- const headers: [string, string][] = []
33
- forEachSelectedHeader(req, plan, methodKind, undefined, (name, value) => {
34
- headers.push([decoder.decode(name), value])
35
- })
36
- return headers
37
- }
38
-
39
23
  /**
40
24
  * Gather the request headers selected by `plan` as a packed byte block
41
25
  * (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
42
26
  * reusable thread-local buffer.
43
27
  *
44
- * This is the zero-intermediate-string equivalent of [`gatherRawHeaders`] for
45
- * the packed-input pipeline: identical header selection and per-header size
28
+ * This is the zero-intermediate-string equivalent of the legacy string-array
29
+ * header gathering (a test-local reference in header-packing.test.ts) for the
30
+ * packed-input pipeline: identical header selection and per-header size
46
31
  * guards, but no `[string, string][]` array and no napi string marshaling.
47
32
  * The returned view is valid only until the next call on this call site.
48
33
  *
34
+ * A plan that selects NO headers (no cookie/cors/proxy/proto) short-circuits
35
+ * to a shared empty block — skipping the scratch-buffer round-robin, the
36
+ * `setUint16` and the per-call `subarray` entirely (the common minimal-route
37
+ * case).
38
+ *
39
+ * A CORS-ONLY plan on a non-preflight request (the dominant minimal-route
40
+ * case, e.g. the benchmark server) produces a block that depends ONLY on the
41
+ * `Origin` header value — which is constant per deployment. Such blocks are
42
+ * cached keyed by origin, so the per-request UTF-8 encode of the origin (the
43
+ * largest single JS packing cost) and the scratch write are skipped entirely;
44
+ * the cache is bounded (FIFO eviction) so multi-origin deployments stay safe.
45
+ *
49
46
  * @param originValue - When provided (non-undefined), the already-fetched
50
47
  * `Origin` header value to pack, avoiding a second `req.headers.get('origin')`
51
48
  * native→JS string conversion per request on the hot path (the caller, e.g.
@@ -59,6 +56,24 @@ export function gatherRawHeadersPacked(
59
56
  methodKind: number,
60
57
  originValue?: string | null,
61
58
  ): Uint8Array {
59
+ if (!plan.cookie && !plan.cors && !plan.proxy && !plan.proto) {
60
+ return EMPTY_HEADERS_BLOCK
61
+ }
62
+
63
+ // CORS-only fast path: the only selected header is the (typically constant)
64
+ // Origin, so the whole packed block is a pure function of the origin string.
65
+ // Skip the per-request encode + scratch write by returning the cached block.
66
+ // Preflight (OPTIONS) is excluded — it also packs ACRM/ACRH and must take
67
+ // the general path. The MAX_SMALL_HEADER_BYTES guard mirrors the general
68
+ // path (an oversized origin is dropped → empty block, not cached).
69
+ if (plan.cors && !plan.cookie && !plan.proxy && !plan.proto && methodKind !== METHOD_KIND.OPTIONS) {
70
+ const origin = originValue !== undefined ? originValue : req.headers.get('origin')
71
+ if (origin === null || origin.length > MAX_SMALL_HEADER_BYTES) {
72
+ return EMPTY_HEADERS_BLOCK
73
+ }
74
+ return cachedOriginBlock(origin)
75
+ }
76
+
62
77
  let [buf, view] = getHeaderBuf()
63
78
  let pos = 2
64
79
  let count = 0
@@ -70,6 +85,55 @@ export function gatherRawHeadersPacked(
70
85
 
71
86
  forEachSelectedHeader(req, plan, methodKind, originValue, write)
72
87
 
88
+ // No selected header was actually present (e.g. a route with cookie+cors in
89
+ // its plan but a request that sends neither) — return the shared empty block
90
+ // instead of touching the scratch buffer again (setUint16 + subarray) on the
91
+ // hot path.
92
+ if (count === 0) {
93
+ return EMPTY_HEADERS_BLOCK
94
+ }
95
+
73
96
  view.setUint16(0, count, true)
74
97
  return buf.subarray(0, pos)
75
98
  }
99
+
100
+ /** Shared `[0, 0]` (zero headers) block — returned when a plan selects nothing.
101
+ * Callers treat the result as read-only (the packers copy it verbatim). */
102
+ const EMPTY_HEADERS_BLOCK = new Uint8Array(2)
103
+
104
+ /**
105
+ * Cached packed blocks for the CORS-only case, keyed by origin string.
106
+ * Bounded (FIFO eviction) — a multi-origin deployment never grows it
107
+ * unboundedly, and a single-origin deployment hits a stable single entry.
108
+ * Module state is per-thread in Bun workers, so no cross-thread aliasing.
109
+ */
110
+ const ORIGIN_BLOCK_CACHE = new Map<string, Uint8Array>()
111
+ const ORIGIN_CACHE_MAX = 8
112
+
113
+ /**
114
+ * Return (building and caching on first use) the packed header block for a
115
+ * CORS-only plan whose only selected header is `origin`.
116
+ *
117
+ * The block is byte-identical to what the general path would produce
118
+ * (`[u16 1][u16 name_len]['origin'][u32 val_len][value]`), built once with the
119
+ * shared `writeHeaderPair` and copied out of the scratch buffer into a stable
120
+ * allocation — never aliased to the round-robin scratch, so it stays valid
121
+ * across subsequent gathers.
122
+ */
123
+ function cachedOriginBlock(origin: string): Uint8Array {
124
+ const hit = ORIGIN_BLOCK_CACHE.get(origin)
125
+ if (hit !== undefined) return hit
126
+
127
+ let [buf, view] = getHeaderBuf()
128
+ let pos = 2
129
+ ;[pos, buf, view] = writeHeaderPair(buf, view, pos, HDR_ORIGIN, origin)
130
+ view.setUint16(0, 1, true)
131
+ const block = buf.slice(0, pos)
132
+
133
+ if (ORIGIN_BLOCK_CACHE.size >= ORIGIN_CACHE_MAX) {
134
+ const firstKey = ORIGIN_BLOCK_CACHE.keys().next().value
135
+ if (firstKey !== undefined) ORIGIN_BLOCK_CACHE.delete(firstKey)
136
+ }
137
+ ORIGIN_BLOCK_CACHE.set(origin, block)
138
+ return block
139
+ }
@@ -5,12 +5,11 @@
5
5
  // scratch buffers keeps this allocation-free on the hot path while isolating
6
6
  // the buffers per call-site sequence.
7
7
 
8
- import { METHOD_KIND, METHOD_KIND_UNKNOWN, type HeaderPlan } from '../shared'
8
+ import { encodeUtf8Into } from '../../shared/codec'
9
+ import { type HeaderPlan, METHOD_KIND, METHOD_KIND_UNKNOWN } from '../shared'
9
10
  import { getHeaderBuf } from './scratch'
10
11
  import { forEachSelectedHeader } from './select-headers'
11
12
 
12
- const encoder = new TextEncoder()
13
-
14
13
  /**
15
14
  * Write one header pair into `buf`/`view`, growing them if needed.
16
15
  *
@@ -43,8 +42,9 @@ export function writeHeaderPair(
43
42
  const valueLenPos = pos
44
43
  pos += 4
45
44
 
46
- const dest = buf.subarray(pos)
47
- const { written } = encoder.encodeInto(value, dest)
45
+ // Number-returning codec write directly at `pos` (no `{read, written}`
46
+ // object, no intermediate `subarray` — both were per-header allocations).
47
+ const written = encodeUtf8Into(value, buf, pos)
48
48
 
49
49
  view.setUint32(valueLenPos, written, true)
50
50
  pos += written
@@ -4,7 +4,8 @@
4
4
  // headers) into a single growable Uint8Array in the layout the native
5
5
  // `Ingress.handleRequestPacked` expects.
6
6
 
7
- const encoder = new TextEncoder()
7
+ import { encoder } from '../../shared/bytes'
8
+
8
9
  const EMPTY_BYTES = new Uint8Array(0)
9
10
  const EMPTY_IP_BYTES = encoder.encode('0.0.0.0')
10
11
 
@@ -12,6 +13,10 @@ const EMPTY_IP_BYTES = encoder.encode('0.0.0.0')
12
13
  export class IngressInputPacker {
13
14
  private buf: Uint8Array
14
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
15
20
  private pos = 0
16
21
 
17
22
  constructor(initialSize = 65536) {
@@ -33,6 +38,7 @@ export class IngressInputPacker {
33
38
 
34
39
  this.buf = next
35
40
  this.view = new DataView(next.buffer)
41
+ this.bufView = null
36
42
  }
37
43
 
38
44
  private writeU8(value: number): void {
@@ -76,8 +82,16 @@ export class IngressInputPacker {
76
82
  this.ensure(4 + value.length * 3)
77
83
  const valueLenPos = this.pos
78
84
  this.pos += 4
79
- const dest = this.buf.subarray(this.pos)
80
- const { written } = encoder.encodeInto(value, dest)
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')
81
95
  this.view.setUint32(valueLenPos, written, true)
82
96
  this.pos += written
83
97
  }
@@ -1,29 +1,35 @@
1
1
  // src/ingress/packing/select-headers.ts — Shared header-selection iterator.
2
2
  //
3
3
  // Single source of truth for "which headers does a HeaderPlan select" across
4
- // the THREE packing paths:
4
+ // the TWO packing paths (a string-array reference variant lives test-local in
5
+ // header-packing.test.ts):
5
6
  // - `packHeaders` (fast path, ./header-packing.ts)
6
7
  // - `gatherRawHeadersPacked` (pre-baked packed path, ./gather-raw-headers.ts)
7
- // - `gatherRawHeaders` (pre-baked string-array reference, ./gather-raw-headers.ts)
8
8
  //
9
- // All three apply the SAME selection rules and the SAME per-header size guards
9
+ // Both apply the SAME selection rules and the SAME per-header size guards
10
10
  // (an oversized value is dropped rather than forwarded, so the packed block can
11
11
  // never exceed the native `max_headers_bytes` (65536) and 500). This module
12
12
  // owns that logic plus the pre-encoded header-name bytes, so a rule or size
13
13
  // change cannot drift between the paths.
14
14
 
15
- import { METHOD_KIND, type HeaderPlan } from '../shared'
15
+ import { encoder } from '../../shared/bytes'
16
+ import { type HeaderPlan, METHOD_KIND } from '../shared'
16
17
  import { MAX_COOKIE_HEADER_BYTES, MAX_SMALL_HEADER_BYTES, MAX_XFF_HEADER_BYTES } from './scratch'
17
18
 
18
- const encoder = new TextEncoder()
19
-
20
19
  // ── Header name constants (pre-encoded) ────────────────────────
20
+ /** Pre-encoded `cookie` header name. */
21
21
  export const HDR_COOKIE = encoder.encode('cookie')
22
+ /** Pre-encoded `origin` header name. */
22
23
  export const HDR_ORIGIN = encoder.encode('origin')
24
+ /** Pre-encoded `access-control-request-method` header name. */
23
25
  export const HDR_ACRM = encoder.encode('access-control-request-method')
26
+ /** Pre-encoded `access-control-request-headers` header name. */
24
27
  export const HDR_ACRH = encoder.encode('access-control-request-headers')
28
+ /** Pre-encoded `x-forwarded-for` header name. */
25
29
  export const HDR_XFF = encoder.encode('x-forwarded-for')
30
+ /** Pre-encoded `x-real-ip` header name. */
26
31
  export const HDR_XRI = encoder.encode('x-real-ip')
32
+ /** Pre-encoded `x-forwarded-proto` header name. */
27
33
  export const HDR_XFP = encoder.encode('x-forwarded-proto')
28
34
 
29
35
  /**
@@ -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
+ }
@@ -1,6 +1,8 @@
1
1
  // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
2
 
3
- import type { OptimizedIngressHandler } from '../types'
3
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
4
+ import type { BakedIngressResult } from '../decode/baked-result'
5
+ import { secondsFromMs } from '../shared'
4
6
 
5
7
  /** Options accepted by the pre-baked route factories. */
6
8
  export interface BakedHandlerOptions {
@@ -36,3 +38,42 @@ export function resolveIp(
36
38
  ): string | undefined {
37
39
  return opts.getIp ? opts.getIp(req, srv) : undefined
38
40
  }
41
+
42
+ /**
43
+ * Build the success `ResponseInit` (status + ratelimit headers) shared by
44
+ * every pre-baked success path. A plain function call, so the hot path keeps
45
+ * its single init-object allocation and nothing else.
46
+ *
47
+ * @param status Success status; defaults to 200.
48
+ */
49
+ export function buildSuccessInit(
50
+ ingress: OptimizedIngressHandler,
51
+ result: BakedIngressResult,
52
+ ctx: BakedContext,
53
+ status = 200,
54
+ ): ResponseInit {
55
+ return {
56
+ status,
57
+ headers: ingress.responseHeaders(
58
+ result.headerVariant,
59
+ ctx.requestIdHeader,
60
+ ctx.origin,
61
+ result.rateRemaining,
62
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
63
+ ),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Wrap a `(result, ctx) => Response` responder in the shared sync route
69
+ * runner: resolve the client IP once and dispatch through the native pipeline
70
+ * (`ingress.run` with a null body). Used by the read / head / fallback route
71
+ * factories whose responder never needs the request object.
72
+ */
73
+ export function runBaked(
74
+ ingress: OptimizedIngressHandler,
75
+ opts: BakedHandlerOptions,
76
+ respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
77
+ ): (req: Request, srv?: unknown) => Response {
78
+ return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
79
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { BakedContext, OptimizedIngressHandler } from '../types'
4
4
  import type { BakedIngressResult } from '../decode/baked-result'
5
- import { resolveIp, type BakedHandlerOptions } from './common'
5
+ import { runBaked, type BakedHandlerOptions } from './common'
6
6
 
7
7
  /**
8
8
  * Pre-baked fallback handler: 404 for unmatched routes / OPTIONS.
@@ -19,5 +19,5 @@ export function fallbackHandler(
19
19
  return ingress.errorResponse(undefined, result, 404, 'not_found', 'Not found', ctx)
20
20
  }
21
21
 
22
- return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
22
+ return runBaked(ingress, opts, respond)
23
23
  }
@@ -2,8 +2,7 @@
2
2
 
3
3
  import type { BakedContext, OptimizedIngressHandler } from '../types'
4
4
  import type { BakedIngressResult } from '../decode/baked-result'
5
- import { resolveIp, type BakedHandlerOptions } from './common'
6
- import { secondsFromMs } from '../shared'
5
+ import { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
7
6
 
8
7
  /**
9
8
  * Pre-baked HEAD read handler: headers only, no body.
@@ -21,17 +20,8 @@ export function headHandler(
21
20
  return ingress.internalErrorResponse(ctx, result)
22
21
  }
23
22
 
24
- return new Response(null, {
25
- status: 200,
26
- headers: ingress.responseHeaders(
27
- result.headerVariant,
28
- ctx.requestIdHeader,
29
- ctx.origin,
30
- result.rateRemaining,
31
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
32
- ),
33
- })
23
+ return new Response(null, buildSuccessInit(ingress, result, ctx))
34
24
  }
35
25
 
36
- return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
26
+ return runBaked(ingress, opts, respond)
37
27
  }
@@ -1,10 +1,11 @@
1
1
  // src/ingress/routes/index.ts — Route-handler factory composer.
2
2
 
3
3
  export * from './common'
4
- export * from './read'
4
+ export * from './delete'
5
+ export * from './echo'
6
+ export * from './fallback'
5
7
  export * from './head'
6
8
  export * from './json-write'
7
- export * from './echo'
8
- export * from './delete'
9
9
  export * from './options'
10
- export * from './fallback'
10
+ export * from './read'
11
+ export * from './responder'
@@ -2,8 +2,8 @@
2
2
  // (POST/PUT/PATCH).
3
3
 
4
4
  import type { OptimizedIngressHandler } from '../types'
5
- import { resolveIp, type BakedHandlerOptions } from './common'
6
- import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS, secondsFromMs } from '../shared'
5
+ import { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
6
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from '../shared'
7
7
  import { readBodyWithLimit } from '../body'
8
8
  import { getAddon } from '../../native'
9
9
  import { getBunFFI } from '../../native/ffi'
@@ -60,25 +60,10 @@ export function jsonWriteHandler(
60
60
  })
61
61
  }
62
62
 
63
- const contentLengthHeader = req.headers.get('content-length')
64
- const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
65
-
66
- if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
67
- return fallback.run(req, ip, null, (result, ctx) => {
68
- const terminal = fallback.terminalResponse(req, result, ctx)
69
- if (terminal) return terminal
70
-
71
- return fallback.errorResponse(
72
- req,
73
- result,
74
- 413,
75
- 'body_too_large',
76
- 'Request body is too large',
77
- ctx,
78
- )
79
- })
80
- }
81
-
63
+ // Content-Length / body-size enforcement happens INSIDE readBodyWithLimit
64
+ // (guard on): the declared length is checked before reading and re-checked
65
+ // after, so a client lying about Content-Length still gets a 413. Not
66
+ // re-reading the header here saves a per-POST headers.get on the hot path.
82
67
  let bodyBytes: Uint8Array
83
68
  try {
84
69
  // Stream-read with the limit enforced WHILE reading — a body that
@@ -134,16 +119,7 @@ export function jsonWriteHandler(
134
119
  return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
135
120
  }
136
121
 
137
- const init: ResponseInit = {
138
- status: 200,
139
- headers: ingress.responseHeaders(
140
- result.headerVariant,
141
- ctx.requestIdHeader,
142
- ctx.origin,
143
- result.rateRemaining,
144
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
145
- ),
146
- }
122
+ const init = buildSuccessInit(ingress, result, ctx)
147
123
 
148
124
  return copyBody
149
125
  ? new Response(result.bodyJson(true), init)