castrum 0.9.0 → 0.9.2
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/README.md +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1653 -1817
- package/dist/index.js +6172 -4294
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +111 -17
- package/docs/BENCHMARKS.md +117 -49
- package/docs/CASE_STUDY.md +243 -0
- package/docs/ENVIRONMENT.md +24 -7
- package/docs/FFI_BUN_GUIDE.md +335 -0
- package/docs/GETTING_STARTED.md +18 -34
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +82 -0
- package/docs/INGRESS.md +11 -7
- package/docs/NATIVE-ROUTE.md +77 -0
- package/docs/REPO_MAP.md +103 -53
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +41 -28
- package/src/ingress/body.ts +167 -27
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +116 -285
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +45 -169
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +110 -22
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +20 -7
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +277 -0
- package/src/ingress/routes/common.ts +50 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +26 -47
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +7 -7
- package/src/ingress/server.ts +83 -112
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +44 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +22 -0
- package/src/native/ffi/types.ts +499 -0
- package/src/native/ffi.ts +307 -1357
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +279 -0
- package/src/native/types.ts +41 -238
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +1 -1
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +40 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +257 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/{packed.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- package/src/shared/bench-classify.ts +0 -209
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/ingress/packing/route-wire.ts — route-wire v3 byte helpers (PURE).
|
|
2
|
+
//
|
|
3
|
+
// The per-route native stack (`rust/ingress/native_route.rs`,
|
|
4
|
+
// `castrum_route_*` / napi `Route`) compiles a descriptor ONCE and runs each
|
|
5
|
+
// request frame in ONE native call. This module owns the WIRE on the JS side:
|
|
6
|
+
// descriptor encoding, frame packing, and result decoding — byte-layout
|
|
7
|
+
// constants MUST match `rust/ingress/native_route.rs` and `@ignex/native`
|
|
8
|
+
// `route-wire.ts` EXACTLY (`ROUTE_DESC_VERSION` bumps on any layout change; a
|
|
9
|
+
// mismatched compiler/addon must be a hard reject, never a silent misparse).
|
|
10
|
+
//
|
|
11
|
+
// PURE: no addon import, no module state — safe for any consumer. The
|
|
12
|
+
// addon-touching factory lives in `src/ingress/native-route.ts`.
|
|
13
|
+
|
|
14
|
+
import { decoder, encoder } from '../../shared/bytes'
|
|
15
|
+
|
|
16
|
+
/** Route descriptor magic (`"ROUT"` LE). Must match `ROUTE_DESC_MAGIC` in Rust. */
|
|
17
|
+
export const ROUTE_DESC_MAGIC = 0x524f5554
|
|
18
|
+
/** Wire version — bump on ANY descriptor/frame/result layout change. */
|
|
19
|
+
export const ROUTE_DESC_VERSION = 3
|
|
20
|
+
|
|
21
|
+
/** Descriptor stage tags (the ordered pipeline a route instance runs). */
|
|
22
|
+
export const ROUTE_STAGE = {
|
|
23
|
+
parseQuery: 0,
|
|
24
|
+
parseCookies: 1,
|
|
25
|
+
validateQuery: 2,
|
|
26
|
+
validateCookies: 3,
|
|
27
|
+
validateBody: 4,
|
|
28
|
+
requireJsonBody: 5,
|
|
29
|
+
} as const
|
|
30
|
+
/** A descriptor stage tag (`ROUTE_STAGE` values, must match Rust). */
|
|
31
|
+
export type RouteStageTag = (typeof ROUTE_STAGE)[keyof typeof ROUTE_STAGE]
|
|
32
|
+
|
|
33
|
+
/** Descriptor part tags (`RoutePartKind`): the schema-bearing request parts. */
|
|
34
|
+
export const ROUTE_PART = { body: 3 } as const
|
|
35
|
+
|
|
36
|
+
/** Result flag bits (`ROUTE_RESULT_FLAG_*` in Rust). */
|
|
37
|
+
export const ROUTE_FLAG = {
|
|
38
|
+
OK: 1 << 0,
|
|
39
|
+
BODY_VALID_JSON: 1 << 1,
|
|
40
|
+
QUERY_VALID: 1 << 2,
|
|
41
|
+
COOKIE_VALID: 1 << 3,
|
|
42
|
+
BODY_VALID: 1 << 4,
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
/** Frame flag: the body section is present (bit 0 of the frame flags word). */
|
|
46
|
+
export const ROUTE_FRAME_FLAG_HAS_BODY = 1 << 0
|
|
47
|
+
|
|
48
|
+
/** Size limits a route descriptor carries. */
|
|
49
|
+
export interface RouteWireLimits {
|
|
50
|
+
maxBodyBytes: number
|
|
51
|
+
maxQueryBytes: number
|
|
52
|
+
maxCookieBytes: number
|
|
53
|
+
maxPairs: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A schema part carried by the descriptor (part tag + draft-07 JSON bytes). */
|
|
57
|
+
export interface RouteWireSchema {
|
|
58
|
+
part: number
|
|
59
|
+
bytes: Uint8Array
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Encode a route plan into the descriptor wire.
|
|
64
|
+
*
|
|
65
|
+
* Layout: `[magic u32][version u32][maxBody u32][maxQuery u32][maxCookie u32]
|
|
66
|
+
* [maxPairs u32][stageCount u32][stages u8…][schemaCount u32]
|
|
67
|
+
* { [part u8][len u32][schema] }…`
|
|
68
|
+
*/
|
|
69
|
+
export function encodeRouteDescriptor(
|
|
70
|
+
pipeline: readonly RouteStageTag[],
|
|
71
|
+
schemas: readonly RouteWireSchema[],
|
|
72
|
+
limits: RouteWireLimits,
|
|
73
|
+
): Uint8Array {
|
|
74
|
+
let total = 8 + 16 + 4 + pipeline.length + 4
|
|
75
|
+
for (const s of schemas) total += 1 + 4 + s.bytes.byteLength
|
|
76
|
+
const out = new Uint8Array(total)
|
|
77
|
+
const view = new DataView(out.buffer)
|
|
78
|
+
let pos = 0
|
|
79
|
+
view.setUint32(pos, ROUTE_DESC_MAGIC, true)
|
|
80
|
+
pos += 4
|
|
81
|
+
view.setUint32(pos, ROUTE_DESC_VERSION, true)
|
|
82
|
+
pos += 4
|
|
83
|
+
for (const v of [
|
|
84
|
+
limits.maxBodyBytes,
|
|
85
|
+
limits.maxQueryBytes,
|
|
86
|
+
limits.maxCookieBytes,
|
|
87
|
+
limits.maxPairs,
|
|
88
|
+
]) {
|
|
89
|
+
view.setUint32(pos, v, true)
|
|
90
|
+
pos += 4
|
|
91
|
+
}
|
|
92
|
+
view.setUint32(pos, pipeline.length, true)
|
|
93
|
+
pos += 4
|
|
94
|
+
for (const stage of pipeline) {
|
|
95
|
+
out[pos] = stage
|
|
96
|
+
pos += 1
|
|
97
|
+
}
|
|
98
|
+
view.setUint32(pos, schemas.length, true)
|
|
99
|
+
pos += 4
|
|
100
|
+
for (const s of schemas) {
|
|
101
|
+
out[pos] = s.part
|
|
102
|
+
pos += 1
|
|
103
|
+
view.setUint32(pos, s.bytes.byteLength, true)
|
|
104
|
+
pos += 4
|
|
105
|
+
out.set(s.bytes, pos)
|
|
106
|
+
pos += s.bytes.byteLength
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Encode a request frame: `[flags u32][qLen][query][cLen][cookie]([bLen][body])`.
|
|
113
|
+
* The `flags` word currently only carries `ROUTE_FRAME_FLAG_HAS_BODY`.
|
|
114
|
+
*/
|
|
115
|
+
export function packRouteFrame(
|
|
116
|
+
query: string,
|
|
117
|
+
cookie: string,
|
|
118
|
+
body: Uint8Array | null,
|
|
119
|
+
): Uint8Array {
|
|
120
|
+
const q = encoder.encode(query)
|
|
121
|
+
const c = encoder.encode(cookie)
|
|
122
|
+
const hasBody = body !== null && body.byteLength > 0
|
|
123
|
+
const total =
|
|
124
|
+
4 + 4 + q.byteLength + 4 + c.byteLength + (hasBody ? 4 + (body?.byteLength ?? 0) : 0)
|
|
125
|
+
const out = new Uint8Array(total)
|
|
126
|
+
const view = new DataView(out.buffer)
|
|
127
|
+
let pos = 0
|
|
128
|
+
view.setUint32(pos, hasBody ? ROUTE_FRAME_FLAG_HAS_BODY : 0, true)
|
|
129
|
+
pos += 4
|
|
130
|
+
view.setUint32(pos, q.byteLength, true)
|
|
131
|
+
pos += 4
|
|
132
|
+
out.set(q, pos)
|
|
133
|
+
pos += q.byteLength
|
|
134
|
+
view.setUint32(pos, c.byteLength, true)
|
|
135
|
+
pos += 4
|
|
136
|
+
out.set(c, pos)
|
|
137
|
+
pos += c.byteLength
|
|
138
|
+
if (hasBody && body) {
|
|
139
|
+
view.setUint32(pos, body.byteLength, true)
|
|
140
|
+
pos += 4
|
|
141
|
+
out.set(body, pos)
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A decoded `[name, value]` pair from a result section. */
|
|
147
|
+
export type RouteWirePair = [string, string]
|
|
148
|
+
|
|
149
|
+
/** The decoded route result: the verdict header + optional pair sections. */
|
|
150
|
+
export interface RouteWireResult {
|
|
151
|
+
/** `ROUTE_FLAG_*` bits from the result header. */
|
|
152
|
+
flags: number
|
|
153
|
+
/** `0` = ok; `400` = not-JSON under requireJsonBody; `422` = schema fail. */
|
|
154
|
+
errorCode: number
|
|
155
|
+
/** Decoded query pairs (present iff the plan compiled `parseQuery`). */
|
|
156
|
+
query: RouteWirePair[]
|
|
157
|
+
/** Decoded cookie pairs (present iff the plan compiled `parseCookies`). */
|
|
158
|
+
cookie: RouteWirePair[]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Decode the result wire: `[flags u32][errorCode u32]` + a query pair section
|
|
163
|
+
* iff `query` and a cookie pair section iff `cookie` (the caller knows its own
|
|
164
|
+
* plan). Sections are `[count u32] { [nameLen u32][name][valueLen u32][value] }`.
|
|
165
|
+
*/
|
|
166
|
+
export function decodeRouteResult(
|
|
167
|
+
buf: Uint8Array,
|
|
168
|
+
opts: { query: boolean; cookie: boolean },
|
|
169
|
+
): RouteWireResult {
|
|
170
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
171
|
+
const flags = view.getUint32(0, true)
|
|
172
|
+
const errorCode = view.getUint32(4, true)
|
|
173
|
+
let pos = 8
|
|
174
|
+
const readPairs = (): RouteWirePair[] => {
|
|
175
|
+
const count = view.getUint32(pos, true)
|
|
176
|
+
pos += 4
|
|
177
|
+
const out: RouteWirePair[] = []
|
|
178
|
+
for (let i = 0; i < count; i++) {
|
|
179
|
+
const nameLen = view.getUint32(pos, true)
|
|
180
|
+
pos += 4
|
|
181
|
+
const name = decoder.decode(buf.subarray(pos, pos + nameLen))
|
|
182
|
+
pos += nameLen
|
|
183
|
+
const valueLen = view.getUint32(pos, true)
|
|
184
|
+
pos += 4
|
|
185
|
+
const value = decoder.decode(buf.subarray(pos, pos + valueLen))
|
|
186
|
+
pos += valueLen
|
|
187
|
+
out.push([name, value])
|
|
188
|
+
}
|
|
189
|
+
return out
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
flags,
|
|
193
|
+
errorCode,
|
|
194
|
+
query: opts.query ? readPairs() : [],
|
|
195
|
+
cookie: opts.cookie ? readPairs() : [],
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -1,29 +1,35 @@
|
|
|
1
1
|
// src/ingress/packing/select-headers.ts — Shared header-selection iterator.
|
|
2
2
|
//
|
|
3
3
|
// Single source of truth for "which headers does a HeaderPlan select" across
|
|
4
|
-
// the
|
|
4
|
+
// the TWO packing paths (a string-array reference variant lives test-local in
|
|
5
|
+
// header-packing.test.ts):
|
|
5
6
|
// - `packHeaders` (fast path, ./header-packing.ts)
|
|
6
7
|
// - `gatherRawHeadersPacked` (pre-baked packed path, ./gather-raw-headers.ts)
|
|
7
|
-
// - `gatherRawHeaders` (pre-baked string-array reference, ./gather-raw-headers.ts)
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Both apply the SAME selection rules and the SAME per-header size guards
|
|
10
10
|
// (an oversized value is dropped rather than forwarded, so the packed block can
|
|
11
11
|
// never exceed the native `max_headers_bytes` (65536) and 500). This module
|
|
12
12
|
// owns that logic plus the pre-encoded header-name bytes, so a rule or size
|
|
13
13
|
// change cannot drift between the paths.
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { encoder } from '../../shared/bytes'
|
|
16
|
+
import { type HeaderPlan, METHOD_KIND } from '../shared'
|
|
16
17
|
import { MAX_COOKIE_HEADER_BYTES, MAX_SMALL_HEADER_BYTES, MAX_XFF_HEADER_BYTES } from './scratch'
|
|
17
18
|
|
|
18
|
-
const encoder = new TextEncoder()
|
|
19
|
-
|
|
20
19
|
// ── Header name constants (pre-encoded) ────────────────────────
|
|
20
|
+
/** Pre-encoded `cookie` header name. */
|
|
21
21
|
export const HDR_COOKIE = encoder.encode('cookie')
|
|
22
|
+
/** Pre-encoded `origin` header name. */
|
|
22
23
|
export const HDR_ORIGIN = encoder.encode('origin')
|
|
24
|
+
/** Pre-encoded `access-control-request-method` header name. */
|
|
23
25
|
export const HDR_ACRM = encoder.encode('access-control-request-method')
|
|
26
|
+
/** Pre-encoded `access-control-request-headers` header name. */
|
|
24
27
|
export const HDR_ACRH = encoder.encode('access-control-request-headers')
|
|
28
|
+
/** Pre-encoded `x-forwarded-for` header name. */
|
|
25
29
|
export const HDR_XFF = encoder.encode('x-forwarded-for')
|
|
30
|
+
/** Pre-encoded `x-real-ip` header name. */
|
|
26
31
|
export const HDR_XRI = encoder.encode('x-real-ip')
|
|
32
|
+
/** Pre-encoded `x-forwarded-proto` header name. */
|
|
27
33
|
export const HDR_XFP = encoder.encode('x-forwarded-proto')
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -39,6 +45,12 @@ export const HDR_XFP = encoder.encode('x-forwarded-proto')
|
|
|
39
45
|
* fetched here. The same `MAX_SMALL_HEADER_BYTES` guard applies either way.
|
|
40
46
|
* @param visit - Invoked once per selected header with the pre-encoded name
|
|
41
47
|
* bytes and the raw value string.
|
|
48
|
+
* @param cookieValue - When provided (non-undefined), the already-fetched
|
|
49
|
+
* `Cookie` header value, avoiding a second `req.headers.get('cookie')`
|
|
50
|
+
* native→JS conversion (the caller's cookie+cors fast path already fetched
|
|
51
|
+
* it once to decide between the cached origin block and the general path).
|
|
52
|
+
* When omitted, the cookie is fetched here — so existing callers are
|
|
53
|
+
* byte-for-byte unchanged.
|
|
42
54
|
*/
|
|
43
55
|
export function forEachSelectedHeader(
|
|
44
56
|
req: Request,
|
|
@@ -46,11 +58,12 @@ export function forEachSelectedHeader(
|
|
|
46
58
|
methodKind: number,
|
|
47
59
|
originValue: string | null | undefined,
|
|
48
60
|
visit: (name: Uint8Array, value: string) => void,
|
|
61
|
+
cookieValue?: string | null,
|
|
49
62
|
): void {
|
|
50
63
|
const h = req.headers
|
|
51
64
|
|
|
52
65
|
if (plan.cookie) {
|
|
53
|
-
const v = h.get('cookie')
|
|
66
|
+
const v = cookieValue !== undefined ? cookieValue : h.get('cookie')
|
|
54
67
|
if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
|
|
55
68
|
visit(HDR_COOKIE, v)
|
|
56
69
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/ingress/path-matcher.ts — `:param` / `*` path matching for pre-baked routes
|
|
2
|
+
//
|
|
3
|
+
// The dynamic-path matcher shared by the Node adapter (server-node.ts) and the
|
|
4
|
+
// router (router.ts). Bun's native router matches dynamic paths itself, so this
|
|
5
|
+
// only runs on Node / route-manager paths. The ingress pipeline does not echo
|
|
6
|
+
// path params in its response, so matching only selects the handler.
|
|
7
|
+
|
|
8
|
+
/** A matched dynamic route: the handler map + the extracted path params. */
|
|
9
|
+
export interface PathMatch {
|
|
10
|
+
methods: Record<string, unknown>
|
|
11
|
+
params: Record<string, string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CompiledDynamicPattern {
|
|
15
|
+
re: RegExp
|
|
16
|
+
paramNames: string[]
|
|
17
|
+
rest: boolean
|
|
18
|
+
methods: Record<string, unknown>
|
|
19
|
+
/** Number of literal (static) segments — used to prefer the most specific pattern. */
|
|
20
|
+
staticSegments: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a path matcher over a route map, supporting `:param` and `*` (rest)
|
|
25
|
+
* segments. Exact (static) paths always win over dynamic patterns; dynamic
|
|
26
|
+
* patterns are ordered most-specific-first (most static segments, then fewest
|
|
27
|
+
* params). Used by `createIngressServerNode` (Bun's native router already
|
|
28
|
+
* matches dynamic patterns itself). The ingress pipeline does not echo path
|
|
29
|
+
* params in its response, so matching only selects the handler.
|
|
30
|
+
*
|
|
31
|
+
* @param routes the built route map (`path` → `method` → handler)
|
|
32
|
+
* @returns a matcher: `pathname` → `{ methods, params }` or `undefined`
|
|
33
|
+
*/
|
|
34
|
+
export function buildPathMatcher(
|
|
35
|
+
routes: Record<string, Record<string, unknown>>,
|
|
36
|
+
): (pathname: string) => PathMatch | undefined {
|
|
37
|
+
const exact = new Map<string, Record<string, unknown>>()
|
|
38
|
+
const dynamic: CompiledDynamicPattern[] = []
|
|
39
|
+
|
|
40
|
+
for (const [path, methods] of Object.entries(routes)) {
|
|
41
|
+
const hasParam = path.indexOf(':') !== -1
|
|
42
|
+
const hasRest = path.indexOf('*') !== -1
|
|
43
|
+
if (!hasParam && !hasRest) {
|
|
44
|
+
exact.set(path, methods)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const paramNames: string[] = []
|
|
49
|
+
let rest = false
|
|
50
|
+
let staticSegments = 0
|
|
51
|
+
const parts = path.split('/').filter((s) => s.length > 0)
|
|
52
|
+
const source = parts
|
|
53
|
+
.map((seg) => {
|
|
54
|
+
if (seg === '*') {
|
|
55
|
+
rest = true
|
|
56
|
+
return '(.*)'
|
|
57
|
+
}
|
|
58
|
+
if (seg.startsWith(':')) {
|
|
59
|
+
paramNames.push(seg.slice(1))
|
|
60
|
+
return '([^/]+)'
|
|
61
|
+
}
|
|
62
|
+
staticSegments++
|
|
63
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
64
|
+
})
|
|
65
|
+
.join('/')
|
|
66
|
+
|
|
67
|
+
dynamic.push({
|
|
68
|
+
re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
|
|
69
|
+
paramNames,
|
|
70
|
+
rest,
|
|
71
|
+
methods,
|
|
72
|
+
staticSegments,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
dynamic.sort(
|
|
77
|
+
(a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return (pathname: string) => {
|
|
81
|
+
const exactHit = exact.get(pathname)
|
|
82
|
+
if (exactHit !== undefined) {
|
|
83
|
+
return { methods: exactHit, params: {} }
|
|
84
|
+
}
|
|
85
|
+
for (const d of dynamic) {
|
|
86
|
+
const m = d.re.exec(pathname)
|
|
87
|
+
if (m !== null) {
|
|
88
|
+
const params: Record<string, string> = {}
|
|
89
|
+
for (let i = 0; i < d.paramNames.length; i++) {
|
|
90
|
+
const raw = m[i + 1]
|
|
91
|
+
if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
|
|
92
|
+
}
|
|
93
|
+
if (d.rest) {
|
|
94
|
+
const restRaw = m[d.paramNames.length + 1]
|
|
95
|
+
if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
|
|
96
|
+
}
|
|
97
|
+
return { methods: d.methods, params }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Percent-decode a matched path segment, tolerating malformed escapes: a bad
|
|
106
|
+
* `%` sequence is returned raw rather than throwing, so the matcher never
|
|
107
|
+
* rejects a request over a decode error.
|
|
108
|
+
*/
|
|
109
|
+
export function safeDecode(value: string): string {
|
|
110
|
+
try {
|
|
111
|
+
return decodeURIComponent(value)
|
|
112
|
+
} catch {
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// src/ingress/response/baked-response.ts — pre-baked response builders (path 2)
|
|
2
|
+
//
|
|
3
|
+
// The seven response-builder methods of `OptimizedIngressHandler`, extracted
|
|
4
|
+
// from createIngressHandler (handlers.ts) so the factory stays focused on
|
|
5
|
+
// packing + pooling. Mirrors fast.ts's response/terminal.ts split — but emits
|
|
6
|
+
// the BENCH wire format (`{"ok":false,"error":{...}}` + `ratelimit-*` headers),
|
|
7
|
+
// NOT the fast path's `x-ratelimit-*` shape (see AGENTS.md — do not unify).
|
|
8
|
+
//
|
|
9
|
+
// The builders are bound to a handler's configuration (header templates,
|
|
10
|
+
// request-id emission, CORS cache) plus a shared mutable `BakedResponseState`
|
|
11
|
+
// that `run()` in handlers.ts owns: `zeroCopyResponse` marks the pooled
|
|
12
|
+
// output buffer as borrowed so `run()` does not release it before the body is
|
|
13
|
+
// consumed.
|
|
14
|
+
|
|
15
|
+
import type { PooledBuffer } from '../../shared/buffer-pool'
|
|
16
|
+
import { encoder } from '../../shared/bytes'
|
|
17
|
+
import { pooledBodyResponse } from '../../shared/response'
|
|
18
|
+
import {
|
|
19
|
+
ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
|
|
20
|
+
HV_CORS_PREFLIGHT,
|
|
21
|
+
HV_CORS_SIMPLE,
|
|
22
|
+
HV_JSON,
|
|
23
|
+
HV_RATE_ACTIVE,
|
|
24
|
+
HV_RATE_LIMITED,
|
|
25
|
+
} from '../constants'
|
|
26
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
27
|
+
import { secondsFromMs } from '../shared'
|
|
28
|
+
import { safeTerminalStatus } from '../status'
|
|
29
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
30
|
+
import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './error-bodies'
|
|
31
|
+
|
|
32
|
+
const EMPTY_BODY = new Uint8Array(0)
|
|
33
|
+
|
|
34
|
+
/** Shared per-request state between `run()` (handlers.ts) and the builders. */
|
|
35
|
+
export interface BakedResponseState {
|
|
36
|
+
/** The pooled output buffer backing the current `run()` (null outside one). */
|
|
37
|
+
currentHandle: PooledBuffer | null
|
|
38
|
+
/** True when a zero-copy Response claimed the buffer (run() must not release). */
|
|
39
|
+
responseBorrowsBuffer: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Configuration the builder factory needs from a handler. */
|
|
43
|
+
export interface BakedResponseBuildersDeps {
|
|
44
|
+
/** Variant-indexed pre-baked header templates (regular responses). */
|
|
45
|
+
headerTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
|
|
46
|
+
/** Variant-indexed pre-baked terminal templates (JSON + no-store). */
|
|
47
|
+
terminalTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
|
|
48
|
+
/** Emit an `x-request-id` header on responses. */
|
|
49
|
+
emitRequestIdHeader: boolean
|
|
50
|
+
/** Abandonment guard (ms) for zero-copy responses. */
|
|
51
|
+
zeroCopyTimeoutMs: number
|
|
52
|
+
/** Cache of Origin-augmented header arrays keyed by `variant\u0000origin`. */
|
|
53
|
+
originHeaderCache: Map<string, [string, string][]>
|
|
54
|
+
/** Shared per-request state (owned by `run()` in handlers.ts). */
|
|
55
|
+
state: BakedResponseState
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the seven response-builder methods of `OptimizedIngressHandler`
|
|
60
|
+
* (everything except `run`), bound to this handler's header templates and
|
|
61
|
+
* request-id policy. The returned methods are the exact bodies that lived in
|
|
62
|
+
* `createIngressHandler`.
|
|
63
|
+
*/
|
|
64
|
+
export function buildBakedResponseBuilders(
|
|
65
|
+
deps: BakedResponseBuildersDeps,
|
|
66
|
+
): Omit<OptimizedIngressHandler, 'run'> {
|
|
67
|
+
const {
|
|
68
|
+
headerTemplates,
|
|
69
|
+
terminalTemplates,
|
|
70
|
+
emitRequestIdHeader,
|
|
71
|
+
zeroCopyTimeoutMs,
|
|
72
|
+
originHeaderCache,
|
|
73
|
+
state,
|
|
74
|
+
} = deps
|
|
75
|
+
|
|
76
|
+
function responseHeaders(
|
|
77
|
+
variant: number,
|
|
78
|
+
requestIdHeader: string | null,
|
|
79
|
+
origin: string | null,
|
|
80
|
+
rateRemaining?: number,
|
|
81
|
+
rateResetSecs?: number,
|
|
82
|
+
retryAfterSecs?: number,
|
|
83
|
+
): [string, string][] {
|
|
84
|
+
const template: ReadonlyArray<[string, string]> =
|
|
85
|
+
headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
86
|
+
|
|
87
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
88
|
+
const needsOrigin =
|
|
89
|
+
((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
90
|
+
const needsRate = (variant & HV_RATE_ACTIVE) !== 0
|
|
91
|
+
const needsRetry = (variant & HV_RATE_LIMITED) !== 0
|
|
92
|
+
|
|
93
|
+
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
94
|
+
// Frozen-readonly → mutable: callers only ever read the baked template
|
|
95
|
+
// (zero-copy steady-state path), so the widening is safe in practice.
|
|
96
|
+
return template as unknown as [string, string][]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
|
|
100
|
+
// Steady-state CORS: only the origin varies — cache the augmented array
|
|
101
|
+
// per (variant, origin) so the Origin-present case (the common
|
|
102
|
+
// browser/bench case) stays allocation-free after the first hit for EACH
|
|
103
|
+
// distinct origin (a single-slot memo thrashed when clients alternate
|
|
104
|
+
// between allowed origins, allocating a fresh array on every switch).
|
|
105
|
+
const key = `${variant & 31}\u0000${origin as string}`
|
|
106
|
+
const cached = originHeaderCache.get(key)
|
|
107
|
+
if (cached !== undefined) {
|
|
108
|
+
return cached
|
|
109
|
+
}
|
|
110
|
+
const entries = new Array<[string, string]>(template.length + 1)
|
|
111
|
+
let i = 0
|
|
112
|
+
for (const pair of template) {
|
|
113
|
+
entries[i++] = pair
|
|
114
|
+
}
|
|
115
|
+
entries[i] = ['access-control-allow-origin', origin as string]
|
|
116
|
+
originHeaderCache.set(key, entries)
|
|
117
|
+
return entries
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let extra = 0
|
|
121
|
+
if (needsRequestId) extra++
|
|
122
|
+
if (needsOrigin) extra++
|
|
123
|
+
if (needsRate) extra += 2
|
|
124
|
+
if (needsRetry) extra++
|
|
125
|
+
|
|
126
|
+
const entries = new Array<[string, string]>(template.length + extra)
|
|
127
|
+
let i = 0
|
|
128
|
+
|
|
129
|
+
for (const pair of template) {
|
|
130
|
+
entries[i++] = pair
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (needsRequestId) {
|
|
134
|
+
entries[i++] = ['x-request-id', requestIdHeader as string]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (needsOrigin) {
|
|
138
|
+
entries[i++] = ['access-control-allow-origin', origin as string]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (needsRate) {
|
|
142
|
+
entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
|
|
143
|
+
entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (needsRetry) {
|
|
147
|
+
entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return entries
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function terminalHeaders(
|
|
154
|
+
variant: number,
|
|
155
|
+
ctx: BakedContext,
|
|
156
|
+
result: BakedIngressResult | null,
|
|
157
|
+
): [string, string][] {
|
|
158
|
+
const v = variant | HV_JSON
|
|
159
|
+
const requestIdHeader = ctx.requestIdHeader
|
|
160
|
+
const origin = ctx.origin
|
|
161
|
+
const rateResetSecs =
|
|
162
|
+
result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
|
|
163
|
+
const retryAfterSecs =
|
|
164
|
+
result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
|
|
165
|
+
|
|
166
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
167
|
+
const needsOrigin =
|
|
168
|
+
((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
169
|
+
const needsRate = (v & HV_RATE_ACTIVE) !== 0
|
|
170
|
+
const needsRetry = (v & HV_RATE_LIMITED) !== 0
|
|
171
|
+
|
|
172
|
+
// Steady state: no request-id/origin/rate extras — serve the pre-baked
|
|
173
|
+
// terminal template (JSON + `cache-control: no-store`) directly with zero
|
|
174
|
+
// per-response array allocation/copy.
|
|
175
|
+
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
176
|
+
// Frozen-readonly → mutable: the baked terminal template is only read.
|
|
177
|
+
return terminalTemplates[v & 31] as unknown as [string, string][]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Rare path: extras appended after the baked entries, then `cache-control`.
|
|
181
|
+
const base = responseHeaders(
|
|
182
|
+
v,
|
|
183
|
+
requestIdHeader,
|
|
184
|
+
origin,
|
|
185
|
+
result?.rateRemaining,
|
|
186
|
+
rateResetSecs,
|
|
187
|
+
retryAfterSecs,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const out = new Array<[string, string]>(base.length + 1)
|
|
191
|
+
let i = 0
|
|
192
|
+
for (const pair of base) {
|
|
193
|
+
out[i++] = pair
|
|
194
|
+
}
|
|
195
|
+
out[base.length] = ['cache-control', 'no-store']
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function terminalResponse(
|
|
200
|
+
_req: Request | undefined,
|
|
201
|
+
result: BakedIngressResult,
|
|
202
|
+
ctx: BakedContext,
|
|
203
|
+
): Response | null {
|
|
204
|
+
if (!result.terminal) {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
209
|
+
|
|
210
|
+
if (preflightAllowed) {
|
|
211
|
+
return new Response(null, {
|
|
212
|
+
status: 204,
|
|
213
|
+
headers: responseHeaders(
|
|
214
|
+
result.headerVariant,
|
|
215
|
+
ctx.requestIdHeader,
|
|
216
|
+
ctx.origin,
|
|
217
|
+
result.rateRemaining,
|
|
218
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
219
|
+
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
220
|
+
),
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const status = safeTerminalStatus(result)
|
|
225
|
+
|
|
226
|
+
const body: Uint8Array =
|
|
227
|
+
result.errorCode === ERROR_CODE_RATE_LIMITED
|
|
228
|
+
? rateLimitedBody(result.retryAfterMs)
|
|
229
|
+
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
|
|
230
|
+
|
|
231
|
+
return new Response(body, {
|
|
232
|
+
status,
|
|
233
|
+
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function errorResponse(
|
|
238
|
+
_req: Request | undefined,
|
|
239
|
+
result: BakedIngressResult | null,
|
|
240
|
+
status: number,
|
|
241
|
+
code: string,
|
|
242
|
+
message: string,
|
|
243
|
+
ctx: BakedContext,
|
|
244
|
+
): Response {
|
|
245
|
+
const body =
|
|
246
|
+
ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
|
|
247
|
+
|
|
248
|
+
return new Response(body, {
|
|
249
|
+
status,
|
|
250
|
+
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
255
|
+
return new Response(ERROR_BODIES.internal, {
|
|
256
|
+
status: 500,
|
|
257
|
+
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function withContentType(
|
|
262
|
+
headers: ReadonlyArray<[string, string]>,
|
|
263
|
+
contentType: string,
|
|
264
|
+
): [string, string][] {
|
|
265
|
+
const out = new Array<[string, string]>(headers.length + 1)
|
|
266
|
+
let i = 0
|
|
267
|
+
for (const pair of headers) {
|
|
268
|
+
out[i++] = pair
|
|
269
|
+
}
|
|
270
|
+
out[headers.length] = ['content-type', contentType]
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function zeroCopyResponse(
|
|
275
|
+
result: BakedIngressResult,
|
|
276
|
+
_ctx: BakedContext,
|
|
277
|
+
init: ResponseInit,
|
|
278
|
+
): Response {
|
|
279
|
+
if (state.currentHandle === null) {
|
|
280
|
+
// `zeroCopyResponse` must only run inside a live `run()` callback (a
|
|
281
|
+
// pooled output buffer must be in flight). Called outside `run()` the
|
|
282
|
+
// result has already been invalidated and `result.bodyJson` is empty —
|
|
283
|
+
// throw instead of silently serving an empty body.
|
|
284
|
+
throw new Error(
|
|
285
|
+
'zeroCopyResponse() can only be called from within a run() callback ' +
|
|
286
|
+
'(the pooled output buffer is only live during run()).',
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
state.responseBorrowsBuffer = true
|
|
290
|
+
return pooledBodyResponse(state.currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
responseHeaders,
|
|
295
|
+
terminalHeaders,
|
|
296
|
+
terminalResponse,
|
|
297
|
+
errorResponse,
|
|
298
|
+
internalErrorResponse,
|
|
299
|
+
withContentType,
|
|
300
|
+
zeroCopyResponse,
|
|
301
|
+
}
|
|
302
|
+
}
|