castrum 0.7.0 → 0.9.0

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 (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -59
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +3161 -0
  5. package/dist/index.js +7306 -0
  6. package/docs/ARCHITECTURE.md +415 -0
  7. package/docs/BENCHMARKS.md +307 -0
  8. package/docs/ENVIRONMENT.md +79 -0
  9. package/docs/GETTING_STARTED.md +209 -0
  10. package/docs/INGRESS.md +745 -0
  11. package/docs/REPO_MAP.md +259 -0
  12. package/docs/adr/0001-two-wire-formats.md +39 -0
  13. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  14. package/docs/adr/0003-bun-builtins-delegation.md +42 -0
  15. package/docs/bun-builtins-decision-matrix.md +108 -0
  16. package/index.ts +51 -8
  17. package/package.json +73 -20
  18. package/src/data/json-rows.ts +13 -13
  19. package/src/ingress/body.ts +149 -0
  20. package/src/ingress/constants.ts +220 -48
  21. package/src/ingress/context.ts +136 -0
  22. package/src/ingress/decode/baked-result.ts +121 -0
  23. package/src/ingress/decode/fast-result.ts +160 -0
  24. package/src/ingress/decode/packed-sections.ts +56 -0
  25. package/src/ingress/decode/result-base.ts +172 -0
  26. package/src/ingress/errors.ts +72 -0
  27. package/src/ingress/fast.ts +152 -1021
  28. package/src/ingress/handlers.ts +563 -1001
  29. package/src/ingress/headers/baked-templates.ts +102 -0
  30. package/src/ingress/headers/cors.ts +44 -0
  31. package/src/ingress/headers/fast-templates.ts +238 -0
  32. package/src/ingress/headers/hsts.ts +83 -0
  33. package/src/ingress/health.ts +67 -0
  34. package/src/ingress/index.ts +130 -405
  35. package/src/ingress/metrics.ts +132 -0
  36. package/src/ingress/options.ts +197 -0
  37. package/src/ingress/packing/gather-raw-headers.ts +75 -0
  38. package/src/ingress/packing/header-packing.ts +75 -0
  39. package/src/ingress/packing/input-packer.ts +150 -0
  40. package/src/ingress/packing/scratch.ts +50 -0
  41. package/src/ingress/packing/select-headers.ts +96 -0
  42. package/src/ingress/response/error-bodies.ts +75 -0
  43. package/src/ingress/response/terminal.ts +59 -0
  44. package/src/ingress/routes/common.ts +38 -0
  45. package/src/ingress/routes/delete.ts +14 -0
  46. package/src/ingress/routes/echo.ts +107 -0
  47. package/src/ingress/routes/fallback.ts +23 -0
  48. package/src/ingress/routes/head.ts +37 -0
  49. package/src/ingress/routes/index.ts +10 -0
  50. package/src/ingress/routes/json-write.ts +153 -0
  51. package/src/ingress/routes/options.ts +36 -0
  52. package/src/ingress/routes/read.ts +45 -0
  53. package/src/ingress/server-node.ts +376 -0
  54. package/src/ingress/server.ts +443 -0
  55. package/src/ingress/shared.ts +132 -0
  56. package/src/ingress/status.ts +97 -0
  57. package/src/ingress/types.ts +157 -0
  58. package/src/integration/batch.ts +58 -0
  59. package/src/integration/index.ts +29 -0
  60. package/src/integration/pipeline.ts +217 -0
  61. package/src/integration/streaming.ts +79 -0
  62. package/src/integration/websocket.ts +92 -0
  63. package/src/loader/batch.ts +133 -0
  64. package/src/loader/cost.ts +104 -0
  65. package/src/loader/index.ts +930 -0
  66. package/src/loader/ops.ts +657 -0
  67. package/src/native/ffi.ts +1541 -0
  68. package/src/native/index.ts +8 -207
  69. package/src/native/loader.ts +194 -0
  70. package/src/native/types.ts +598 -0
  71. package/src/rust-ffi/addon.ts +10 -0
  72. package/src/rust-ffi/batch.ts +431 -0
  73. package/src/rust-ffi/client.ts +88 -0
  74. package/src/rust-ffi/context.ts +164 -0
  75. package/src/rust-ffi/index.ts +40 -541
  76. package/src/rust-ffi/options.ts +113 -0
  77. package/src/rust-ffi/packed.ts +253 -0
  78. package/src/rust-ffi/proven.ts +42 -0
  79. package/src/rust-ffi/scalar/crypto.ts +265 -0
  80. package/src/rust-ffi/scalar/factories.ts +233 -0
  81. package/src/rust-ffi/scalar/hashing.ts +72 -0
  82. package/src/rust-ffi/scalar/http.ts +206 -0
  83. package/src/rust-ffi/scalar/index.ts +31 -0
  84. package/src/rust-ffi/scalar/interface.ts +450 -0
  85. package/src/rust-ffi/scalar/json.ts +45 -0
  86. package/src/rust-ffi/scalar/payload.ts +124 -0
  87. package/src/rust-ffi/text.ts +95 -0
  88. package/src/selection.json +194 -0
  89. package/src/selection.ts +77 -0
  90. package/src/shared/adaptive.ts +68 -0
  91. package/src/shared/bench-classify.ts +209 -0
  92. package/src/shared/buffer-pool.ts +209 -0
  93. package/src/shared/bytes.ts +35 -5
  94. package/src/shared/env.ts +20 -0
  95. package/src/shared/log.ts +74 -0
  96. package/src/shared/metrics.ts +247 -0
  97. package/src/shared/packed.ts +350 -154
  98. package/src/shared/proven.ts +596 -0
  99. package/src/shared/request-id.ts +67 -0
  100. package/src/shared/response.ts +104 -0
  101. package/src/shared/runtime.ts +34 -0
  102. package/src/shared/trace.ts +81 -0
  103. package/src/shared/uuid.ts +31 -0
  104. package/Cargo.toml +0 -73
  105. package/bench.ts +0 -3
  106. package/rust/batch.rs +0 -195
  107. package/rust/cookie_parser.rs +0 -70
  108. package/rust/cors.rs +0 -263
  109. package/rust/hashing.rs +0 -164
  110. package/rust/headers.rs +0 -234
  111. package/rust/hmac_sha256.rs +0 -113
  112. package/rust/http_parser.rs +0 -132
  113. package/rust/ingress.rs +0 -616
  114. package/rust/ingress_constants.rs +0 -89
  115. package/rust/ip_trust.rs +0 -185
  116. package/rust/json_ops.rs +0 -66
  117. package/rust/json_patch_ops.rs +0 -25
  118. package/rust/json_schema.rs +0 -151
  119. package/rust/json_ser.rs +0 -429
  120. package/rust/lib.rs +0 -48
  121. package/rust/method.rs +0 -141
  122. package/rust/mime_lookup.rs +0 -76
  123. package/rust/output.rs +0 -238
  124. package/rust/proxy.rs +0 -90
  125. package/rust/query_parser.rs +0 -141
  126. package/rust/random_token.rs +0 -52
  127. package/rust/rate_limit.rs +0 -203
  128. package/rust/terminal.rs +0 -95
  129. package/rust/unit_tests.rs +0 -843
  130. package/rust/url_codec.rs +0 -133
  131. package/rust/util.rs +0 -537
  132. package/rust/validation.rs +0 -107
  133. package/rust/websocket.rs +0 -23
  134. package/src/baseline/index.ts +0 -12
  135. package/src/baseline/tasks/cookie.ts +0 -22
  136. package/src/baseline/tasks/hashing.ts +0 -18
  137. package/src/baseline/tasks/hmac.ts +0 -38
  138. package/src/baseline/tasks/http.ts +0 -62
  139. package/src/baseline/tasks/json-patch.ts +0 -12
  140. package/src/baseline/tasks/json.ts +0 -50
  141. package/src/baseline/tasks/mime.ts +0 -5
  142. package/src/baseline/tasks/query.ts +0 -14
  143. package/src/baseline/tasks/token.ts +0 -15
  144. package/src/baseline/tasks/url.ts +0 -11
  145. package/src/baseline/tasks/validation.ts +0 -19
  146. package/src/baseline/tasks/websocket.ts +0 -14
  147. package/src/bench/assert.ts +0 -70
  148. package/src/bench/checks.ts +0 -227
  149. package/src/bench/checksum.ts +0 -27
  150. package/src/bench/comparisons.ts +0 -70
  151. package/src/bench/concurrent-worker.ts +0 -135
  152. package/src/bench/fixtures.ts +0 -233
  153. package/src/bench/index.ts +0 -2
  154. package/src/bench/measure.ts +0 -177
  155. package/src/bench/now.ts +0 -3
  156. package/src/bench/report.ts +0 -79
  157. package/src/bench/run.ts +0 -57
  158. package/src/bench/tasks/complex.ts +0 -232
  159. package/src/bench/tasks/concurrent.ts +0 -213
  160. package/src/bench/tasks/cookie.ts +0 -44
  161. package/src/bench/tasks/hashing.ts +0 -33
  162. package/src/bench/tasks/hmac.ts +0 -36
  163. package/src/bench/tasks/http.ts +0 -50
  164. package/src/bench/tasks/index.ts +0 -52
  165. package/src/bench/tasks/json-patch.ts +0 -21
  166. package/src/bench/tasks/json.ts +0 -33
  167. package/src/bench/tasks/mime.ts +0 -21
  168. package/src/bench/tasks/query.ts +0 -44
  169. package/src/bench/tasks/stress.ts +0 -142
  170. package/src/bench/tasks/token.ts +0 -20
  171. package/src/bench/tasks/url.ts +0 -34
  172. package/src/bench/tasks/validation.ts +0 -57
  173. package/src/bench/tasks/websocket.ts +0 -21
  174. package/src/bench/types.ts +0 -60
  175. package/src/ingress/packed-input.ts +0 -59
  176. package/src/shared/json.ts +0 -18
@@ -0,0 +1,96 @@
1
+ // src/ingress/packing/select-headers.ts — Shared header-selection iterator.
2
+ //
3
+ // Single source of truth for "which headers does a HeaderPlan select" across
4
+ // the THREE packing paths:
5
+ // - `packHeaders` (fast path, ./header-packing.ts)
6
+ // - `gatherRawHeadersPacked` (pre-baked packed path, ./gather-raw-headers.ts)
7
+ // - `gatherRawHeaders` (pre-baked string-array reference, ./gather-raw-headers.ts)
8
+ //
9
+ // All three apply the SAME selection rules and the SAME per-header size guards
10
+ // (an oversized value is dropped rather than forwarded, so the packed block can
11
+ // never exceed the native `max_headers_bytes` (65536) and 500). This module
12
+ // owns that logic plus the pre-encoded header-name bytes, so a rule or size
13
+ // change cannot drift between the paths.
14
+
15
+ import { METHOD_KIND, type HeaderPlan } from '../shared'
16
+ import { MAX_COOKIE_HEADER_BYTES, MAX_SMALL_HEADER_BYTES, MAX_XFF_HEADER_BYTES } from './scratch'
17
+
18
+ const encoder = new TextEncoder()
19
+
20
+ // ── Header name constants (pre-encoded) ────────────────────────
21
+ export const HDR_COOKIE = encoder.encode('cookie')
22
+ export const HDR_ORIGIN = encoder.encode('origin')
23
+ export const HDR_ACRM = encoder.encode('access-control-request-method')
24
+ export const HDR_ACRH = encoder.encode('access-control-request-headers')
25
+ export const HDR_XFF = encoder.encode('x-forwarded-for')
26
+ export const HDR_XRI = encoder.encode('x-real-ip')
27
+ export const HDR_XFP = encoder.encode('x-forwarded-proto')
28
+
29
+ /**
30
+ * Visit every request header selected by `plan` (cookie, origin + CORS
31
+ * preflight method/headers, proxy XFF/XRI, proto XFP), applying the shared
32
+ * per-header size guards from scratch.ts. Callers supply `visit(name, value)`
33
+ * with the pre-encoded header-name bytes; no intermediate array is allocated.
34
+ *
35
+ * @param originValue - When provided (non-undefined), the already-fetched
36
+ * `Origin` header value, avoiding a second `req.headers.get('origin')`
37
+ * native→JS conversion on the hot path (the caller, e.g. `handlers.ts::run`,
38
+ * already fetched it once for `ctx.origin`). When omitted, the origin is
39
+ * fetched here. The same `MAX_SMALL_HEADER_BYTES` guard applies either way.
40
+ * @param visit - Invoked once per selected header with the pre-encoded name
41
+ * bytes and the raw value string.
42
+ */
43
+ export function forEachSelectedHeader(
44
+ req: Request,
45
+ plan: HeaderPlan,
46
+ methodKind: number,
47
+ originValue: string | null | undefined,
48
+ visit: (name: Uint8Array, value: string) => void,
49
+ ): void {
50
+ const h = req.headers
51
+
52
+ if (plan.cookie) {
53
+ const v = h.get('cookie')
54
+ if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
55
+ visit(HDR_COOKIE, v)
56
+ }
57
+ }
58
+
59
+ if (plan.cors) {
60
+ const originV = originValue !== undefined ? originValue : h.get('origin')
61
+ if (originV !== null && originV.length <= MAX_SMALL_HEADER_BYTES) {
62
+ visit(HDR_ORIGIN, originV)
63
+ }
64
+
65
+ if (methodKind === METHOD_KIND.OPTIONS) {
66
+ const acrm = h.get('access-control-request-method')
67
+ if (acrm !== null && acrm.length <= MAX_SMALL_HEADER_BYTES) {
68
+ visit(HDR_ACRM, acrm)
69
+ }
70
+
71
+ const acrh = h.get('access-control-request-headers')
72
+ if (acrh !== null && acrh.length <= MAX_SMALL_HEADER_BYTES) {
73
+ visit(HDR_ACRH, acrh)
74
+ }
75
+ }
76
+ }
77
+
78
+ if (plan.proxy) {
79
+ const xff = h.get('x-forwarded-for')
80
+ if (xff !== null && xff.length <= MAX_XFF_HEADER_BYTES) {
81
+ visit(HDR_XFF, xff)
82
+ }
83
+
84
+ const xri = h.get('x-real-ip')
85
+ if (xri !== null && xri.length <= MAX_SMALL_HEADER_BYTES) {
86
+ visit(HDR_XRI, xri)
87
+ }
88
+ }
89
+
90
+ if (plan.proto) {
91
+ const xfp = h.get('x-forwarded-proto')
92
+ if (xfp !== null && xfp.length <= MAX_SMALL_HEADER_BYTES) {
93
+ visit(HDR_XFP, xfp)
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,75 @@
1
+ // src/ingress/response/error-bodies.ts — Pre-encoded static error bodies
2
+ // (pre-baked path).
3
+ //
4
+ // The pre-baked path emits the benchmark wire format:
5
+ // {"ok":false,"error":{"code":"...","message":"..."}}
6
+ // These bodies are encoded once at module load so error responses never
7
+ // re-allocate or re-serialize on the hot path.
8
+
9
+ import { encoder } from '../../shared/bytes'
10
+ import {
11
+ ERR_CODE_CORS_PREFLIGHT,
12
+ ERR_CODE_RATE_LIMITED,
13
+ ERR_CODE_BODY_TOO_LARGE,
14
+ ERR_CODE_INVALID_JSON,
15
+ ERR_CODE_SCHEMA_VALIDATION,
16
+ ERR_CODE_BAD_REQUEST,
17
+ ERR_CODE_REQUEST_TOO_LARGE,
18
+ ERR_CODE_INTERNAL,
19
+ } from '../constants'
20
+
21
+ function staticErrorBody(code: string, message: string): Uint8Array {
22
+ return encoder.encode(`{"ok":false,"error":{"code":"${code}","message":"${message}"}}`)
23
+ }
24
+
25
+ /** Pre-encoded static error bodies, keyed by error-code string name. */
26
+ export const ERROR_BODIES: Record<string, Uint8Array> = {
27
+ not_found: staticErrorBody('not_found', 'Not found'),
28
+ unsupported_media_type: staticErrorBody(
29
+ 'unsupported_media_type',
30
+ 'Content-Type must be application/json',
31
+ ),
32
+ body_too_large: staticErrorBody('body_too_large', 'Request body is too large'),
33
+ request_timeout: staticErrorBody('request_timeout', 'Request body read timed out'),
34
+ invalid_json: staticErrorBody('invalid_json', 'Invalid JSON body'),
35
+ schema_validation_failed: staticErrorBody(
36
+ 'schema_validation_failed',
37
+ 'Request body failed schema validation',
38
+ ),
39
+ cors_preflight_not_allowed: staticErrorBody(
40
+ 'cors_preflight_not_allowed',
41
+ 'CORS preflight not allowed',
42
+ ),
43
+ bad_request: staticErrorBody('bad_request', 'Bad request'),
44
+ request_too_large: staticErrorBody('request_too_large', 'Request too large'),
45
+ rate_limited: staticErrorBody('rate_limited', 'Too Many Requests'),
46
+ internal: staticErrorBody('internal_error', 'Internal server error'),
47
+ }
48
+
49
+ /** Error bodies indexed by numeric native error code. */
50
+ export const ERROR_CODE_BODIES: (Uint8Array | undefined)[] = []
51
+ ERROR_CODE_BODIES[ERR_CODE_CORS_PREFLIGHT] = ERROR_BODIES.cors_preflight_not_allowed
52
+ ERROR_CODE_BODIES[ERR_CODE_RATE_LIMITED] = ERROR_BODIES.rate_limited
53
+ ERROR_CODE_BODIES[ERR_CODE_BODY_TOO_LARGE] = ERROR_BODIES.body_too_large
54
+ ERROR_CODE_BODIES[ERR_CODE_INVALID_JSON] = ERROR_BODIES.invalid_json
55
+ ERROR_CODE_BODIES[ERR_CODE_SCHEMA_VALIDATION] = ERROR_BODIES.schema_validation_failed
56
+ ERROR_CODE_BODIES[ERR_CODE_BAD_REQUEST] = ERROR_BODIES.bad_request
57
+ ERROR_CODE_BODIES[ERR_CODE_REQUEST_TOO_LARGE] = ERROR_BODIES.request_too_large
58
+ ERROR_CODE_BODIES[ERR_CODE_INTERNAL] = ERROR_BODIES.internal
59
+
60
+ const RATE_LIMIT_BODY_PREFIX = encoder.encode(
61
+ '{"ok":false,"error":{"code":"rate_limited","message":"Too Many Requests","retry_after_ms":',
62
+ )
63
+ const RATE_LIMIT_BODY_SUFFIX = encoder.encode('}}')
64
+
65
+ /** Build a rate-limited error body with the retry-after value inlined. */
66
+ export function rateLimitedBody(retryAfterMs: number): Uint8Array {
67
+ const digits = encoder.encode(String(Math.max(0, Math.floor(retryAfterMs))))
68
+ const out = new Uint8Array(
69
+ RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength + RATE_LIMIT_BODY_SUFFIX.byteLength,
70
+ )
71
+ out.set(RATE_LIMIT_BODY_PREFIX, 0)
72
+ out.set(digits, RATE_LIMIT_BODY_PREFIX.byteLength)
73
+ out.set(RATE_LIMIT_BODY_SUFFIX, RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength)
74
+ return out
75
+ }
@@ -0,0 +1,59 @@
1
+ // src/ingress/response/terminal.ts — Terminal (error) response builder (fast
2
+ // path).
3
+ //
4
+ // Builds the fast-path terminal response: {"error":{code,status,message,
5
+ // requestId}} with x-ratelimit-* headers. This is the fast-path wire format;
6
+ // the pre-baked path has its own builders (see ./handlers.ts) — do not unify
7
+ // them (see AGENTS.md).
8
+
9
+ import { headersForResult, type ResponseBuildContext } from '../headers/fast-templates'
10
+ import { safeTerminalStatus } from '../status'
11
+ import { errorCodeName, errorMessage } from '../errors'
12
+
13
+ /**
14
+ * Build a terminal response for a result, or `null` when the result is not
15
+ * terminal (the caller should serve the body instead).
16
+ */
17
+ export function buildTerminalResponse(
18
+ ctx: ResponseBuildContext,
19
+ r: {
20
+ readonly terminal: boolean
21
+ readonly isPreflight: boolean
22
+ readonly corsAllowed: boolean
23
+ readonly errorCode: number
24
+ readonly status: number
25
+ readonly headerVariant: number
26
+ readonly https: boolean
27
+ readonly rateLimit: number
28
+ readonly rateRemaining: number
29
+ readonly rateResetMs: number
30
+ readonly retryAfterMs: number
31
+ },
32
+ req: Request,
33
+ requestId: string,
34
+ ): Response | null {
35
+ if (!r.terminal) return null
36
+
37
+ const preflightAllowed = r.isPreflight && r.corsAllowed
38
+ const headers = headersForResult(ctx, r, req, requestId)
39
+
40
+ if (preflightAllowed) {
41
+ return new Response(null, { status: 204, headers })
42
+ }
43
+
44
+ const status = safeTerminalStatus(r)
45
+
46
+ headers.set('content-type', 'application/json; charset=utf-8')
47
+ headers.set('cache-control', 'no-store')
48
+
49
+ const payload = JSON.stringify({
50
+ error: {
51
+ code: errorCodeName(r.errorCode),
52
+ status,
53
+ message: errorMessage(status, r.errorCode),
54
+ requestId: requestId || undefined,
55
+ },
56
+ })
57
+
58
+ return new Response(payload, { status, headers })
59
+ }
@@ -0,0 +1,38 @@
1
+ // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
+
3
+ import type { OptimizedIngressHandler } from '../types'
4
+
5
+ /** Options accepted by the pre-baked route factories. */
6
+ export interface BakedHandlerOptions {
7
+ /** Resolve the client IP from the server object (e.g. `srv.requestIP`). */
8
+ getIp?: (req: Request, srv: unknown) => string | undefined
9
+ /** Copy body slices instead of sharing the native buffer. Default: true (safe). */
10
+ copyBody?: boolean
11
+ /** Maximum request body bytes for write/echo handlers. Default: 1 MiB. */
12
+ maxBodyBytes?: number
13
+ /**
14
+ * Overall deadline (ms) for reading the request body. Default: 30s
15
+ * (`DEFAULT_BODY_TIMEOUT_MS`). Set `0` to disable. Protects against
16
+ * slowloris / trickling bodies.
17
+ */
18
+ bodyTimeoutMs?: number
19
+ /** Fallback handler used for write error paths. Defaults to the write ingress. */
20
+ fallback?: OptimizedIngressHandler
21
+ /**
22
+ * When true, route the JSON-validity fallback check through the higher-order
23
+ * loader (`loader("jsonValid")`) instead of the direct native call —
24
+ * exercising the loader's dispatch + counters on the real request path.
25
+ * Default: false (the direct call is marginally faster for a single request;
26
+ * the loader pays off for same-tick bursts / bulk work, see src/integration/batch.ts).
27
+ */
28
+ enableLoader?: boolean
29
+ }
30
+
31
+ /** Resolve the client IP for a route request via the configured getter. */
32
+ export function resolveIp(
33
+ req: Request,
34
+ srv: unknown,
35
+ opts: BakedHandlerOptions,
36
+ ): string | undefined {
37
+ return opts.getIp ? opts.getIp(req, srv) : undefined
38
+ }
@@ -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 { resolveIp, 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 (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
23
+ }
@@ -0,0 +1,37 @@
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 { resolveIp, type BakedHandlerOptions } from './common'
6
+ import { secondsFromMs } from '../shared'
7
+
8
+ /**
9
+ * Pre-baked HEAD read handler: headers only, no body.
10
+ */
11
+ export function headHandler(
12
+ ingress: OptimizedIngressHandler,
13
+ opts: BakedHandlerOptions = {},
14
+ ): (req: Request, srv?: unknown) => Response {
15
+ // Result→Response callback, built ONCE per handler (no per-request closure).
16
+ const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
17
+ const terminal = ingress.terminalResponse(undefined, result, ctx)
18
+ if (terminal) return terminal
19
+
20
+ if (result.bodyTruncated) {
21
+ return ingress.internalErrorResponse(ctx, result)
22
+ }
23
+
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
+ })
34
+ }
35
+
36
+ return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
37
+ }
@@ -0,0 +1,10 @@
1
+ // src/ingress/routes/index.ts — Route-handler factory composer.
2
+
3
+ export * from './common'
4
+ export * from './read'
5
+ export * from './head'
6
+ export * from './json-write'
7
+ export * from './echo'
8
+ export * from './delete'
9
+ export * from './options'
10
+ export * from './fallback'
@@ -0,0 +1,153 @@
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 { resolveIp, type BakedHandlerOptions } from './common'
6
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS, secondsFromMs } 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
+ 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
+
82
+ let bodyBytes: Uint8Array
83
+ try {
84
+ // Stream-read with the limit enforced WHILE reading — a body that
85
+ // exceeds maxBodyBytes is rejected as soon as the limit is crossed,
86
+ // never fully buffered first.
87
+ bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
88
+ } catch (err) {
89
+ const code = (err as { code?: string } | null)?.code
90
+ const isTooLarge = code === 'BODY_TOO_LARGE'
91
+ return fallback.run(req, ip, null, (result, ctx) => {
92
+ const terminal = fallback.terminalResponse(req, result, ctx)
93
+ if (terminal) return terminal
94
+
95
+ return fallback.errorResponse(
96
+ req,
97
+ result,
98
+ isTooLarge ? 413 : 408,
99
+ isTooLarge ? 'body_too_large' : 'request_timeout',
100
+ isTooLarge ? 'Request body is too large' : 'Request body read timed out',
101
+ ctx,
102
+ )
103
+ })
104
+ }
105
+
106
+ return ingress.run(req, ip, bodyBytes, (result, ctx) => {
107
+ const terminal = ingress.terminalResponse(req, result, ctx)
108
+ if (terminal) return terminal
109
+
110
+ if (result.bodyTruncated) {
111
+ return ingress.internalErrorResponse(ctx, result)
112
+ }
113
+
114
+ // `bodyValidJson`/`schemaValid` are only computed when the pipeline ran
115
+ // JSON validation — i.e. when `requireJsonBody` is set or a `schema` is
116
+ // configured on the ingress. When `bodyValidJson` is false here,
117
+ // validation was skipped entirely (a genuine validation failure already
118
+ // short-circuits as a terminal 400 above). In that case enforce validity
119
+ // with the same zero-DOM native check, and schema validation trivially
120
+ // passes because no schema was configured (had one been, the pipeline
121
+ // would have run and set the flags).
122
+ if (result.bodyValidJson) {
123
+ if (!result.schemaValid) {
124
+ return ingress.errorResponse(
125
+ req,
126
+ result,
127
+ 422,
128
+ 'schema_validation_failed',
129
+ 'Request body failed schema validation',
130
+ ctx,
131
+ )
132
+ }
133
+ } else if (!isValidJsonBytes(bodyBytes)) {
134
+ return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
135
+ }
136
+
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
+ }
147
+
148
+ return copyBody
149
+ ? new Response(result.bodyJson(true), init)
150
+ : ingress.zeroCopyResponse(result, ctx, init)
151
+ })
152
+ }
153
+ }
@@ -0,0 +1,36 @@
1
+ // src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
2
+
3
+ import type { OptimizedIngressHandler } from '../types'
4
+ import { resolveIp, type BakedHandlerOptions } from './common'
5
+ import { secondsFromMs } from '../shared'
6
+
7
+ /**
8
+ * Pre-baked OPTIONS handler: serves the native pipeline's CORS preflight
9
+ * decision — 204 when the preflight is allowed, 403 (terminal) when denied —
10
+ * and a plain 204 for non-preflight OPTIONS.
11
+ *
12
+ * `createIngressServer` wires this for EVERY route that has a handler, so
13
+ * read-only routes also answer preflights (previously OPTIONS only reached the
14
+ * pipeline on write routes that also configured a fallback).
15
+ */
16
+ export function optionsHandler(
17
+ ingress: OptimizedIngressHandler,
18
+ opts: BakedHandlerOptions = {},
19
+ ): (req: Request, srv?: unknown) => Response {
20
+ return (req, srv) =>
21
+ ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
22
+ const terminal = ingress.terminalResponse(req, result, ctx)
23
+ if (terminal) return terminal
24
+
25
+ return new Response(null, {
26
+ status: 204,
27
+ headers: ingress.responseHeaders(
28
+ result.headerVariant,
29
+ ctx.requestIdHeader,
30
+ ctx.origin,
31
+ result.rateRemaining,
32
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
33
+ ),
34
+ })
35
+ })
36
+ }