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.
- package/LICENSE +21 -0
- package/README.md +322 -59
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +3161 -0
- package/dist/index.js +7306 -0
- package/docs/ARCHITECTURE.md +415 -0
- package/docs/BENCHMARKS.md +307 -0
- package/docs/ENVIRONMENT.md +79 -0
- package/docs/GETTING_STARTED.md +209 -0
- package/docs/INGRESS.md +745 -0
- package/docs/REPO_MAP.md +259 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +42 -0
- package/docs/bun-builtins-decision-matrix.md +108 -0
- package/index.ts +51 -8
- package/package.json +73 -20
- package/src/data/json-rows.ts +13 -13
- package/src/ingress/body.ts +149 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +563 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +67 -0
- package/src/ingress/index.ts +130 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +75 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +150 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +96 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/routes/common.ts +38 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +37 -0
- package/src/ingress/routes/index.ts +10 -0
- package/src/ingress/routes/json-write.ts +153 -0
- package/src/ingress/routes/options.ts +36 -0
- package/src/ingress/routes/read.ts +45 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +443 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +157 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +217 -0
- package/src/integration/streaming.ts +79 -0
- package/src/integration/websocket.ts +92 -0
- package/src/loader/batch.ts +133 -0
- package/src/loader/cost.ts +104 -0
- package/src/loader/index.ts +930 -0
- package/src/loader/ops.ts +657 -0
- package/src/native/ffi.ts +1541 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +194 -0
- package/src/native/types.ts +598 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch.ts +431 -0
- package/src/rust-ffi/client.ts +88 -0
- package/src/rust-ffi/context.ts +164 -0
- package/src/rust-ffi/index.ts +40 -541
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +42 -0
- package/src/rust-ffi/scalar/crypto.ts +265 -0
- package/src/rust-ffi/scalar/factories.ts +233 -0
- package/src/rust-ffi/scalar/hashing.ts +72 -0
- package/src/rust-ffi/scalar/http.ts +206 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +450 -0
- package/src/rust-ffi/scalar/json.ts +45 -0
- package/src/rust-ffi/scalar/payload.ts +124 -0
- package/src/rust-ffi/text.ts +95 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +77 -0
- package/src/shared/adaptive.ts +68 -0
- package/src/shared/bench-classify.ts +209 -0
- package/src/shared/buffer-pool.ts +209 -0
- package/src/shared/bytes.ts +35 -5
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +74 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed.ts +350 -154
- package/src/shared/proven.ts +596 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +34 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +31 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/ingress/packed-input.ts +0 -59
- 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
|
+
}
|