castrum 0.9.0 → 0.9.2

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 (120) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1653 -1817
  4. package/dist/index.js +6172 -4294
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +111 -17
  7. package/docs/BENCHMARKS.md +117 -49
  8. package/docs/CASE_STUDY.md +243 -0
  9. package/docs/ENVIRONMENT.md +24 -7
  10. package/docs/FFI_BUN_GUIDE.md +335 -0
  11. package/docs/GETTING_STARTED.md +18 -34
  12. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  13. package/docs/INGRESS-ROUTER.md +82 -0
  14. package/docs/INGRESS.md +11 -7
  15. package/docs/NATIVE-ROUTE.md +77 -0
  16. package/docs/REPO_MAP.md +103 -53
  17. package/docs/adr/0001-two-wire-formats.md +1 -1
  18. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  19. package/docs/ai/LOCAL_DEV.md +81 -0
  20. package/docs/ai/TREE.md +1070 -0
  21. package/docs/bun-builtins-decision-matrix.md +25 -18
  22. package/index.ts +9 -16
  23. package/package.json +41 -28
  24. package/src/ingress/body.ts +167 -27
  25. package/src/ingress/decode/result-base.ts +10 -2
  26. package/src/ingress/handlers.ts +116 -285
  27. package/src/ingress/headers/security.ts +41 -0
  28. package/src/ingress/health.ts +1 -0
  29. package/src/ingress/index.ts +45 -169
  30. package/src/ingress/native-route.ts +182 -0
  31. package/src/ingress/packing/gather-raw-headers.ts +110 -22
  32. package/src/ingress/packing/header-packing.ts +5 -5
  33. package/src/ingress/packing/input-packer.ts +17 -3
  34. package/src/ingress/packing/route-wire.ts +197 -0
  35. package/src/ingress/packing/select-headers.ts +20 -7
  36. package/src/ingress/path-matcher.ts +115 -0
  37. package/src/ingress/response/baked-response.ts +302 -0
  38. package/src/ingress/router.ts +277 -0
  39. package/src/ingress/routes/common.ts +50 -1
  40. package/src/ingress/routes/fallback.ts +2 -2
  41. package/src/ingress/routes/head.ts +3 -13
  42. package/src/ingress/routes/index.ts +5 -4
  43. package/src/ingress/routes/json-write.ts +26 -47
  44. package/src/ingress/routes/native.ts +130 -0
  45. package/src/ingress/routes/options.ts +2 -12
  46. package/src/ingress/routes/read.ts +3 -13
  47. package/src/ingress/routes/responder.ts +159 -0
  48. package/src/ingress/server-node.ts +7 -7
  49. package/src/ingress/server.ts +83 -112
  50. package/src/ingress/sync.ts +151 -0
  51. package/src/ingress/types.ts +44 -1
  52. package/src/integration/pipeline.ts +3 -0
  53. package/src/integration/streaming.ts +2 -3
  54. package/src/integration/websocket.ts +4 -3
  55. package/src/loader/batch.ts +4 -0
  56. package/src/loader/cost.ts +1 -0
  57. package/src/loader/index.ts +43 -228
  58. package/src/loader/state.ts +84 -0
  59. package/src/loader/types.ts +145 -0
  60. package/src/native/ffi/build/codecs.ts +736 -0
  61. package/src/native/ffi/build/compress.ts +248 -0
  62. package/src/native/ffi/build/instances.ts +598 -0
  63. package/src/native/ffi/build/parse.ts +539 -0
  64. package/src/native/ffi/build/util.ts +140 -0
  65. package/src/native/ffi/build.ts +94 -0
  66. package/src/native/ffi/constants.ts +58 -0
  67. package/src/native/ffi/selftest.ts +22 -0
  68. package/src/native/ffi/types.ts +499 -0
  69. package/src/native/ffi.ts +307 -1357
  70. package/src/native/loader.ts +52 -6
  71. package/src/native/types/instances.ts +279 -0
  72. package/src/native/types.ts +41 -238
  73. package/src/runtime/builtins.ts +131 -0
  74. package/src/runtime/codec.ts +147 -0
  75. package/src/runtime/detect.ts +36 -0
  76. package/src/runtime/index.ts +28 -0
  77. package/src/runtime/native.ts +35 -0
  78. package/src/runtime/server.ts +31 -0
  79. package/src/runtime/transport.ts +64 -0
  80. package/src/runtime/types.ts +173 -0
  81. package/src/runtime/uuid.ts +45 -0
  82. package/src/runtime/websocket.ts +30 -0
  83. package/src/rust-ffi/addon.ts +1 -1
  84. package/src/rust-ffi/batch/build.ts +155 -0
  85. package/src/rust-ffi/batch/index.ts +8 -0
  86. package/src/rust-ffi/batch/types.ts +92 -0
  87. package/src/rust-ffi/client.ts +5 -6
  88. package/src/rust-ffi/context.ts +39 -10
  89. package/src/rust-ffi/index.ts +28 -17
  90. package/src/rust-ffi/into.ts +21 -0
  91. package/src/rust-ffi/options.ts +1 -1
  92. package/src/rust-ffi/packed.ts +40 -2
  93. package/src/rust-ffi/proven.ts +16 -30
  94. package/src/rust-ffi/scalar/crypto.ts +188 -107
  95. package/src/rust-ffi/scalar/factories.ts +257 -27
  96. package/src/rust-ffi/scalar/hashing.ts +43 -41
  97. package/src/rust-ffi/scalar/http.ts +208 -77
  98. package/src/rust-ffi/scalar/index.ts +3 -3
  99. package/src/rust-ffi/scalar/interface.ts +88 -213
  100. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  101. package/src/rust-ffi/scalar/json.ts +28 -12
  102. package/src/rust-ffi/scalar/payload.ts +159 -42
  103. package/src/rust-ffi/text.ts +31 -5
  104. package/src/selection.ts +27 -31
  105. package/src/shared/adaptive.ts +1 -2
  106. package/src/shared/buffer-pool.ts +72 -17
  107. package/src/shared/bytes.ts +75 -6
  108. package/src/shared/codec.ts +78 -0
  109. package/src/shared/env.ts +4 -4
  110. package/src/shared/log.ts +1 -0
  111. package/src/shared/packed/index.ts +10 -0
  112. package/src/shared/packed/parsers.ts +31 -0
  113. package/src/shared/packed/schema.ts +23 -0
  114. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  115. package/src/shared/proven.ts +189 -496
  116. package/src/shared/runtime.ts +20 -25
  117. package/src/shared/uuid.ts +12 -28
  118. package/src/data/json-rows.ts +0 -33
  119. package/src/rust-ffi/batch.ts +0 -431
  120. package/src/shared/bench-classify.ts +0 -209
@@ -0,0 +1,197 @@
1
+ // src/ingress/packing/route-wire.ts — route-wire v3 byte helpers (PURE).
2
+ //
3
+ // The per-route native stack (`rust/ingress/native_route.rs`,
4
+ // `castrum_route_*` / napi `Route`) compiles a descriptor ONCE and runs each
5
+ // request frame in ONE native call. This module owns the WIRE on the JS side:
6
+ // descriptor encoding, frame packing, and result decoding — byte-layout
7
+ // constants MUST match `rust/ingress/native_route.rs` and `@ignex/native`
8
+ // `route-wire.ts` EXACTLY (`ROUTE_DESC_VERSION` bumps on any layout change; a
9
+ // mismatched compiler/addon must be a hard reject, never a silent misparse).
10
+ //
11
+ // PURE: no addon import, no module state — safe for any consumer. The
12
+ // addon-touching factory lives in `src/ingress/native-route.ts`.
13
+
14
+ import { decoder, encoder } from '../../shared/bytes'
15
+
16
+ /** Route descriptor magic (`"ROUT"` LE). Must match `ROUTE_DESC_MAGIC` in Rust. */
17
+ export const ROUTE_DESC_MAGIC = 0x524f5554
18
+ /** Wire version — bump on ANY descriptor/frame/result layout change. */
19
+ export const ROUTE_DESC_VERSION = 3
20
+
21
+ /** Descriptor stage tags (the ordered pipeline a route instance runs). */
22
+ export const ROUTE_STAGE = {
23
+ parseQuery: 0,
24
+ parseCookies: 1,
25
+ validateQuery: 2,
26
+ validateCookies: 3,
27
+ validateBody: 4,
28
+ requireJsonBody: 5,
29
+ } as const
30
+ /** A descriptor stage tag (`ROUTE_STAGE` values, must match Rust). */
31
+ export type RouteStageTag = (typeof ROUTE_STAGE)[keyof typeof ROUTE_STAGE]
32
+
33
+ /** Descriptor part tags (`RoutePartKind`): the schema-bearing request parts. */
34
+ export const ROUTE_PART = { body: 3 } as const
35
+
36
+ /** Result flag bits (`ROUTE_RESULT_FLAG_*` in Rust). */
37
+ export const ROUTE_FLAG = {
38
+ OK: 1 << 0,
39
+ BODY_VALID_JSON: 1 << 1,
40
+ QUERY_VALID: 1 << 2,
41
+ COOKIE_VALID: 1 << 3,
42
+ BODY_VALID: 1 << 4,
43
+ } as const
44
+
45
+ /** Frame flag: the body section is present (bit 0 of the frame flags word). */
46
+ export const ROUTE_FRAME_FLAG_HAS_BODY = 1 << 0
47
+
48
+ /** Size limits a route descriptor carries. */
49
+ export interface RouteWireLimits {
50
+ maxBodyBytes: number
51
+ maxQueryBytes: number
52
+ maxCookieBytes: number
53
+ maxPairs: number
54
+ }
55
+
56
+ /** A schema part carried by the descriptor (part tag + draft-07 JSON bytes). */
57
+ export interface RouteWireSchema {
58
+ part: number
59
+ bytes: Uint8Array
60
+ }
61
+
62
+ /**
63
+ * Encode a route plan into the descriptor wire.
64
+ *
65
+ * Layout: `[magic u32][version u32][maxBody u32][maxQuery u32][maxCookie u32]
66
+ * [maxPairs u32][stageCount u32][stages u8…][schemaCount u32]
67
+ * { [part u8][len u32][schema] }…`
68
+ */
69
+ export function encodeRouteDescriptor(
70
+ pipeline: readonly RouteStageTag[],
71
+ schemas: readonly RouteWireSchema[],
72
+ limits: RouteWireLimits,
73
+ ): Uint8Array {
74
+ let total = 8 + 16 + 4 + pipeline.length + 4
75
+ for (const s of schemas) total += 1 + 4 + s.bytes.byteLength
76
+ const out = new Uint8Array(total)
77
+ const view = new DataView(out.buffer)
78
+ let pos = 0
79
+ view.setUint32(pos, ROUTE_DESC_MAGIC, true)
80
+ pos += 4
81
+ view.setUint32(pos, ROUTE_DESC_VERSION, true)
82
+ pos += 4
83
+ for (const v of [
84
+ limits.maxBodyBytes,
85
+ limits.maxQueryBytes,
86
+ limits.maxCookieBytes,
87
+ limits.maxPairs,
88
+ ]) {
89
+ view.setUint32(pos, v, true)
90
+ pos += 4
91
+ }
92
+ view.setUint32(pos, pipeline.length, true)
93
+ pos += 4
94
+ for (const stage of pipeline) {
95
+ out[pos] = stage
96
+ pos += 1
97
+ }
98
+ view.setUint32(pos, schemas.length, true)
99
+ pos += 4
100
+ for (const s of schemas) {
101
+ out[pos] = s.part
102
+ pos += 1
103
+ view.setUint32(pos, s.bytes.byteLength, true)
104
+ pos += 4
105
+ out.set(s.bytes, pos)
106
+ pos += s.bytes.byteLength
107
+ }
108
+ return out
109
+ }
110
+
111
+ /**
112
+ * Encode a request frame: `[flags u32][qLen][query][cLen][cookie]([bLen][body])`.
113
+ * The `flags` word currently only carries `ROUTE_FRAME_FLAG_HAS_BODY`.
114
+ */
115
+ export function packRouteFrame(
116
+ query: string,
117
+ cookie: string,
118
+ body: Uint8Array | null,
119
+ ): Uint8Array {
120
+ const q = encoder.encode(query)
121
+ const c = encoder.encode(cookie)
122
+ const hasBody = body !== null && body.byteLength > 0
123
+ const total =
124
+ 4 + 4 + q.byteLength + 4 + c.byteLength + (hasBody ? 4 + (body?.byteLength ?? 0) : 0)
125
+ const out = new Uint8Array(total)
126
+ const view = new DataView(out.buffer)
127
+ let pos = 0
128
+ view.setUint32(pos, hasBody ? ROUTE_FRAME_FLAG_HAS_BODY : 0, true)
129
+ pos += 4
130
+ view.setUint32(pos, q.byteLength, true)
131
+ pos += 4
132
+ out.set(q, pos)
133
+ pos += q.byteLength
134
+ view.setUint32(pos, c.byteLength, true)
135
+ pos += 4
136
+ out.set(c, pos)
137
+ pos += c.byteLength
138
+ if (hasBody && body) {
139
+ view.setUint32(pos, body.byteLength, true)
140
+ pos += 4
141
+ out.set(body, pos)
142
+ }
143
+ return out
144
+ }
145
+
146
+ /** A decoded `[name, value]` pair from a result section. */
147
+ export type RouteWirePair = [string, string]
148
+
149
+ /** The decoded route result: the verdict header + optional pair sections. */
150
+ export interface RouteWireResult {
151
+ /** `ROUTE_FLAG_*` bits from the result header. */
152
+ flags: number
153
+ /** `0` = ok; `400` = not-JSON under requireJsonBody; `422` = schema fail. */
154
+ errorCode: number
155
+ /** Decoded query pairs (present iff the plan compiled `parseQuery`). */
156
+ query: RouteWirePair[]
157
+ /** Decoded cookie pairs (present iff the plan compiled `parseCookies`). */
158
+ cookie: RouteWirePair[]
159
+ }
160
+
161
+ /**
162
+ * Decode the result wire: `[flags u32][errorCode u32]` + a query pair section
163
+ * iff `query` and a cookie pair section iff `cookie` (the caller knows its own
164
+ * plan). Sections are `[count u32] { [nameLen u32][name][valueLen u32][value] }`.
165
+ */
166
+ export function decodeRouteResult(
167
+ buf: Uint8Array,
168
+ opts: { query: boolean; cookie: boolean },
169
+ ): RouteWireResult {
170
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
171
+ const flags = view.getUint32(0, true)
172
+ const errorCode = view.getUint32(4, true)
173
+ let pos = 8
174
+ const readPairs = (): RouteWirePair[] => {
175
+ const count = view.getUint32(pos, true)
176
+ pos += 4
177
+ const out: RouteWirePair[] = []
178
+ for (let i = 0; i < count; i++) {
179
+ const nameLen = view.getUint32(pos, true)
180
+ pos += 4
181
+ const name = decoder.decode(buf.subarray(pos, pos + nameLen))
182
+ pos += nameLen
183
+ const valueLen = view.getUint32(pos, true)
184
+ pos += 4
185
+ const value = decoder.decode(buf.subarray(pos, pos + valueLen))
186
+ pos += valueLen
187
+ out.push([name, value])
188
+ }
189
+ return out
190
+ }
191
+ return {
192
+ flags,
193
+ errorCode,
194
+ query: opts.query ? readPairs() : [],
195
+ cookie: opts.cookie ? readPairs() : [],
196
+ }
197
+ }
@@ -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
  /**
@@ -39,6 +45,12 @@ export const HDR_XFP = encoder.encode('x-forwarded-proto')
39
45
  * fetched here. The same `MAX_SMALL_HEADER_BYTES` guard applies either way.
40
46
  * @param visit - Invoked once per selected header with the pre-encoded name
41
47
  * bytes and the raw value string.
48
+ * @param cookieValue - When provided (non-undefined), the already-fetched
49
+ * `Cookie` header value, avoiding a second `req.headers.get('cookie')`
50
+ * native→JS conversion (the caller's cookie+cors fast path already fetched
51
+ * it once to decide between the cached origin block and the general path).
52
+ * When omitted, the cookie is fetched here — so existing callers are
53
+ * byte-for-byte unchanged.
42
54
  */
43
55
  export function forEachSelectedHeader(
44
56
  req: Request,
@@ -46,11 +58,12 @@ export function forEachSelectedHeader(
46
58
  methodKind: number,
47
59
  originValue: string | null | undefined,
48
60
  visit: (name: Uint8Array, value: string) => void,
61
+ cookieValue?: string | null,
49
62
  ): void {
50
63
  const h = req.headers
51
64
 
52
65
  if (plan.cookie) {
53
- const v = h.get('cookie')
66
+ const v = cookieValue !== undefined ? cookieValue : h.get('cookie')
54
67
  if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
55
68
  visit(HDR_COOKIE, v)
56
69
  }
@@ -0,0 +1,115 @@
1
+ // src/ingress/path-matcher.ts — `:param` / `*` path matching for pre-baked routes
2
+ //
3
+ // The dynamic-path matcher shared by the Node adapter (server-node.ts) and the
4
+ // router (router.ts). Bun's native router matches dynamic paths itself, so this
5
+ // only runs on Node / route-manager paths. The ingress pipeline does not echo
6
+ // path params in its response, so matching only selects the handler.
7
+
8
+ /** A matched dynamic route: the handler map + the extracted path params. */
9
+ export interface PathMatch {
10
+ methods: Record<string, unknown>
11
+ params: Record<string, string>
12
+ }
13
+
14
+ interface CompiledDynamicPattern {
15
+ re: RegExp
16
+ paramNames: string[]
17
+ rest: boolean
18
+ methods: Record<string, unknown>
19
+ /** Number of literal (static) segments — used to prefer the most specific pattern. */
20
+ staticSegments: number
21
+ }
22
+
23
+ /**
24
+ * Build a path matcher over a route map, supporting `:param` and `*` (rest)
25
+ * segments. Exact (static) paths always win over dynamic patterns; dynamic
26
+ * patterns are ordered most-specific-first (most static segments, then fewest
27
+ * params). Used by `createIngressServerNode` (Bun's native router already
28
+ * matches dynamic patterns itself). The ingress pipeline does not echo path
29
+ * params in its response, so matching only selects the handler.
30
+ *
31
+ * @param routes the built route map (`path` → `method` → handler)
32
+ * @returns a matcher: `pathname` → `{ methods, params }` or `undefined`
33
+ */
34
+ export function buildPathMatcher(
35
+ routes: Record<string, Record<string, unknown>>,
36
+ ): (pathname: string) => PathMatch | undefined {
37
+ const exact = new Map<string, Record<string, unknown>>()
38
+ const dynamic: CompiledDynamicPattern[] = []
39
+
40
+ for (const [path, methods] of Object.entries(routes)) {
41
+ const hasParam = path.indexOf(':') !== -1
42
+ const hasRest = path.indexOf('*') !== -1
43
+ if (!hasParam && !hasRest) {
44
+ exact.set(path, methods)
45
+ continue
46
+ }
47
+
48
+ const paramNames: string[] = []
49
+ let rest = false
50
+ let staticSegments = 0
51
+ const parts = path.split('/').filter((s) => s.length > 0)
52
+ const source = parts
53
+ .map((seg) => {
54
+ if (seg === '*') {
55
+ rest = true
56
+ return '(.*)'
57
+ }
58
+ if (seg.startsWith(':')) {
59
+ paramNames.push(seg.slice(1))
60
+ return '([^/]+)'
61
+ }
62
+ staticSegments++
63
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
64
+ })
65
+ .join('/')
66
+
67
+ dynamic.push({
68
+ re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
69
+ paramNames,
70
+ rest,
71
+ methods,
72
+ staticSegments,
73
+ })
74
+ }
75
+
76
+ dynamic.sort(
77
+ (a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
78
+ )
79
+
80
+ return (pathname: string) => {
81
+ const exactHit = exact.get(pathname)
82
+ if (exactHit !== undefined) {
83
+ return { methods: exactHit, params: {} }
84
+ }
85
+ for (const d of dynamic) {
86
+ const m = d.re.exec(pathname)
87
+ if (m !== null) {
88
+ const params: Record<string, string> = {}
89
+ for (let i = 0; i < d.paramNames.length; i++) {
90
+ const raw = m[i + 1]
91
+ if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
92
+ }
93
+ if (d.rest) {
94
+ const restRaw = m[d.paramNames.length + 1]
95
+ if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
96
+ }
97
+ return { methods: d.methods, params }
98
+ }
99
+ }
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Percent-decode a matched path segment, tolerating malformed escapes: a bad
106
+ * `%` sequence is returned raw rather than throwing, so the matcher never
107
+ * rejects a request over a decode error.
108
+ */
109
+ export function safeDecode(value: string): string {
110
+ try {
111
+ return decodeURIComponent(value)
112
+ } catch {
113
+ return value
114
+ }
115
+ }
@@ -0,0 +1,302 @@
1
+ // src/ingress/response/baked-response.ts — pre-baked response builders (path 2)
2
+ //
3
+ // The seven response-builder methods of `OptimizedIngressHandler`, extracted
4
+ // from createIngressHandler (handlers.ts) so the factory stays focused on
5
+ // packing + pooling. Mirrors fast.ts's response/terminal.ts split — but emits
6
+ // the BENCH wire format (`{"ok":false,"error":{...}}` + `ratelimit-*` headers),
7
+ // NOT the fast path's `x-ratelimit-*` shape (see AGENTS.md — do not unify).
8
+ //
9
+ // The builders are bound to a handler's configuration (header templates,
10
+ // request-id emission, CORS cache) plus a shared mutable `BakedResponseState`
11
+ // that `run()` in handlers.ts owns: `zeroCopyResponse` marks the pooled
12
+ // output buffer as borrowed so `run()` does not release it before the body is
13
+ // consumed.
14
+
15
+ import type { PooledBuffer } from '../../shared/buffer-pool'
16
+ import { encoder } from '../../shared/bytes'
17
+ import { pooledBodyResponse } from '../../shared/response'
18
+ import {
19
+ ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
20
+ HV_CORS_PREFLIGHT,
21
+ HV_CORS_SIMPLE,
22
+ HV_JSON,
23
+ HV_RATE_ACTIVE,
24
+ HV_RATE_LIMITED,
25
+ } from '../constants'
26
+ import type { BakedIngressResult } from '../decode/baked-result'
27
+ import { secondsFromMs } from '../shared'
28
+ import { safeTerminalStatus } from '../status'
29
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
30
+ import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './error-bodies'
31
+
32
+ const EMPTY_BODY = new Uint8Array(0)
33
+
34
+ /** Shared per-request state between `run()` (handlers.ts) and the builders. */
35
+ export interface BakedResponseState {
36
+ /** The pooled output buffer backing the current `run()` (null outside one). */
37
+ currentHandle: PooledBuffer | null
38
+ /** True when a zero-copy Response claimed the buffer (run() must not release). */
39
+ responseBorrowsBuffer: boolean
40
+ }
41
+
42
+ /** Configuration the builder factory needs from a handler. */
43
+ export interface BakedResponseBuildersDeps {
44
+ /** Variant-indexed pre-baked header templates (regular responses). */
45
+ headerTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
46
+ /** Variant-indexed pre-baked terminal templates (JSON + no-store). */
47
+ terminalTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
48
+ /** Emit an `x-request-id` header on responses. */
49
+ emitRequestIdHeader: boolean
50
+ /** Abandonment guard (ms) for zero-copy responses. */
51
+ zeroCopyTimeoutMs: number
52
+ /** Cache of Origin-augmented header arrays keyed by `variant\u0000origin`. */
53
+ originHeaderCache: Map<string, [string, string][]>
54
+ /** Shared per-request state (owned by `run()` in handlers.ts). */
55
+ state: BakedResponseState
56
+ }
57
+
58
+ /**
59
+ * Build the seven response-builder methods of `OptimizedIngressHandler`
60
+ * (everything except `run`), bound to this handler's header templates and
61
+ * request-id policy. The returned methods are the exact bodies that lived in
62
+ * `createIngressHandler`.
63
+ */
64
+ export function buildBakedResponseBuilders(
65
+ deps: BakedResponseBuildersDeps,
66
+ ): Omit<OptimizedIngressHandler, 'run'> {
67
+ const {
68
+ headerTemplates,
69
+ terminalTemplates,
70
+ emitRequestIdHeader,
71
+ zeroCopyTimeoutMs,
72
+ originHeaderCache,
73
+ state,
74
+ } = deps
75
+
76
+ function responseHeaders(
77
+ variant: number,
78
+ requestIdHeader: string | null,
79
+ origin: string | null,
80
+ rateRemaining?: number,
81
+ rateResetSecs?: number,
82
+ retryAfterSecs?: number,
83
+ ): [string, string][] {
84
+ const template: ReadonlyArray<[string, string]> =
85
+ headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
86
+
87
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
88
+ const needsOrigin =
89
+ ((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
90
+ const needsRate = (variant & HV_RATE_ACTIVE) !== 0
91
+ const needsRetry = (variant & HV_RATE_LIMITED) !== 0
92
+
93
+ if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
94
+ // Frozen-readonly → mutable: callers only ever read the baked template
95
+ // (zero-copy steady-state path), so the widening is safe in practice.
96
+ return template as unknown as [string, string][]
97
+ }
98
+
99
+ if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
100
+ // Steady-state CORS: only the origin varies — cache the augmented array
101
+ // per (variant, origin) so the Origin-present case (the common
102
+ // browser/bench case) stays allocation-free after the first hit for EACH
103
+ // distinct origin (a single-slot memo thrashed when clients alternate
104
+ // between allowed origins, allocating a fresh array on every switch).
105
+ const key = `${variant & 31}\u0000${origin as string}`
106
+ const cached = originHeaderCache.get(key)
107
+ if (cached !== undefined) {
108
+ return cached
109
+ }
110
+ const entries = new Array<[string, string]>(template.length + 1)
111
+ let i = 0
112
+ for (const pair of template) {
113
+ entries[i++] = pair
114
+ }
115
+ entries[i] = ['access-control-allow-origin', origin as string]
116
+ originHeaderCache.set(key, entries)
117
+ return entries
118
+ }
119
+
120
+ let extra = 0
121
+ if (needsRequestId) extra++
122
+ if (needsOrigin) extra++
123
+ if (needsRate) extra += 2
124
+ if (needsRetry) extra++
125
+
126
+ const entries = new Array<[string, string]>(template.length + extra)
127
+ let i = 0
128
+
129
+ for (const pair of template) {
130
+ entries[i++] = pair
131
+ }
132
+
133
+ if (needsRequestId) {
134
+ entries[i++] = ['x-request-id', requestIdHeader as string]
135
+ }
136
+
137
+ if (needsOrigin) {
138
+ entries[i++] = ['access-control-allow-origin', origin as string]
139
+ }
140
+
141
+ if (needsRate) {
142
+ entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
143
+ entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
144
+ }
145
+
146
+ if (needsRetry) {
147
+ entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
148
+ }
149
+
150
+ return entries
151
+ }
152
+
153
+ function terminalHeaders(
154
+ variant: number,
155
+ ctx: BakedContext,
156
+ result: BakedIngressResult | null,
157
+ ): [string, string][] {
158
+ const v = variant | HV_JSON
159
+ const requestIdHeader = ctx.requestIdHeader
160
+ const origin = ctx.origin
161
+ const rateResetSecs =
162
+ result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
163
+ const retryAfterSecs =
164
+ result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
165
+
166
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
167
+ const needsOrigin =
168
+ ((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
169
+ const needsRate = (v & HV_RATE_ACTIVE) !== 0
170
+ const needsRetry = (v & HV_RATE_LIMITED) !== 0
171
+
172
+ // Steady state: no request-id/origin/rate extras — serve the pre-baked
173
+ // terminal template (JSON + `cache-control: no-store`) directly with zero
174
+ // per-response array allocation/copy.
175
+ if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
176
+ // Frozen-readonly → mutable: the baked terminal template is only read.
177
+ return terminalTemplates[v & 31] as unknown as [string, string][]
178
+ }
179
+
180
+ // Rare path: extras appended after the baked entries, then `cache-control`.
181
+ const base = responseHeaders(
182
+ v,
183
+ requestIdHeader,
184
+ origin,
185
+ result?.rateRemaining,
186
+ rateResetSecs,
187
+ retryAfterSecs,
188
+ )
189
+
190
+ const out = new Array<[string, string]>(base.length + 1)
191
+ let i = 0
192
+ for (const pair of base) {
193
+ out[i++] = pair
194
+ }
195
+ out[base.length] = ['cache-control', 'no-store']
196
+ return out
197
+ }
198
+
199
+ function terminalResponse(
200
+ _req: Request | undefined,
201
+ result: BakedIngressResult,
202
+ ctx: BakedContext,
203
+ ): Response | null {
204
+ if (!result.terminal) {
205
+ return null
206
+ }
207
+
208
+ const preflightAllowed = result.isPreflight && result.corsAllowed
209
+
210
+ if (preflightAllowed) {
211
+ return new Response(null, {
212
+ status: 204,
213
+ headers: responseHeaders(
214
+ result.headerVariant,
215
+ ctx.requestIdHeader,
216
+ ctx.origin,
217
+ result.rateRemaining,
218
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
219
+ result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
220
+ ),
221
+ })
222
+ }
223
+
224
+ const status = safeTerminalStatus(result)
225
+
226
+ const body: Uint8Array =
227
+ result.errorCode === ERROR_CODE_RATE_LIMITED
228
+ ? rateLimitedBody(result.retryAfterMs)
229
+ : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
230
+
231
+ return new Response(body, {
232
+ status,
233
+ headers: terminalHeaders(result.headerVariant, ctx, result),
234
+ })
235
+ }
236
+
237
+ function errorResponse(
238
+ _req: Request | undefined,
239
+ result: BakedIngressResult | null,
240
+ status: number,
241
+ code: string,
242
+ message: string,
243
+ ctx: BakedContext,
244
+ ): Response {
245
+ const body =
246
+ ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
247
+
248
+ return new Response(body, {
249
+ status,
250
+ headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
251
+ })
252
+ }
253
+
254
+ function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
255
+ return new Response(ERROR_BODIES.internal, {
256
+ status: 500,
257
+ headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
258
+ })
259
+ }
260
+
261
+ function withContentType(
262
+ headers: ReadonlyArray<[string, string]>,
263
+ contentType: string,
264
+ ): [string, string][] {
265
+ const out = new Array<[string, string]>(headers.length + 1)
266
+ let i = 0
267
+ for (const pair of headers) {
268
+ out[i++] = pair
269
+ }
270
+ out[headers.length] = ['content-type', contentType]
271
+ return out
272
+ }
273
+
274
+ function zeroCopyResponse(
275
+ result: BakedIngressResult,
276
+ _ctx: BakedContext,
277
+ init: ResponseInit,
278
+ ): Response {
279
+ if (state.currentHandle === null) {
280
+ // `zeroCopyResponse` must only run inside a live `run()` callback (a
281
+ // pooled output buffer must be in flight). Called outside `run()` the
282
+ // result has already been invalidated and `result.bodyJson` is empty —
283
+ // throw instead of silently serving an empty body.
284
+ throw new Error(
285
+ 'zeroCopyResponse() can only be called from within a run() callback ' +
286
+ '(the pooled output buffer is only live during run()).',
287
+ )
288
+ }
289
+ state.responseBorrowsBuffer = true
290
+ return pooledBodyResponse(state.currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
291
+ }
292
+
293
+ return {
294
+ responseHeaders,
295
+ terminalHeaders,
296
+ terminalResponse,
297
+ errorResponse,
298
+ internalErrorResponse,
299
+ withContentType,
300
+ zeroCopyResponse,
301
+ }
302
+ }