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,191 @@
1
+ // src/ingress/types.ts — Public API types for the ingress module.
2
+ //
3
+ // Shared by the sync/async factories in ./index.ts and the result/context
4
+ // helpers in ./context.ts, so the implementation modules never need to import
5
+ // the public barrel.
6
+
7
+ import type { BakedIngressResult } from './decode/baked-result'
8
+ import type { IngressFastOptions } from './options'
9
+
10
+ /** Public ingress options (extends the fast-path options). */
11
+ export interface IngressOptions extends IngressFastOptions {
12
+ enableRequestIds?: boolean
13
+ /**
14
+ * Invoked before a request is processed (for tracing/context hooks).
15
+ * Only used by the async `createIngress` path.
16
+ */
17
+ onRequest?: (req: Request, requestId: string, ip: string | undefined) => void
18
+ /**
19
+ * Invoked after a terminal context (and its `Response`) is produced, for
20
+ * metrics/logging hooks. Only used by the async `createIngress` path.
21
+ */
22
+ onResponse?: (req: Request, result: IngressContext, status: number, requestId: string) => void
23
+ }
24
+
25
+ /**
26
+ * Readonly structural view of a decoded ingress result.
27
+ *
28
+ * Note: the exported `FastIngressResult` class is the zero-alloc implementation;
29
+ * this interface is the readonly view used by the public sync/async handlers.
30
+ */
31
+ export interface IngressResult {
32
+ readonly status: number
33
+ readonly verdict: number
34
+ readonly flags: number
35
+ readonly errorCode: number
36
+ readonly terminal: boolean
37
+ readonly ok: boolean
38
+ readonly https: boolean
39
+ readonly trustedProxy: boolean
40
+ readonly hasCookies: boolean
41
+ readonly hasQuery: boolean
42
+ readonly bodyValidJson: boolean
43
+ readonly schemaValid: boolean
44
+ readonly corsAllowed: boolean
45
+ readonly isPreflight: boolean
46
+ readonly rateLimited: boolean
47
+ readonly rateLimit: number
48
+ readonly rateRemaining: number
49
+ readonly rateResetMs: number
50
+ readonly retryAfterMs: number
51
+ readonly body: Uint8Array
52
+ readonly headerVariant: number
53
+ readonly requestId: string
54
+ readonly bodyTruncated: boolean
55
+
56
+ cookiesJson(): string
57
+ queryJson(): string
58
+ bodyJson(): Uint8Array
59
+ }
60
+
61
+ /** A result plus the terminal Response computed for it. */
62
+ export interface IngressContext extends IngressResult {
63
+ response: Response | null
64
+ }
65
+
66
+ /**
67
+ * Synchronous ingress handler.
68
+ *
69
+ * The callback runs on the zero-alloc result object and MUST be synchronous:
70
+ * returning a Promise (or any thenable) throws. The result is invalidated after
71
+ * `run()` returns, so capture the fields you need inside the callback (or use
72
+ * the async {@link IngressHandler} which snapshots for you).
73
+ */
74
+ export interface SyncIngressHandler {
75
+ run<T>(
76
+ req: Request,
77
+ ip: string | undefined,
78
+ body: Uint8Array | null,
79
+ requestId: string,
80
+ fn: (result: IngressResult) => T,
81
+ ): T
82
+ }
83
+
84
+ /** Async ingress handler (wraps {@link SyncIngressHandler} with body reading). */
85
+ export type IngressHandler = (req: Request, ip?: string) => Promise<IngressContext>
86
+
87
+ // ── Pre-baked (handlers.ts) shared types ────────────────────────────
88
+ // These live here (not in ./handlers.ts) so the route factories in
89
+ // ./routes/* can import them without a type-only cycle back into the factory
90
+ // module that aggregates them. `handlers.ts` re-exports them for back-compat.
91
+
92
+ /** Mutable per-request context threaded through the pre-baked handler run. */
93
+ export interface BakedContext {
94
+ /** The request-id string (null when `emitRequestIdHeader` is off). */
95
+ requestIdHeader: string | null
96
+ /** The `Origin` request header when CORS extraction is enabled. */
97
+ origin: string | null
98
+ }
99
+
100
+ /**
101
+ * An optimized ingress handler. `run()` is the zero-alloc pipeline entry
102
+ * point; the response-builder methods are pre-baked and bound to this
103
+ * handler's configuration, so consumers can build custom routes without
104
+ * touching header templates or error bodies.
105
+ */
106
+ export interface OptimizedIngressHandler {
107
+ run<T>(
108
+ req: Request,
109
+ ip: string | undefined,
110
+ body: Uint8Array | null,
111
+ fn: (result: BakedIngressResult, ctx: BakedContext) => T,
112
+ ): T
113
+
114
+ responseHeaders(
115
+ variant: number,
116
+ requestIdHeader: string | null,
117
+ origin: string | null,
118
+ rateRemaining?: number,
119
+ rateResetSecs?: number,
120
+ retryAfterSecs?: number,
121
+ ): [string, string][]
122
+
123
+ terminalHeaders(
124
+ variant: number,
125
+ ctx: BakedContext,
126
+ result: BakedIngressResult | null,
127
+ ): [string, string][]
128
+
129
+ /** `req` is accepted for back-compat but unused by the baked terminal path. */
130
+ terminalResponse(
131
+ req: Request | undefined,
132
+ result: BakedIngressResult,
133
+ ctx: BakedContext,
134
+ ): Response | null
135
+
136
+ /** `req` is accepted for back-compat but unused by the baked error path. */
137
+ errorResponse(
138
+ req: Request | undefined,
139
+ result: BakedIngressResult | null,
140
+ status: number,
141
+ code: string,
142
+ message: string,
143
+ ctx: BakedContext,
144
+ ): Response
145
+
146
+ internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response
147
+
148
+ withContentType(headers: ReadonlyArray<[string, string]>, contentType: string): [string, string][]
149
+
150
+ /**
151
+ * Build a zero-copy `Response` whose body is the request's pooled output
152
+ * slice. The pooled buffer is returned to its pool once the body has been
153
+ * consumed (stream closed) or the request aborted. Use when `copyBody` is
154
+ * `false`; otherwise a copied body is released eagerly at the end of `run()`.
155
+ */
156
+ zeroCopyResponse(result: BakedIngressResult, ctx: BakedContext, init: ResponseInit): Response
157
+ }
158
+
159
+ // ── Responder bridge (native decides, JS formats the 2xx) ─────────
160
+
161
+ /** Terminal (rejection) response envelope style for responder routes.
162
+ * `"castrum"` (default) emits the pre-baked `ok:false`/`ratelimit-*` wire;
163
+ * `"ignex"` emits the ignex framework envelope (`{error,status,code}` +
164
+ * `x-frame-options`/`nosniff`/`referrer-policy` security headers). Additive —
165
+ * castrum's own wire formats are untouched. */
166
+ export type TerminalStyle = 'castrum' | 'ignex'
167
+
168
+ /**
169
+ * Decoded request context handed to a {@link NativeResponder} after the native
170
+ * pipeline succeeded (non-terminal). The native pipeline owns parse/validate/
171
+ * CORS/rate-limit + terminal rejections; the responder builds the 2xx body
172
+ * (async OK). Fields are captured INSIDE the sync `run()` callback, so the
173
+ * pooled output buffer is never aliased after it is released.
174
+ */
175
+ export interface NativeRequestContext {
176
+ readonly requestId: string
177
+ readonly status: number
178
+ readonly rateLimited: boolean
179
+ readonly retryAfterMs: number
180
+ /** Parsed query object (last-wins per key) — `{}` when `parseQuery` is off. */
181
+ readonly query: Record<string, string | string[]>
182
+ /** Parsed cookie object (last-wins per key) — `{}` when `parseCookies` is off. */
183
+ readonly cookies: Record<string, string>
184
+ /** Request body bytes when read for native validation, else empty. */
185
+ readonly body: Uint8Array
186
+ readonly ip: string | undefined
187
+ readonly req: Request
188
+ }
189
+
190
+ /** A JS 2xx builder invoked after the native pipeline succeeds (async OK). */
191
+ export type NativeResponder = (snap: NativeRequestContext) => Response | Promise<Response>
@@ -0,0 +1,58 @@
1
+ // src/integration/batch.ts — Loader-backed bulk processing helpers.
2
+ //
3
+ // The higher-order loader (`castrum.loader`, src/loader) is built for BULK /
4
+ // coalesced work: many same-tick items dispatch as ONE packed native batch
5
+ // call instead of N native crossings, and expensive same-tick `load()`s
6
+ // coalesce into one packed flush. These helpers surface that machinery through
7
+ // the integration layer so the loader is actually USED at runtime — not just
8
+ // exported and benchmarked — for the workloads it genuinely serves.
9
+ //
10
+ // Honest guidance: for ONE item per request the loader's dispatch adds a small
11
+ // constant tax (~1.04–1.29x of a direct scalar call). Batching pays off when
12
+ // you process many items together (validating/hashing a list, batch schema
13
+ // checks) — exactly the pattern these helpers exist for.
14
+
15
+ import {
16
+ loader,
17
+ type LoaderBulk,
18
+ type LoaderBulkArgs,
19
+ type LoaderOpArgs,
20
+ type LoaderOpName,
21
+ type LoaderScalar,
22
+ } from '../loader'
23
+ import type { SchemaValidator } from '../shared/packed'
24
+
25
+ /**
26
+ * Batch-validate JSON documents against a schema through the loader's bulk
27
+ * `schemaValidate` path. Returns a bitset — byte `i` is 1 when document `i`
28
+ * is valid (same shape as `rust.batch.schemaValidate`).
29
+ */
30
+ export function validateMany(validator: SchemaValidator, docs: Uint8Array[]): Uint8Array {
31
+ return loader.schema(validator)(docs)
32
+ }
33
+
34
+ /** Count how many of `docs` validate against `validator` (one packed call). */
35
+ export function validateCount(validator: SchemaValidator, docs: Uint8Array[]): number {
36
+ return loader.schema(validator).count(docs)
37
+ }
38
+
39
+ /**
40
+ * Generic loader bulk dispatch: `loader.run(op, items, ...rest)` → one packed
41
+ * batch native call (or the adaptive scalar loop for tiny batches).
42
+ */
43
+ export function runMany<K extends LoaderOpName>(
44
+ op: K,
45
+ items: Uint8Array[],
46
+ ...rest: LoaderBulkArgs<K>
47
+ ): LoaderBulk<K> {
48
+ return loader.run(op, items, ...rest)
49
+ }
50
+
51
+ /** Single-item loader dispatch — pairs with `runMany` for mixed workloads. */
52
+ export function runOne<K extends LoaderOpName>(
53
+ op: K,
54
+ item: Uint8Array,
55
+ ...rest: LoaderOpArgs[K]
56
+ ): LoaderScalar<K> {
57
+ return loader.run(op, item, ...rest)
58
+ }
@@ -0,0 +1,29 @@
1
+ // src/integration/index.ts — framework-agnostic integration helpers.
2
+ //
3
+ // These helpers let any Bun backend framework (Hono, Elysia, or plain
4
+ // Bun.serve) embed castrum as a request stage:
5
+ // - createPipeline → run the ingress pipeline (rate-limit/CORS/schema/body)
6
+ // and either short-circuit or hand a context to the app
7
+ // - createWebSocketUpgrade → RFC 6455 101 upgrade handshake
8
+ // - sseResponse → server-sent-events response framed on the fast path
9
+
10
+ export {
11
+ createPipeline,
12
+ type IngressPipeline,
13
+ type PipelineResult,
14
+ type PipelineContext,
15
+ type PreprocessOutcome,
16
+ type CreatePipelineOptions,
17
+ } from './pipeline'
18
+ export {
19
+ createWebSocketUpgrade,
20
+ type WebSocketUpgradeOptions,
21
+ type WebSocketUpgradeResult,
22
+ } from './websocket'
23
+ export { sseResponse, type SseEvent } from './streaming'
24
+ export {
25
+ validateMany,
26
+ validateCount,
27
+ runMany,
28
+ runOne,
29
+ } from './batch'
@@ -0,0 +1,220 @@
1
+ // src/integration/pipeline.ts — framework-agnostic ingress pipeline adapter.
2
+ //
3
+ // Embeds the pre-baked ingress pipeline as a request stage that ANY Bun
4
+ // backend framework can use: Hono middleware, Elysia onRequest, or a plain
5
+ // Bun.serve `fetch`. `preprocess` runs the rate-limit / CORS / schema / body
6
+ // logic and either short-circuits (terminal) or hands a snapshotted result +
7
+ // context to the app's own handler. `handleRequest` is a fetch-compatible
8
+ // convenience that serves the terminal response or a rendered OK response.
9
+
10
+ import {
11
+ createIngressHandler,
12
+ type BakedIngressRuntime,
13
+ type OptimizedIngressHandler,
14
+ } from '../ingress/handlers'
15
+ import type { BakedContext } from '../ingress/types'
16
+ import type { IngressHandlerOptions } from '../ingress/options'
17
+ import { readBodyWithLimit } from '../ingress/body'
18
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from '../ingress/shared'
19
+ import { generateRequestId } from '../shared/request-id'
20
+ import { decoder } from '../shared/bytes'
21
+ import type { BakedIngressResult } from '../ingress/decode/baked-result'
22
+
23
+ /** Readonly snapshot of the ingress decision, safe after `run()` returns. */
24
+ export interface PipelineResult {
25
+ readonly ok: boolean
26
+ readonly status: number
27
+ readonly errorCode: number
28
+ readonly terminal: boolean
29
+ readonly rateLimited: boolean
30
+ readonly bodyValidJson: boolean
31
+ readonly schemaValid: boolean
32
+ readonly bodyTruncated: boolean
33
+ readonly requestId: string
34
+ /** The raw request body the pipeline saw (read-only; aliases the request body). */
35
+ readonly body: Uint8Array
36
+ /** The ingress metadata JSON body (when `emitMetadataJson` is on), else empty. */
37
+ readonly metadataJson: Uint8Array
38
+ }
39
+
40
+ /** Per-request context threaded into the app's handler. */
41
+ export interface PipelineContext {
42
+ readonly requestId: string
43
+ readonly ip: string | undefined
44
+ /** Mutable per-request storage for middleware/user state. */
45
+ readonly locals: Map<string, unknown>
46
+ }
47
+
48
+ /** Result of running `preprocess`: terminal short-circuit or pass-through. */
49
+ export interface PreprocessOutcome {
50
+ /** True when the pipeline short-circuited (error/denied) — serve `response`. */
51
+ readonly terminal: boolean
52
+ readonly response: Response | null
53
+ readonly result: PipelineResult | null
54
+ readonly ctx: PipelineContext
55
+ }
56
+
57
+ /** Options for `createPipeline`. */
58
+ export interface CreatePipelineOptions {
59
+ /** Ingress options (same surface as `createIngressHandler`). */
60
+ options?: IngressHandlerOptions
61
+ /** Runtime hooks: onRequest/onResponse/onError, logging, pool sizing. */
62
+ runtime?: BakedIngressRuntime
63
+ /** Body limit for `readBody`/`preprocess`. Default: 1 MiB. */
64
+ maxBodyBytes?: number
65
+ /** Body-read deadline (ms) for `readBody`/`preprocess`. Default: 30s. */
66
+ bodyTimeoutMs?: number
67
+ /**
68
+ * When true (default), `preprocess` reads a request body (guarded) so the
69
+ * result carries it. Set false when the app handler owns the body.
70
+ */
71
+ readBody?: boolean
72
+ /**
73
+ * Optional renderer for the OK (non-terminal) case of `handleRequest`.
74
+ * Defaults to a minimal `{"ok":true,"requestId":...}` JSON body.
75
+ */
76
+ render?: (result: PipelineResult, ctx: PipelineContext) => Response | Promise<Response>
77
+ }
78
+
79
+ /** The composed ingress pipeline: preprocess / handleRequest / readBody. */
80
+ export interface IngressPipeline {
81
+ /** The underlying pre-baked handler (custom route factories / responses). */
82
+ readonly ingress: OptimizedIngressHandler
83
+ /** Run the pipeline for a middleware/framework. */
84
+ preprocess(req: Request, ip?: string): Promise<PreprocessOutcome>
85
+ /** Fetch-compatible handler: terminal response or the renderer's response. */
86
+ handleRequest(req: Request, ip?: string): Promise<Response>
87
+ /** Stream-read a request body with the configured limits. */
88
+ readBody(req: Request, maxBytes?: number, timeoutMs?: number): Promise<Uint8Array>
89
+ }
90
+
91
+ function snapshotResult(result: BakedIngressResult, requestId: string): PipelineResult {
92
+ return {
93
+ ok: result.ok,
94
+ status: result.status,
95
+ errorCode: result.errorCode,
96
+ terminal: result.terminal,
97
+ rateLimited: result.rateLimited,
98
+ bodyValidJson: result.bodyValidJson,
99
+ schemaValid: result.schemaValid,
100
+ bodyTruncated: result.bodyTruncated,
101
+ requestId,
102
+ body: result.body,
103
+ metadataJson: result.bodyJson(true) as Uint8Array,
104
+ }
105
+ }
106
+
107
+ /** Build a terminal error response for a body-read failure (413/408/400). */
108
+ function bodyErrorResponse(
109
+ ingress: OptimizedIngressHandler,
110
+ req: Request,
111
+ requestId: string,
112
+ err: unknown,
113
+ ): Response {
114
+ const ctx: BakedContext = {
115
+ requestIdHeader: requestId,
116
+ origin: req.headers.get('origin'),
117
+ }
118
+ const code = (err as { code?: string } | null)?.code
119
+ if (code === 'BODY_TOO_LARGE') {
120
+ return ingress.errorResponse(req, null, 413, 'body_too_large', 'Request body is too large', ctx)
121
+ }
122
+ if (code === 'REQUEST_TIMEOUT') {
123
+ return ingress.errorResponse(
124
+ req,
125
+ null,
126
+ 408,
127
+ 'request_timeout',
128
+ 'Request body read timed out',
129
+ ctx,
130
+ )
131
+ }
132
+ return ingress.errorResponse(req, null, 400, 'bad_request', 'Bad request', ctx)
133
+ }
134
+
135
+ /**
136
+ * Create a framework-agnostic ingress pipeline.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * // Plain Bun.serve fetch handler
141
+ * const pipeline = createPipeline({ options: { parseCookies: true } });
142
+ * Bun.serve({ port: 3000, fetch: (req) => pipeline.handleRequest(req) });
143
+ *
144
+ * // Hono-style middleware: short-circuit on terminal, else handle the app route
145
+ * app.use("*", async (c, next) => {
146
+ * const { terminal, response, ctx } = await pipeline.preprocess(c.req.raw);
147
+ * if (terminal && response) return response;
148
+ * c.set("ingress", ctx); // pass requestId / locals into the app handler
149
+ * await next();
150
+ * });
151
+ * ```
152
+ */
153
+ export function createPipeline(opts: CreatePipelineOptions = {}): IngressPipeline {
154
+ const ingress = createIngressHandler(opts.options ?? {}, opts.runtime ?? {})
155
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
156
+ const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
157
+ const readBodyEnabled = opts.readBody !== false
158
+ const render =
159
+ opts.render ??
160
+ ((result: PipelineResult): Response =>
161
+ new Response(JSON.stringify({ ok: true, requestId: result.requestId }), {
162
+ status: result.status,
163
+ headers: { 'content-type': 'application/json; charset=utf-8' },
164
+ }))
165
+
166
+ async function preprocess(req: Request, ip?: string): Promise<PreprocessOutcome> {
167
+ const requestId = decoder.decode(generateRequestId())
168
+ const ctx: PipelineContext = { requestId, ip, locals: new Map() }
169
+
170
+ let body: Uint8Array | null = null
171
+ if (readBodyEnabled && req.body !== null) {
172
+ try {
173
+ body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
174
+ } catch (err) {
175
+ return {
176
+ terminal: true,
177
+ response: bodyErrorResponse(ingress, req, requestId, err),
178
+ result: null,
179
+ ctx,
180
+ }
181
+ }
182
+ }
183
+
184
+ return ingress.run<PreprocessOutcome>(req, ip, body, (result, ingressCtx) => {
185
+ if (result.terminal) {
186
+ return {
187
+ terminal: true,
188
+ response: ingress.terminalResponse(req, result, ingressCtx),
189
+ result: null,
190
+ ctx,
191
+ }
192
+ }
193
+ return {
194
+ terminal: false,
195
+ response: null,
196
+ result: snapshotResult(result, requestId),
197
+ ctx,
198
+ }
199
+ })
200
+ }
201
+
202
+ async function handleRequest(req: Request, ip?: string): Promise<Response> {
203
+ const outcome = await preprocess(req, ip)
204
+ if (outcome.terminal && outcome.response) {
205
+ return outcome.response
206
+ }
207
+ const result = outcome.result as PipelineResult
208
+ return render(result, outcome.ctx)
209
+ }
210
+
211
+ function readBody(
212
+ req: Request,
213
+ maxBytes: number = maxBodyBytes,
214
+ timeoutMs: number = bodyTimeoutMs,
215
+ ): Promise<Uint8Array> {
216
+ return readBodyWithLimit(req, maxBytes, true, timeoutMs)
217
+ }
218
+
219
+ return { ingress, preprocess, handleRequest, readBody }
220
+ }
@@ -0,0 +1,78 @@
1
+ // src/integration/streaming.ts — SSE response helper.
2
+
3
+ import { rust } from '../rust-ffi'
4
+ import { encoder } from '../shared/bytes'
5
+ /** One SSE event to frame via `sseResponse` (event/id/retry/data). */ export interface SseEvent {
6
+ /** Optional `event:` field. */
7
+ event?: string | null
8
+ /** Optional `id:` field. */
9
+ id?: string | null
10
+ /** Optional `retry:` field (ms). */
11
+ retry?: number | null
12
+ /** Event data. */
13
+ data: string | Uint8Array
14
+ }
15
+
16
+ /**
17
+ * Build a `text/event-stream` Response from an (async) iterable of events.
18
+ *
19
+ * Each event is framed with the native `rust.sseEncodeEvent` (zero-DOM, byte
20
+ * parity with the FFI primitive), so SSE stays on the fast path:
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * async function* tick() {
25
+ * for (let i = 0; i < 5; i++) {
26
+ * await Bun.sleep(250);
27
+ * yield { event: "tick", data: String(i) };
28
+ * }
29
+ * }
30
+ * return sseResponse(tick(), { headers: { "x-request-id": rid } });
31
+ * ```
32
+ */
33
+ export function sseResponse(
34
+ events: AsyncIterable<SseEvent> | Iterable<SseEvent>,
35
+ init: ResponseInit = {},
36
+ ): Response {
37
+ const stream = new ReadableStream<Uint8Array>({
38
+ async start(controller) {
39
+ try {
40
+ for await (const ev of events) {
41
+ const data = typeof ev.data === 'string' ? encoder.encode(ev.data) : ev.data
42
+ controller.enqueue(
43
+ rust.sseEncodeEvent(ev.event ?? null, data, ev.id ?? null, ev.retry ?? null),
44
+ )
45
+ }
46
+ } finally {
47
+ controller.close()
48
+ }
49
+ },
50
+ })
51
+
52
+ // Merge caller headers without relying on a specific `HeadersInit` type
53
+ // (Bun vs undici global type drift), then force the SSE headers.
54
+ const merged: Record<string, string> = {}
55
+ const provided = init.headers
56
+ if (provided) {
57
+ if (provided instanceof Headers) {
58
+ provided.forEach((value, key) => {
59
+ merged[key] = value
60
+ })
61
+ } else if (Array.isArray(provided)) {
62
+ for (const [key, value] of provided) {
63
+ merged[key] = String(value)
64
+ }
65
+ } else {
66
+ Object.assign(merged, provided)
67
+ }
68
+ }
69
+ merged['content-type'] = 'text/event-stream'
70
+ merged['cache-control'] = 'no-cache'
71
+ merged.connection = 'keep-alive'
72
+
73
+ return new Response(stream, {
74
+ status: init.status,
75
+ statusText: init.statusText,
76
+ headers: merged,
77
+ })
78
+ }
@@ -0,0 +1,93 @@
1
+ // src/integration/websocket.ts — WebSocket upgrade handshake helper.
2
+
3
+ import { isBun } from '../shared/runtime'
4
+ import { rust } from '../rust-ffi'
5
+ /** Options for `createWebSocketUpgrade` (supported subprotocols). */export interface WebSocketUpgradeOptions {
6
+ /** Subprotocols this server supports; the first client-requested match wins. */
7
+ protocols?: ReadonlyArray<string>
8
+ }
9
+
10
+ /** The WebSocket upgrade handshake result (101 response + negotiation info). */
11
+ export interface WebSocketUpgradeResult {
12
+ /** The 101 Switching Protocols response — return it from your fetch handler. */
13
+ readonly response: Response
14
+ /** The client's `Sec-WebSocket-Key` (handy for connection logging). */
15
+ readonly key: string
16
+ /** The negotiated subprotocol, if any. */
17
+ readonly protocol: string | null
18
+ }
19
+
20
+ /**
21
+ * Build a WebSocket 101 upgrade handshake response for a request.
22
+ *
23
+ * Validates `Sec-WebSocket-Key` presence and computes the RFC 6455 accept key
24
+ * via the native `rust.wsAcceptKey`, optionally negotiates a subprotocol, and
25
+ * returns a ready-to-serve `Response`. Returns `null` when the request is not a
26
+ * valid upgrade (missing/empty key).
27
+ *
28
+ * This only produces the handshake — pair it with your framework's own
29
+ * WebSocket server (Bun.serve's `websocket` handler, or a WS library), which
30
+ * owns the subsequent frames:
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const srv = Bun.serve({
35
+ * port: 3000,
36
+ * fetch(req, server) {
37
+ * const up = createWebSocketUpgrade(req, { protocols: ["chat"] });
38
+ * if (up && server.upgrade(req, { data: { protocol: up.protocol } })) {
39
+ * return up.response;
40
+ * }
41
+ * return new Response("Not a websocket", { status: 400 });
42
+ * },
43
+ * websocket: {
44
+ * message(ws, msg) { ws.send(msg); },
45
+ * },
46
+ * });
47
+ * ```
48
+ */
49
+ export function createWebSocketUpgrade(
50
+ req: Request,
51
+ opts: WebSocketUpgradeOptions = {},
52
+ ): WebSocketUpgradeResult | null {
53
+ const key = req.headers.get('sec-websocket-key')
54
+ if (!key || key.length === 0) return null
55
+
56
+ const accept = rust.text.wsAcceptKey(key)
57
+
58
+ let protocol: string | null = null
59
+ const offered = opts.protocols ?? []
60
+ const requested = req.headers.get('sec-websocket-protocol')
61
+ if (requested && offered.length > 0) {
62
+ const wanted = requested
63
+ .split(',')
64
+ .map((s) => s.trim())
65
+ .filter(Boolean)
66
+ protocol = offered.find((p) => wanted.some((w) => w.toLowerCase() === p.toLowerCase())) ?? null
67
+ }
68
+
69
+ // Node's fetch `Response` rejects status 101 (undici allows only 200-599);
70
+ // Bun permits it. This helper produces a Bun.serve-compatible 101 Response,
71
+ // so on Node throw a clear error pointing at the Node adapter's `upgrade`
72
+ // option (which writes the RFC 6455 handshake head directly).
73
+ if (!isBun()) {
74
+ throw new Error(
75
+ "createWebSocketUpgrade is Bun-only: Node's Response cannot carry status 101. " +
76
+ "On Node, use createIngressServerNode's `upgrade` option and compute the " +
77
+ 'accept key with rust.text.wsAcceptKey().',
78
+ )
79
+ }
80
+
81
+ const headers: Record<string, string> = {
82
+ 'sec-websocket-accept': accept,
83
+ }
84
+ if (protocol) {
85
+ headers['sec-websocket-protocol'] = protocol
86
+ }
87
+
88
+ return {
89
+ response: new Response(null, { status: 101, headers }),
90
+ key,
91
+ protocol,
92
+ }
93
+ }