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,157 @@
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 { IngressFastOptions } from './options'
8
+ import type { BakedIngressResult } from './decode/baked-result'
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
+ }
@@ -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,217 @@
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
+ export interface PreprocessOutcome {
49
+ /** True when the pipeline short-circuited (error/denied) — serve `response`. */
50
+ readonly terminal: boolean
51
+ readonly response: Response | null
52
+ readonly result: PipelineResult | null
53
+ readonly ctx: PipelineContext
54
+ }
55
+
56
+ export interface CreatePipelineOptions {
57
+ /** Ingress options (same surface as `createIngressHandler`). */
58
+ options?: IngressHandlerOptions
59
+ /** Runtime hooks: onRequest/onResponse/onError, logging, pool sizing. */
60
+ runtime?: BakedIngressRuntime
61
+ /** Body limit for `readBody`/`preprocess`. Default: 1 MiB. */
62
+ maxBodyBytes?: number
63
+ /** Body-read deadline (ms) for `readBody`/`preprocess`. Default: 30s. */
64
+ bodyTimeoutMs?: number
65
+ /**
66
+ * When true (default), `preprocess` reads a request body (guarded) so the
67
+ * result carries it. Set false when the app handler owns the body.
68
+ */
69
+ readBody?: boolean
70
+ /**
71
+ * Optional renderer for the OK (non-terminal) case of `handleRequest`.
72
+ * Defaults to a minimal `{"ok":true,"requestId":...}` JSON body.
73
+ */
74
+ render?: (result: PipelineResult, ctx: PipelineContext) => Response | Promise<Response>
75
+ }
76
+
77
+ export interface IngressPipeline {
78
+ /** The underlying pre-baked handler (custom route factories / responses). */
79
+ readonly ingress: OptimizedIngressHandler
80
+ /** Run the pipeline for a middleware/framework. */
81
+ preprocess(req: Request, ip?: string): Promise<PreprocessOutcome>
82
+ /** Fetch-compatible handler: terminal response or the renderer's response. */
83
+ handleRequest(req: Request, ip?: string): Promise<Response>
84
+ /** Stream-read a request body with the configured limits. */
85
+ readBody(req: Request, maxBytes?: number, timeoutMs?: number): Promise<Uint8Array>
86
+ }
87
+
88
+ function snapshotResult(result: BakedIngressResult, requestId: string): PipelineResult {
89
+ return {
90
+ ok: result.ok,
91
+ status: result.status,
92
+ errorCode: result.errorCode,
93
+ terminal: result.terminal,
94
+ rateLimited: result.rateLimited,
95
+ bodyValidJson: result.bodyValidJson,
96
+ schemaValid: result.schemaValid,
97
+ bodyTruncated: result.bodyTruncated,
98
+ requestId,
99
+ body: result.body,
100
+ metadataJson: result.bodyJson(true) as Uint8Array,
101
+ }
102
+ }
103
+
104
+ /** Build a terminal error response for a body-read failure (413/408/400). */
105
+ function bodyErrorResponse(
106
+ ingress: OptimizedIngressHandler,
107
+ req: Request,
108
+ requestId: string,
109
+ err: unknown,
110
+ ): Response {
111
+ const ctx: BakedContext = {
112
+ requestIdHeader: requestId,
113
+ origin: req.headers.get('origin'),
114
+ }
115
+ const code = (err as { code?: string } | null)?.code
116
+ if (code === 'BODY_TOO_LARGE') {
117
+ return ingress.errorResponse(req, null, 413, 'body_too_large', 'Request body is too large', ctx)
118
+ }
119
+ if (code === 'REQUEST_TIMEOUT') {
120
+ return ingress.errorResponse(
121
+ req,
122
+ null,
123
+ 408,
124
+ 'request_timeout',
125
+ 'Request body read timed out',
126
+ ctx,
127
+ )
128
+ }
129
+ return ingress.errorResponse(req, null, 400, 'bad_request', 'Bad request', ctx)
130
+ }
131
+
132
+ /**
133
+ * Create a framework-agnostic ingress pipeline.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * // Plain Bun.serve fetch handler
138
+ * const pipeline = createPipeline({ options: { parseCookies: true } });
139
+ * Bun.serve({ port: 3000, fetch: (req) => pipeline.handleRequest(req) });
140
+ *
141
+ * // Hono-style middleware: short-circuit on terminal, else handle the app route
142
+ * app.use("*", async (c, next) => {
143
+ * const { terminal, response, ctx } = await pipeline.preprocess(c.req.raw);
144
+ * if (terminal && response) return response;
145
+ * c.set("ingress", ctx); // pass requestId / locals into the app handler
146
+ * await next();
147
+ * });
148
+ * ```
149
+ */
150
+ export function createPipeline(opts: CreatePipelineOptions = {}): IngressPipeline {
151
+ const ingress = createIngressHandler(opts.options ?? {}, opts.runtime ?? {})
152
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
153
+ const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
154
+ const readBodyEnabled = opts.readBody !== false
155
+ const render =
156
+ opts.render ??
157
+ ((result: PipelineResult): Response =>
158
+ new Response(JSON.stringify({ ok: true, requestId: result.requestId }), {
159
+ status: result.status,
160
+ headers: { 'content-type': 'application/json; charset=utf-8' },
161
+ }))
162
+
163
+ async function preprocess(req: Request, ip?: string): Promise<PreprocessOutcome> {
164
+ const requestId = decoder.decode(generateRequestId())
165
+ const ctx: PipelineContext = { requestId, ip, locals: new Map() }
166
+
167
+ let body: Uint8Array | null = null
168
+ if (readBodyEnabled && req.body !== null) {
169
+ try {
170
+ body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
171
+ } catch (err) {
172
+ return {
173
+ terminal: true,
174
+ response: bodyErrorResponse(ingress, req, requestId, err),
175
+ result: null,
176
+ ctx,
177
+ }
178
+ }
179
+ }
180
+
181
+ return ingress.run<PreprocessOutcome>(req, ip, body, (result, ingressCtx) => {
182
+ if (result.terminal) {
183
+ return {
184
+ terminal: true,
185
+ response: ingress.terminalResponse(req, result, ingressCtx),
186
+ result: null,
187
+ ctx,
188
+ }
189
+ }
190
+ return {
191
+ terminal: false,
192
+ response: null,
193
+ result: snapshotResult(result, requestId),
194
+ ctx,
195
+ }
196
+ })
197
+ }
198
+
199
+ async function handleRequest(req: Request, ip?: string): Promise<Response> {
200
+ const outcome = await preprocess(req, ip)
201
+ if (outcome.terminal && outcome.response) {
202
+ return outcome.response
203
+ }
204
+ const result = outcome.result as PipelineResult
205
+ return render(result, outcome.ctx)
206
+ }
207
+
208
+ function readBody(
209
+ req: Request,
210
+ maxBytes: number = maxBodyBytes,
211
+ timeoutMs: number = bodyTimeoutMs,
212
+ ): Promise<Uint8Array> {
213
+ return readBodyWithLimit(req, maxBytes, true, timeoutMs)
214
+ }
215
+
216
+ return { ingress, preprocess, handleRequest, readBody }
217
+ }
@@ -0,0 +1,79 @@
1
+ // src/integration/streaming.ts — SSE response helper.
2
+
3
+ import { rust } from '../rust-ffi'
4
+
5
+ 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 encoder = new TextEncoder()
38
+ const stream = new ReadableStream<Uint8Array>({
39
+ async start(controller) {
40
+ try {
41
+ for await (const ev of events) {
42
+ const data = typeof ev.data === 'string' ? encoder.encode(ev.data) : ev.data
43
+ controller.enqueue(
44
+ rust.sseEncodeEvent(ev.event ?? null, data, ev.id ?? null, ev.retry ?? null),
45
+ )
46
+ }
47
+ } finally {
48
+ controller.close()
49
+ }
50
+ },
51
+ })
52
+
53
+ // Merge caller headers without relying on a specific `HeadersInit` type
54
+ // (Bun vs undici global type drift), then force the SSE headers.
55
+ const merged: Record<string, string> = {}
56
+ const provided = init.headers
57
+ if (provided) {
58
+ if (provided instanceof Headers) {
59
+ provided.forEach((value, key) => {
60
+ merged[key] = value
61
+ })
62
+ } else if (Array.isArray(provided)) {
63
+ for (const [key, value] of provided) {
64
+ merged[key] = String(value)
65
+ }
66
+ } else {
67
+ Object.assign(merged, provided)
68
+ }
69
+ }
70
+ merged['content-type'] = 'text/event-stream'
71
+ merged['cache-control'] = 'no-cache'
72
+ merged.connection = 'keep-alive'
73
+
74
+ return new Response(stream, {
75
+ status: init.status,
76
+ statusText: init.statusText,
77
+ headers: merged,
78
+ })
79
+ }
@@ -0,0 +1,92 @@
1
+ // src/integration/websocket.ts — WebSocket upgrade handshake helper.
2
+
3
+ import { rust } from '../rust-ffi'
4
+
5
+ export interface WebSocketUpgradeOptions {
6
+ /** Subprotocols this server supports; the first client-requested match wins. */
7
+ protocols?: ReadonlyArray<string>
8
+ }
9
+
10
+ export interface WebSocketUpgradeResult {
11
+ /** The 101 Switching Protocols response — return it from your fetch handler. */
12
+ readonly response: Response
13
+ /** The client's `Sec-WebSocket-Key` (handy for connection logging). */
14
+ readonly key: string
15
+ /** The negotiated subprotocol, if any. */
16
+ readonly protocol: string | null
17
+ }
18
+
19
+ /**
20
+ * Build a WebSocket 101 upgrade handshake response for a request.
21
+ *
22
+ * Validates `Sec-WebSocket-Key` presence and computes the RFC 6455 accept key
23
+ * via the native `rust.wsAcceptKey`, optionally negotiates a subprotocol, and
24
+ * returns a ready-to-serve `Response`. Returns `null` when the request is not a
25
+ * valid upgrade (missing/empty key).
26
+ *
27
+ * This only produces the handshake — pair it with your framework's own
28
+ * WebSocket server (Bun.serve's `websocket` handler, or a WS library), which
29
+ * owns the subsequent frames:
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const srv = Bun.serve({
34
+ * port: 3000,
35
+ * fetch(req, server) {
36
+ * const up = createWebSocketUpgrade(req, { protocols: ["chat"] });
37
+ * if (up && server.upgrade(req, { data: { protocol: up.protocol } })) {
38
+ * return up.response;
39
+ * }
40
+ * return new Response("Not a websocket", { status: 400 });
41
+ * },
42
+ * websocket: {
43
+ * message(ws, msg) { ws.send(msg); },
44
+ * },
45
+ * });
46
+ * ```
47
+ */
48
+ export function createWebSocketUpgrade(
49
+ req: Request,
50
+ opts: WebSocketUpgradeOptions = {},
51
+ ): WebSocketUpgradeResult | null {
52
+ const key = req.headers.get('sec-websocket-key')
53
+ if (!key || key.length === 0) return null
54
+
55
+ const accept = rust.text.wsAcceptKey(key)
56
+
57
+ let protocol: string | null = null
58
+ const offered = opts.protocols ?? []
59
+ const requested = req.headers.get('sec-websocket-protocol')
60
+ if (requested && offered.length > 0) {
61
+ const wanted = requested
62
+ .split(',')
63
+ .map((s) => s.trim())
64
+ .filter(Boolean)
65
+ protocol = offered.find((p) => wanted.some((w) => w.toLowerCase() === p.toLowerCase())) ?? null
66
+ }
67
+
68
+ // Node's fetch `Response` rejects status 101 (undici allows only 200-599);
69
+ // Bun permits it. This helper produces a Bun.serve-compatible 101 Response,
70
+ // so on Node throw a clear error pointing at the Node adapter's `upgrade`
71
+ // option (which writes the RFC 6455 handshake head directly).
72
+ if (typeof Bun === 'undefined') {
73
+ throw new Error(
74
+ "createWebSocketUpgrade is Bun-only: Node's Response cannot carry status 101. " +
75
+ "On Node, use createIngressServerNode's `upgrade` option and compute the " +
76
+ 'accept key with rust.text.wsAcceptKey().',
77
+ )
78
+ }
79
+
80
+ const headers: Record<string, string> = {
81
+ 'sec-websocket-accept': accept,
82
+ }
83
+ if (protocol) {
84
+ headers['sec-websocket-protocol'] = protocol
85
+ }
86
+
87
+ return {
88
+ response: new Response(null, { status: 101, headers }),
89
+ key,
90
+ protocol,
91
+ }
92
+ }