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,79 @@
1
+ // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
+
3
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
4
+ import type { BakedIngressResult } from '../decode/baked-result'
5
+ import { secondsFromMs } from '../shared'
6
+
7
+ /** Options accepted by the pre-baked route factories. */
8
+ export interface BakedHandlerOptions {
9
+ /** Resolve the client IP from the server object (e.g. `srv.requestIP`). */
10
+ getIp?: (req: Request, srv: unknown) => string | undefined
11
+ /** Copy body slices instead of sharing the native buffer. Default: true (safe). */
12
+ copyBody?: boolean
13
+ /** Maximum request body bytes for write/echo handlers. Default: 1 MiB. */
14
+ maxBodyBytes?: number
15
+ /**
16
+ * Overall deadline (ms) for reading the request body. Default: 30s
17
+ * (`DEFAULT_BODY_TIMEOUT_MS`). Set `0` to disable. Protects against
18
+ * slowloris / trickling bodies.
19
+ */
20
+ bodyTimeoutMs?: number
21
+ /** Fallback handler used for write error paths. Defaults to the write ingress. */
22
+ fallback?: OptimizedIngressHandler
23
+ /**
24
+ * When true, route the JSON-validity fallback check through the higher-order
25
+ * loader (`loader("jsonValid")`) instead of the direct native call —
26
+ * exercising the loader's dispatch + counters on the real request path.
27
+ * Default: false (the direct call is marginally faster for a single request;
28
+ * the loader pays off for same-tick bursts / bulk work, see src/integration/batch.ts).
29
+ */
30
+ enableLoader?: boolean
31
+ }
32
+
33
+ /** Resolve the client IP for a route request via the configured getter. */
34
+ export function resolveIp(
35
+ req: Request,
36
+ srv: unknown,
37
+ opts: BakedHandlerOptions,
38
+ ): string | undefined {
39
+ return opts.getIp ? opts.getIp(req, srv) : undefined
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
+ }
@@ -0,0 +1,14 @@
1
+ // src/ingress/routes/delete.ts — Pre-baked DELETE handler.
2
+
3
+ import { readHandler } from './read'
4
+
5
+ /**
6
+ * Pre-baked DELETE handler: DELETE is a read-style method (no request body is
7
+ * read), so it shares the exact pipeline flow of {@link readHandler} — it
8
+ * returns the ingress body JSON on success and the terminal/error responses
9
+ * otherwise. Reusing the read factory keeps the two handlers byte-identical
10
+ * and avoids a copy-pasted variant.
11
+ *
12
+ * Wire it via the `delete` key of a `BakedRoute` (see `createIngressServer`).
13
+ */
14
+ export const deleteHandler = readHandler
@@ -0,0 +1,107 @@
1
+ // src/ingress/routes/echo.ts — Pre-baked echo handler.
2
+
3
+ import type { OptimizedIngressHandler } from '../types'
4
+ import { resolveIp, type BakedHandlerOptions } from './common'
5
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS, secondsFromMs } from '../shared'
6
+ import { HV_JSON } from '../constants'
7
+ import { ERROR_BODIES } from '../response/error-bodies'
8
+ import { readBodyWithLimit } from '../body'
9
+
10
+ /**
11
+ * Pre-baked echo handler: streams the request body back with the client's
12
+ * Content-Type, bounded by `maxBodyBytes`.
13
+ */
14
+ export function echoHandler(
15
+ ingress: OptimizedIngressHandler,
16
+ opts: BakedHandlerOptions = {},
17
+ ): (req: Request, srv?: unknown) => Promise<Response> {
18
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
19
+ // Non-zero default: protect against slowloris / trickling bodies.
20
+ const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
21
+
22
+ return async (req, srv) => {
23
+ const ip = resolveIp(req, srv, opts)
24
+
25
+ const prep = ingress.run<{
26
+ terminal?: Response
27
+ headers?: ReadonlyArray<[string, string]>
28
+ }>(req, ip, null, (result, ctx) => {
29
+ const terminal = ingress.terminalResponse(req, result, ctx)
30
+ if (terminal) return { terminal }
31
+
32
+ if (result.bodyTruncated) {
33
+ return { terminal: ingress.internalErrorResponse(ctx, result) }
34
+ }
35
+
36
+ const hv = result.headerVariant & ~HV_JSON
37
+
38
+ return {
39
+ headers: ingress.responseHeaders(
40
+ hv,
41
+ ctx.requestIdHeader,
42
+ ctx.origin,
43
+ result.rateRemaining,
44
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
45
+ ),
46
+ }
47
+ })
48
+
49
+ if (prep.terminal) return prep.terminal
50
+
51
+ const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? []
52
+
53
+ const requestedContentType = req.headers.get('content-type') ?? 'application/octet-stream'
54
+
55
+ const contentLengthHeader = req.headers.get('content-length')
56
+ const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
57
+
58
+ if (Number.isFinite(contentLength)) {
59
+ if (contentLength > maxBodyBytes) {
60
+ return new Response(ERROR_BODIES.body_too_large, {
61
+ status: 413,
62
+ headers: ingress.withContentType(baseHeaders, 'application/json'),
63
+ })
64
+ }
65
+
66
+ if (contentLength <= 0 || req.body === null) {
67
+ return new Response(null, {
68
+ status: 200,
69
+ headers: ingress.withContentType(baseHeaders, requestedContentType),
70
+ })
71
+ }
72
+
73
+ return new Response(req.body, {
74
+ status: 200,
75
+ headers: ingress.withContentType(baseHeaders, requestedContentType),
76
+ })
77
+ }
78
+
79
+ try {
80
+ // Chunked / unknown-length body: stream-read with the limit enforced
81
+ // WHILE reading and an overall deadline — never fully buffer an
82
+ // oversized or trickling body before rejecting it.
83
+ const bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
84
+
85
+ return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
86
+ status: 200,
87
+ headers: ingress.withContentType(baseHeaders, requestedContentType),
88
+ })
89
+ } catch (err) {
90
+ const code = (err as { code?: string } | null)?.code
91
+ const isTooLarge = code === 'BODY_TOO_LARGE'
92
+ const isTimeout = code === 'REQUEST_TIMEOUT'
93
+
94
+ return new Response(
95
+ isTooLarge
96
+ ? ERROR_BODIES.body_too_large
97
+ : isTimeout
98
+ ? ERROR_BODIES.request_timeout
99
+ : ERROR_BODIES.bad_request,
100
+ {
101
+ status: isTooLarge ? 413 : isTimeout ? 408 : 400,
102
+ headers: ingress.withContentType(baseHeaders, 'application/json'),
103
+ },
104
+ )
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,23 @@
1
+ // src/ingress/routes/fallback.ts — Pre-baked fallback handler.
2
+
3
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
4
+ import type { BakedIngressResult } from '../decode/baked-result'
5
+ import { runBaked, type BakedHandlerOptions } from './common'
6
+
7
+ /**
8
+ * Pre-baked fallback handler: 404 for unmatched routes / OPTIONS.
9
+ */
10
+ export function fallbackHandler(
11
+ ingress: OptimizedIngressHandler,
12
+ opts: BakedHandlerOptions = {},
13
+ ): (req: Request, srv?: unknown) => Response {
14
+ // Result→Response callback, built ONCE per handler (no per-request closure).
15
+ const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
16
+ const terminal = ingress.terminalResponse(undefined, result, ctx)
17
+ if (terminal) return terminal
18
+
19
+ return ingress.errorResponse(undefined, result, 404, 'not_found', 'Not found', ctx)
20
+ }
21
+
22
+ return runBaked(ingress, opts, respond)
23
+ }
@@ -0,0 +1,27 @@
1
+ // src/ingress/routes/head.ts — Pre-baked HEAD read handler.
2
+
3
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
4
+ import type { BakedIngressResult } from '../decode/baked-result'
5
+ import { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
6
+
7
+ /**
8
+ * Pre-baked HEAD read handler: headers only, no body.
9
+ */
10
+ export function headHandler(
11
+ ingress: OptimizedIngressHandler,
12
+ opts: BakedHandlerOptions = {},
13
+ ): (req: Request, srv?: unknown) => Response {
14
+ // Result→Response callback, built ONCE per handler (no per-request closure).
15
+ const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
16
+ const terminal = ingress.terminalResponse(undefined, result, ctx)
17
+ if (terminal) return terminal
18
+
19
+ if (result.bodyTruncated) {
20
+ return ingress.internalErrorResponse(ctx, result)
21
+ }
22
+
23
+ return new Response(null, buildSuccessInit(ingress, result, ctx))
24
+ }
25
+
26
+ return runBaked(ingress, opts, respond)
27
+ }
@@ -0,0 +1,11 @@
1
+ // src/ingress/routes/index.ts — Route-handler factory composer.
2
+
3
+ export * from './common'
4
+ export * from './delete'
5
+ export * from './echo'
6
+ export * from './fallback'
7
+ export * from './head'
8
+ export * from './json-write'
9
+ export * from './options'
10
+ export * from './read'
11
+ export * from './responder'
@@ -0,0 +1,129 @@
1
+ // src/ingress/routes/json-write.ts — Pre-baked JSON-write handler
2
+ // (POST/PUT/PATCH).
3
+
4
+ import type { OptimizedIngressHandler } from '../types'
5
+ import { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
6
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from '../shared'
7
+ import { readBodyWithLimit } from '../body'
8
+ import { getAddon } from '../../native'
9
+ import { getBunFFI } from '../../native/ffi'
10
+
11
+ /**
12
+ * Zero-DOM JSON-validity check — the same native fast path the ingress
13
+ * pipeline uses internally (`json_valid_bytes`).
14
+ *
15
+ * Used by `jsonWriteHandler` as a fallback when the pipeline skipped JSON
16
+ * validation because neither `requireJsonBody` nor a `schema` is configured
17
+ * on the ingress (see the `bodyValidJson` handling in the route).
18
+ *
19
+ * bun:ffi is primary on Bun; the napi addon is the fallback (Node, forced
20
+ * `CASTRUM_FFI_MODE=napi`, or a failed ffi self-test).
21
+ */
22
+ function isValidJsonBytes(bytes: Uint8Array): boolean {
23
+ const ffi = getBunFFI()
24
+ return ffi ? ffi.jsonValid(bytes) : getAddon().jsonValid(bytes)
25
+ }
26
+
27
+ /**
28
+ * Pre-baked JSON-write handler (POST/PUT/PATCH): enforces Content-Type,
29
+ * content-length/body-size limits, JSON validity and (optionally) schema
30
+ * validation, then returns the ingress body JSON on success.
31
+ */
32
+ export function jsonWriteHandler(
33
+ ingress: OptimizedIngressHandler,
34
+ opts: BakedHandlerOptions = {},
35
+ ): (req: Request, srv?: unknown) => Promise<Response> {
36
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
37
+ const fallback = opts.fallback ?? ingress
38
+ const copyBody = opts.copyBody !== false
39
+ // Non-zero default: protect against slowloris / trickling bodies. Set
40
+ // `bodyTimeoutMs: 0` to disable.
41
+ const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
42
+
43
+ return async (req, srv) => {
44
+ const ip = resolveIp(req, srv, opts)
45
+
46
+ const contentType = req.headers.get('content-type') ?? ''
47
+ if (!contentType.includes('application/json')) {
48
+ return fallback.run(req, ip, null, (result, ctx) => {
49
+ const terminal = fallback.terminalResponse(req, result, ctx)
50
+ if (terminal) return terminal
51
+
52
+ return fallback.errorResponse(
53
+ req,
54
+ result,
55
+ 415,
56
+ 'unsupported_media_type',
57
+ 'Content-Type must be application/json',
58
+ ctx,
59
+ )
60
+ })
61
+ }
62
+
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.
67
+ let bodyBytes: Uint8Array
68
+ try {
69
+ // Stream-read with the limit enforced WHILE reading — a body that
70
+ // exceeds maxBodyBytes is rejected as soon as the limit is crossed,
71
+ // never fully buffered first.
72
+ bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
73
+ } catch (err) {
74
+ const code = (err as { code?: string } | null)?.code
75
+ const isTooLarge = code === 'BODY_TOO_LARGE'
76
+ return fallback.run(req, ip, null, (result, ctx) => {
77
+ const terminal = fallback.terminalResponse(req, result, ctx)
78
+ if (terminal) return terminal
79
+
80
+ return fallback.errorResponse(
81
+ req,
82
+ result,
83
+ isTooLarge ? 413 : 408,
84
+ isTooLarge ? 'body_too_large' : 'request_timeout',
85
+ isTooLarge ? 'Request body is too large' : 'Request body read timed out',
86
+ ctx,
87
+ )
88
+ })
89
+ }
90
+
91
+ return ingress.run(req, ip, bodyBytes, (result, ctx) => {
92
+ const terminal = ingress.terminalResponse(req, result, ctx)
93
+ if (terminal) return terminal
94
+
95
+ if (result.bodyTruncated) {
96
+ return ingress.internalErrorResponse(ctx, result)
97
+ }
98
+
99
+ // `bodyValidJson`/`schemaValid` are only computed when the pipeline ran
100
+ // JSON validation — i.e. when `requireJsonBody` is set or a `schema` is
101
+ // configured on the ingress. When `bodyValidJson` is false here,
102
+ // validation was skipped entirely (a genuine validation failure already
103
+ // short-circuits as a terminal 400 above). In that case enforce validity
104
+ // with the same zero-DOM native check, and schema validation trivially
105
+ // passes because no schema was configured (had one been, the pipeline
106
+ // would have run and set the flags).
107
+ if (result.bodyValidJson) {
108
+ if (!result.schemaValid) {
109
+ return ingress.errorResponse(
110
+ req,
111
+ result,
112
+ 422,
113
+ 'schema_validation_failed',
114
+ 'Request body failed schema validation',
115
+ ctx,
116
+ )
117
+ }
118
+ } else if (!isValidJsonBytes(bodyBytes)) {
119
+ return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
120
+ }
121
+
122
+ const init = buildSuccessInit(ingress, result, ctx)
123
+
124
+ return copyBody
125
+ ? new Response(result.bodyJson(true), init)
126
+ : ingress.zeroCopyResponse(result, ctx, init)
127
+ })
128
+ }
129
+ }
@@ -0,0 +1,26 @@
1
+ // src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
2
+
3
+ import type { OptimizedIngressHandler } from '../types'
4
+ import { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
5
+
6
+ /**
7
+ * Pre-baked OPTIONS handler: serves the native pipeline's CORS preflight
8
+ * decision — 204 when the preflight is allowed, 403 (terminal) when denied —
9
+ * and a plain 204 for non-preflight OPTIONS.
10
+ *
11
+ * `createIngressServer` wires this for EVERY route that has a handler, so
12
+ * read-only routes also answer preflights (previously OPTIONS only reached the
13
+ * pipeline on write routes that also configured a fallback).
14
+ */
15
+ export function optionsHandler(
16
+ ingress: OptimizedIngressHandler,
17
+ opts: BakedHandlerOptions = {},
18
+ ): (req: Request, srv?: unknown) => Response {
19
+ return (req, srv) =>
20
+ ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
21
+ const terminal = ingress.terminalResponse(req, result, ctx)
22
+ if (terminal) return terminal
23
+
24
+ return new Response(null, buildSuccessInit(ingress, result, ctx, 204))
25
+ })
26
+ }
@@ -0,0 +1,35 @@
1
+ // src/ingress/routes/read.ts — Pre-baked GET read handler.
2
+
3
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
4
+ import type { BakedIngressResult } from '../decode/baked-result'
5
+ import { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
6
+
7
+ /**
8
+ * Pre-baked GET read handler: returns the ingress body JSON on success.
9
+ */
10
+ export function readHandler(
11
+ ingress: OptimizedIngressHandler,
12
+ opts: BakedHandlerOptions = {},
13
+ ): (req: Request, srv?: unknown) => Response {
14
+ const copyBody = opts.copyBody !== false
15
+
16
+ // Result→Response callback, built ONCE per handler (it depends only on the
17
+ // ingress handle + config, never on the per-request `req`), so the hot path
18
+ // allocates no fresh closure per request.
19
+ const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
20
+ const terminal = ingress.terminalResponse(undefined, result, ctx)
21
+ if (terminal) return terminal
22
+
23
+ if (result.bodyTruncated) {
24
+ return ingress.internalErrorResponse(ctx, result)
25
+ }
26
+
27
+ const init = buildSuccessInit(ingress, result, ctx)
28
+
29
+ return copyBody
30
+ ? new Response(result.bodyJson(true), init)
31
+ : ingress.zeroCopyResponse(result, ctx, init)
32
+ }
33
+
34
+ return runBaked(ingress, opts, respond)
35
+ }
@@ -0,0 +1,159 @@
1
+ // src/ingress/routes/responder.ts — JS responder bridge over the native
2
+ // pipeline ("native decides, JS formats the 2xx").
3
+ //
4
+ // The responder bridge is the castrum foundation for delegating a WHOLE
5
+ // request to the per-route native `IngressInner` while keeping the 2xx body in
6
+ // JS: the native pipeline runs parse/validate/CORS/rate-limit/limits and either
7
+ // produces a terminal (rejection) response — in the configured envelope style —
8
+ // or hands a decoded snapshot to a `NativeResponder` that builds the 2xx.
9
+ //
10
+ // The sync/async boundary is deliberate: `OptimizedIngressHandler.run()` is
11
+ // SYNC-only (`assertSyncCallback` rejects thenables), so the factory captures
12
+ // the decoded snapshot (requestId/query/cookies/body/verdicts) INSIDE the sync
13
+ // callback and calls the async `responder` OUTSIDE it, after the pooled output
14
+ // buffer has been released. `result.body` is the caller-provided request body
15
+ // (never the pooled output buffer), so the snapshot's `body` is safe to hold.
16
+
17
+ import { decoder } from '../../shared/bytes'
18
+ import { generateRequestId } from '../../shared/request-id'
19
+ import { readBodyWithLimit } from '../body'
20
+ import type { BakedIngressResult } from '../decode/baked-result'
21
+ import { errorCodeName, errorMessage } from '../errors'
22
+ import type { RouteHandler } from '../server'
23
+ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
24
+ import { safeTerminalStatus } from '../status'
25
+ import type {
26
+ BakedContext,
27
+ NativeRequestContext,
28
+ NativeResponder,
29
+ OptimizedIngressHandler,
30
+ TerminalStyle,
31
+ } from '../types'
32
+ import { type BakedHandlerOptions, resolveIp } from './common'
33
+
34
+ /** Options for {@link nativeResponderRoute}. */
35
+ export interface ResponderRouteOptions extends BakedHandlerOptions {
36
+ /** Terminal envelope style. Default: `'castrum'` (the pre-baked wire). */
37
+ terminalStyle?: TerminalStyle
38
+ /**
39
+ * Read the request body and pass it to the native pipeline for validation
40
+ * (`requireJsonBody`/schema). Default `false` — the framework owns the body
41
+ * (the responder reads it via `ctx.body`); native only needs the bytes when
42
+ * a body stage is enabled in the route's `IngressHandlerOptions`.
43
+ */
44
+ readBody?: boolean
45
+ }
46
+
47
+ /** ignex framework security posture for terminal responses. */
48
+ const IGNGEX_SECURITY_HEADERS: [string, string][] = [
49
+ ['x-frame-options', 'DENY'],
50
+ ['x-content-type-options', 'nosniff'],
51
+ ['referrer-policy', 'no-referrer'],
52
+ ]
53
+
54
+ /** Build the ignex-framework terminal envelope (`{error,status,code}` + security headers). */
55
+ function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Response {
56
+ const preflightAllowed = result.isPreflight && result.corsAllowed
57
+ if (preflightAllowed) {
58
+ return new Response(null, { status: 204, headers: IGNGEX_SECURITY_HEADERS })
59
+ }
60
+ const status = safeTerminalStatus(result)
61
+ const body = JSON.stringify({
62
+ error: errorMessage(status, result.errorCode),
63
+ status,
64
+ code: errorCodeName(result.errorCode),
65
+ })
66
+ const headers = new Headers(IGNGEX_SECURITY_HEADERS)
67
+ headers.set('content-type', 'application/json; charset=utf-8')
68
+ return new Response(body, { status, headers })
69
+ }
70
+
71
+ /** Parse a `queryJson()`/`cookiesJson()` section into an object (empty on failure). */
72
+ function parseSection(json: string): Record<string, unknown> {
73
+ try {
74
+ const v: unknown = JSON.parse(json)
75
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
76
+ ? (v as Record<string, unknown>)
77
+ : {}
78
+ } catch {
79
+ return {}
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Build a responder route: native pipeline decides + rejects (terminal
85
+ * response in the chosen style); on success the JS `responder` builds the 2xx.
86
+ * Returns an async `RouteHandler` (`(req, srv?, params?) => Promise<Response>`).
87
+ */
88
+ export function nativeResponderRoute(
89
+ ingress: OptimizedIngressHandler,
90
+ responder: NativeResponder,
91
+ opts: ResponderRouteOptions = {},
92
+ ): RouteHandler {
93
+ const terminalStyle: TerminalStyle = opts.terminalStyle ?? 'castrum'
94
+ const readBody = opts.readBody ?? false
95
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
96
+ const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
97
+
98
+ return async (req, srv, _params) => {
99
+ const ip = resolveIp(req, srv, opts)
100
+ // The request-id the framework surfaces (same source as the pre-baked path's
101
+ // `ctx.requestIdHeader`): the baked result does not carry it, so generate it
102
+ // here — the native pipeline uses the same rid for its internal frame.
103
+ const requestId = decoder.decode(generateRequestId())
104
+ // Read the body ONLY when native validation needs it (the framework owns
105
+ // the body otherwise). A `BODY_TOO_LARGE`/`REQUEST_TIMEOUT` throw becomes
106
+ // a terminal 413/408 from the native path.
107
+ let body: Uint8Array | null = null
108
+ if (readBody) {
109
+ try {
110
+ body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
111
+ } catch (err) {
112
+ const code = (err as Error & { code?: string }).code
113
+ const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
114
+ const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
115
+ const headers = new Headers(IGNGEX_SECURITY_HEADERS)
116
+ headers.set('content-type', 'application/json; charset=utf-8')
117
+ return new Response(payload, { status, headers })
118
+ }
119
+ }
120
+
121
+ let terminal: Response | null = null
122
+ let snapshot: NativeRequestContext | null = null
123
+
124
+ // Sync callback (never returns a thenable). Captures the decoded snapshot
125
+ // or the terminal response, then the async responder runs outside `run()`.
126
+ ingress.run<null>(req, ip, body, (result, ctx) => {
127
+ if (result.terminal || !result.ok) {
128
+ terminal =
129
+ terminalStyle === 'ignex'
130
+ ? buildIgngexTerminal(result, ctx)
131
+ : ingress.terminalResponse(req, result, ctx)
132
+ return null
133
+ }
134
+ const query = parseSection(result.queryJson())
135
+ const cookies = parseSection(result.cookiesJson())
136
+ snapshot = {
137
+ requestId,
138
+ status: result.status,
139
+ rateLimited: result.rateLimited,
140
+ retryAfterMs: result.retryAfterMs,
141
+ query: query as Record<string, string | string[]>,
142
+ cookies: cookies as Record<string, string>,
143
+ body: body ?? new Uint8Array(0),
144
+ ip,
145
+ req,
146
+ }
147
+ return null
148
+ })
149
+
150
+ if (terminal !== null) {
151
+ return terminal
152
+ }
153
+ if (snapshot === null) {
154
+ // Native internal error (setInternalError) — the result was never OK.
155
+ return ingress.internalErrorResponse({ requestIdHeader: null, origin: null })
156
+ }
157
+ return responder(snapshot)
158
+ }
159
+ }