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,96 @@
|
|
|
1
|
+
// src/ingress/packing/select-headers.ts — Shared header-selection iterator.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for "which headers does a HeaderPlan select" across
|
|
4
|
+
// the THREE packing paths:
|
|
5
|
+
// - `packHeaders` (fast path, ./header-packing.ts)
|
|
6
|
+
// - `gatherRawHeadersPacked` (pre-baked packed path, ./gather-raw-headers.ts)
|
|
7
|
+
// - `gatherRawHeaders` (pre-baked string-array reference, ./gather-raw-headers.ts)
|
|
8
|
+
//
|
|
9
|
+
// All three apply the SAME selection rules and the SAME per-header size guards
|
|
10
|
+
// (an oversized value is dropped rather than forwarded, so the packed block can
|
|
11
|
+
// never exceed the native `max_headers_bytes` (65536) and 500). This module
|
|
12
|
+
// owns that logic plus the pre-encoded header-name bytes, so a rule or size
|
|
13
|
+
// change cannot drift between the paths.
|
|
14
|
+
|
|
15
|
+
import { METHOD_KIND, type HeaderPlan } from '../shared'
|
|
16
|
+
import { MAX_COOKIE_HEADER_BYTES, MAX_SMALL_HEADER_BYTES, MAX_XFF_HEADER_BYTES } from './scratch'
|
|
17
|
+
|
|
18
|
+
const encoder = new TextEncoder()
|
|
19
|
+
|
|
20
|
+
// ── Header name constants (pre-encoded) ────────────────────────
|
|
21
|
+
export const HDR_COOKIE = encoder.encode('cookie')
|
|
22
|
+
export const HDR_ORIGIN = encoder.encode('origin')
|
|
23
|
+
export const HDR_ACRM = encoder.encode('access-control-request-method')
|
|
24
|
+
export const HDR_ACRH = encoder.encode('access-control-request-headers')
|
|
25
|
+
export const HDR_XFF = encoder.encode('x-forwarded-for')
|
|
26
|
+
export const HDR_XRI = encoder.encode('x-real-ip')
|
|
27
|
+
export const HDR_XFP = encoder.encode('x-forwarded-proto')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Visit every request header selected by `plan` (cookie, origin + CORS
|
|
31
|
+
* preflight method/headers, proxy XFF/XRI, proto XFP), applying the shared
|
|
32
|
+
* per-header size guards from scratch.ts. Callers supply `visit(name, value)`
|
|
33
|
+
* with the pre-encoded header-name bytes; no intermediate array is allocated.
|
|
34
|
+
*
|
|
35
|
+
* @param originValue - When provided (non-undefined), the already-fetched
|
|
36
|
+
* `Origin` header value, avoiding a second `req.headers.get('origin')`
|
|
37
|
+
* native→JS conversion on the hot path (the caller, e.g. `handlers.ts::run`,
|
|
38
|
+
* already fetched it once for `ctx.origin`). When omitted, the origin is
|
|
39
|
+
* fetched here. The same `MAX_SMALL_HEADER_BYTES` guard applies either way.
|
|
40
|
+
* @param visit - Invoked once per selected header with the pre-encoded name
|
|
41
|
+
* bytes and the raw value string.
|
|
42
|
+
*/
|
|
43
|
+
export function forEachSelectedHeader(
|
|
44
|
+
req: Request,
|
|
45
|
+
plan: HeaderPlan,
|
|
46
|
+
methodKind: number,
|
|
47
|
+
originValue: string | null | undefined,
|
|
48
|
+
visit: (name: Uint8Array, value: string) => void,
|
|
49
|
+
): void {
|
|
50
|
+
const h = req.headers
|
|
51
|
+
|
|
52
|
+
if (plan.cookie) {
|
|
53
|
+
const v = h.get('cookie')
|
|
54
|
+
if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
|
|
55
|
+
visit(HDR_COOKIE, v)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (plan.cors) {
|
|
60
|
+
const originV = originValue !== undefined ? originValue : h.get('origin')
|
|
61
|
+
if (originV !== null && originV.length <= MAX_SMALL_HEADER_BYTES) {
|
|
62
|
+
visit(HDR_ORIGIN, originV)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (methodKind === METHOD_KIND.OPTIONS) {
|
|
66
|
+
const acrm = h.get('access-control-request-method')
|
|
67
|
+
if (acrm !== null && acrm.length <= MAX_SMALL_HEADER_BYTES) {
|
|
68
|
+
visit(HDR_ACRM, acrm)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const acrh = h.get('access-control-request-headers')
|
|
72
|
+
if (acrh !== null && acrh.length <= MAX_SMALL_HEADER_BYTES) {
|
|
73
|
+
visit(HDR_ACRH, acrh)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (plan.proxy) {
|
|
79
|
+
const xff = h.get('x-forwarded-for')
|
|
80
|
+
if (xff !== null && xff.length <= MAX_XFF_HEADER_BYTES) {
|
|
81
|
+
visit(HDR_XFF, xff)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const xri = h.get('x-real-ip')
|
|
85
|
+
if (xri !== null && xri.length <= MAX_SMALL_HEADER_BYTES) {
|
|
86
|
+
visit(HDR_XRI, xri)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (plan.proto) {
|
|
91
|
+
const xfp = h.get('x-forwarded-proto')
|
|
92
|
+
if (xfp !== null && xfp.length <= MAX_SMALL_HEADER_BYTES) {
|
|
93
|
+
visit(HDR_XFP, xfp)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/ingress/response/error-bodies.ts — Pre-encoded static error bodies
|
|
2
|
+
// (pre-baked path).
|
|
3
|
+
//
|
|
4
|
+
// The pre-baked path emits the benchmark wire format:
|
|
5
|
+
// {"ok":false,"error":{"code":"...","message":"..."}}
|
|
6
|
+
// These bodies are encoded once at module load so error responses never
|
|
7
|
+
// re-allocate or re-serialize on the hot path.
|
|
8
|
+
|
|
9
|
+
import { encoder } from '../../shared/bytes'
|
|
10
|
+
import {
|
|
11
|
+
ERR_CODE_CORS_PREFLIGHT,
|
|
12
|
+
ERR_CODE_RATE_LIMITED,
|
|
13
|
+
ERR_CODE_BODY_TOO_LARGE,
|
|
14
|
+
ERR_CODE_INVALID_JSON,
|
|
15
|
+
ERR_CODE_SCHEMA_VALIDATION,
|
|
16
|
+
ERR_CODE_BAD_REQUEST,
|
|
17
|
+
ERR_CODE_REQUEST_TOO_LARGE,
|
|
18
|
+
ERR_CODE_INTERNAL,
|
|
19
|
+
} from '../constants'
|
|
20
|
+
|
|
21
|
+
function staticErrorBody(code: string, message: string): Uint8Array {
|
|
22
|
+
return encoder.encode(`{"ok":false,"error":{"code":"${code}","message":"${message}"}}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pre-encoded static error bodies, keyed by error-code string name. */
|
|
26
|
+
export const ERROR_BODIES: Record<string, Uint8Array> = {
|
|
27
|
+
not_found: staticErrorBody('not_found', 'Not found'),
|
|
28
|
+
unsupported_media_type: staticErrorBody(
|
|
29
|
+
'unsupported_media_type',
|
|
30
|
+
'Content-Type must be application/json',
|
|
31
|
+
),
|
|
32
|
+
body_too_large: staticErrorBody('body_too_large', 'Request body is too large'),
|
|
33
|
+
request_timeout: staticErrorBody('request_timeout', 'Request body read timed out'),
|
|
34
|
+
invalid_json: staticErrorBody('invalid_json', 'Invalid JSON body'),
|
|
35
|
+
schema_validation_failed: staticErrorBody(
|
|
36
|
+
'schema_validation_failed',
|
|
37
|
+
'Request body failed schema validation',
|
|
38
|
+
),
|
|
39
|
+
cors_preflight_not_allowed: staticErrorBody(
|
|
40
|
+
'cors_preflight_not_allowed',
|
|
41
|
+
'CORS preflight not allowed',
|
|
42
|
+
),
|
|
43
|
+
bad_request: staticErrorBody('bad_request', 'Bad request'),
|
|
44
|
+
request_too_large: staticErrorBody('request_too_large', 'Request too large'),
|
|
45
|
+
rate_limited: staticErrorBody('rate_limited', 'Too Many Requests'),
|
|
46
|
+
internal: staticErrorBody('internal_error', 'Internal server error'),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Error bodies indexed by numeric native error code. */
|
|
50
|
+
export const ERROR_CODE_BODIES: (Uint8Array | undefined)[] = []
|
|
51
|
+
ERROR_CODE_BODIES[ERR_CODE_CORS_PREFLIGHT] = ERROR_BODIES.cors_preflight_not_allowed
|
|
52
|
+
ERROR_CODE_BODIES[ERR_CODE_RATE_LIMITED] = ERROR_BODIES.rate_limited
|
|
53
|
+
ERROR_CODE_BODIES[ERR_CODE_BODY_TOO_LARGE] = ERROR_BODIES.body_too_large
|
|
54
|
+
ERROR_CODE_BODIES[ERR_CODE_INVALID_JSON] = ERROR_BODIES.invalid_json
|
|
55
|
+
ERROR_CODE_BODIES[ERR_CODE_SCHEMA_VALIDATION] = ERROR_BODIES.schema_validation_failed
|
|
56
|
+
ERROR_CODE_BODIES[ERR_CODE_BAD_REQUEST] = ERROR_BODIES.bad_request
|
|
57
|
+
ERROR_CODE_BODIES[ERR_CODE_REQUEST_TOO_LARGE] = ERROR_BODIES.request_too_large
|
|
58
|
+
ERROR_CODE_BODIES[ERR_CODE_INTERNAL] = ERROR_BODIES.internal
|
|
59
|
+
|
|
60
|
+
const RATE_LIMIT_BODY_PREFIX = encoder.encode(
|
|
61
|
+
'{"ok":false,"error":{"code":"rate_limited","message":"Too Many Requests","retry_after_ms":',
|
|
62
|
+
)
|
|
63
|
+
const RATE_LIMIT_BODY_SUFFIX = encoder.encode('}}')
|
|
64
|
+
|
|
65
|
+
/** Build a rate-limited error body with the retry-after value inlined. */
|
|
66
|
+
export function rateLimitedBody(retryAfterMs: number): Uint8Array {
|
|
67
|
+
const digits = encoder.encode(String(Math.max(0, Math.floor(retryAfterMs))))
|
|
68
|
+
const out = new Uint8Array(
|
|
69
|
+
RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength + RATE_LIMIT_BODY_SUFFIX.byteLength,
|
|
70
|
+
)
|
|
71
|
+
out.set(RATE_LIMIT_BODY_PREFIX, 0)
|
|
72
|
+
out.set(digits, RATE_LIMIT_BODY_PREFIX.byteLength)
|
|
73
|
+
out.set(RATE_LIMIT_BODY_SUFFIX, RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength)
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/ingress/response/terminal.ts — Terminal (error) response builder (fast
|
|
2
|
+
// path).
|
|
3
|
+
//
|
|
4
|
+
// Builds the fast-path terminal response: {"error":{code,status,message,
|
|
5
|
+
// requestId}} with x-ratelimit-* headers. This is the fast-path wire format;
|
|
6
|
+
// the pre-baked path has its own builders (see ./handlers.ts) — do not unify
|
|
7
|
+
// them (see AGENTS.md).
|
|
8
|
+
|
|
9
|
+
import { headersForResult, type ResponseBuildContext } from '../headers/fast-templates'
|
|
10
|
+
import { safeTerminalStatus } from '../status'
|
|
11
|
+
import { errorCodeName, errorMessage } from '../errors'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a terminal response for a result, or `null` when the result is not
|
|
15
|
+
* terminal (the caller should serve the body instead).
|
|
16
|
+
*/
|
|
17
|
+
export function buildTerminalResponse(
|
|
18
|
+
ctx: ResponseBuildContext,
|
|
19
|
+
r: {
|
|
20
|
+
readonly terminal: boolean
|
|
21
|
+
readonly isPreflight: boolean
|
|
22
|
+
readonly corsAllowed: boolean
|
|
23
|
+
readonly errorCode: number
|
|
24
|
+
readonly status: number
|
|
25
|
+
readonly headerVariant: number
|
|
26
|
+
readonly https: boolean
|
|
27
|
+
readonly rateLimit: number
|
|
28
|
+
readonly rateRemaining: number
|
|
29
|
+
readonly rateResetMs: number
|
|
30
|
+
readonly retryAfterMs: number
|
|
31
|
+
},
|
|
32
|
+
req: Request,
|
|
33
|
+
requestId: string,
|
|
34
|
+
): Response | null {
|
|
35
|
+
if (!r.terminal) return null
|
|
36
|
+
|
|
37
|
+
const preflightAllowed = r.isPreflight && r.corsAllowed
|
|
38
|
+
const headers = headersForResult(ctx, r, req, requestId)
|
|
39
|
+
|
|
40
|
+
if (preflightAllowed) {
|
|
41
|
+
return new Response(null, { status: 204, headers })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const status = safeTerminalStatus(r)
|
|
45
|
+
|
|
46
|
+
headers.set('content-type', 'application/json; charset=utf-8')
|
|
47
|
+
headers.set('cache-control', 'no-store')
|
|
48
|
+
|
|
49
|
+
const payload = JSON.stringify({
|
|
50
|
+
error: {
|
|
51
|
+
code: errorCodeName(r.errorCode),
|
|
52
|
+
status,
|
|
53
|
+
message: errorMessage(status, r.errorCode),
|
|
54
|
+
requestId: requestId || undefined,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return new Response(payload, { status, headers })
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/ingress/routes/common.ts — Shared route-handler options + helpers.
|
|
2
|
+
|
|
3
|
+
import type { OptimizedIngressHandler } from '../types'
|
|
4
|
+
|
|
5
|
+
/** Options accepted by the pre-baked route factories. */
|
|
6
|
+
export interface BakedHandlerOptions {
|
|
7
|
+
/** Resolve the client IP from the server object (e.g. `srv.requestIP`). */
|
|
8
|
+
getIp?: (req: Request, srv: unknown) => string | undefined
|
|
9
|
+
/** Copy body slices instead of sharing the native buffer. Default: true (safe). */
|
|
10
|
+
copyBody?: boolean
|
|
11
|
+
/** Maximum request body bytes for write/echo handlers. Default: 1 MiB. */
|
|
12
|
+
maxBodyBytes?: number
|
|
13
|
+
/**
|
|
14
|
+
* Overall deadline (ms) for reading the request body. Default: 30s
|
|
15
|
+
* (`DEFAULT_BODY_TIMEOUT_MS`). Set `0` to disable. Protects against
|
|
16
|
+
* slowloris / trickling bodies.
|
|
17
|
+
*/
|
|
18
|
+
bodyTimeoutMs?: number
|
|
19
|
+
/** Fallback handler used for write error paths. Defaults to the write ingress. */
|
|
20
|
+
fallback?: OptimizedIngressHandler
|
|
21
|
+
/**
|
|
22
|
+
* When true, route the JSON-validity fallback check through the higher-order
|
|
23
|
+
* loader (`loader("jsonValid")`) instead of the direct native call —
|
|
24
|
+
* exercising the loader's dispatch + counters on the real request path.
|
|
25
|
+
* Default: false (the direct call is marginally faster for a single request;
|
|
26
|
+
* the loader pays off for same-tick bursts / bulk work, see src/integration/batch.ts).
|
|
27
|
+
*/
|
|
28
|
+
enableLoader?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resolve the client IP for a route request via the configured getter. */
|
|
32
|
+
export function resolveIp(
|
|
33
|
+
req: Request,
|
|
34
|
+
srv: unknown,
|
|
35
|
+
opts: BakedHandlerOptions,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
return opts.getIp ? opts.getIp(req, srv) : undefined
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/ingress/routes/delete.ts — Pre-baked DELETE handler.
|
|
2
|
+
|
|
3
|
+
import { readHandler } from './read'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pre-baked DELETE handler: DELETE is a read-style method (no request body is
|
|
7
|
+
* read), so it shares the exact pipeline flow of {@link readHandler} — it
|
|
8
|
+
* returns the ingress body JSON on success and the terminal/error responses
|
|
9
|
+
* otherwise. Reusing the read factory keeps the two handlers byte-identical
|
|
10
|
+
* and avoids a copy-pasted variant.
|
|
11
|
+
*
|
|
12
|
+
* Wire it via the `delete` key of a `BakedRoute` (see `createIngressServer`).
|
|
13
|
+
*/
|
|
14
|
+
export const deleteHandler = readHandler
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/ingress/routes/echo.ts — Pre-baked echo handler.
|
|
2
|
+
|
|
3
|
+
import type { OptimizedIngressHandler } from '../types'
|
|
4
|
+
import { resolveIp, type BakedHandlerOptions } from './common'
|
|
5
|
+
import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS, secondsFromMs } from '../shared'
|
|
6
|
+
import { HV_JSON } from '../constants'
|
|
7
|
+
import { ERROR_BODIES } from '../response/error-bodies'
|
|
8
|
+
import { readBodyWithLimit } from '../body'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pre-baked echo handler: streams the request body back with the client's
|
|
12
|
+
* Content-Type, bounded by `maxBodyBytes`.
|
|
13
|
+
*/
|
|
14
|
+
export function echoHandler(
|
|
15
|
+
ingress: OptimizedIngressHandler,
|
|
16
|
+
opts: BakedHandlerOptions = {},
|
|
17
|
+
): (req: Request, srv?: unknown) => Promise<Response> {
|
|
18
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
19
|
+
// Non-zero default: protect against slowloris / trickling bodies.
|
|
20
|
+
const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
21
|
+
|
|
22
|
+
return async (req, srv) => {
|
|
23
|
+
const ip = resolveIp(req, srv, opts)
|
|
24
|
+
|
|
25
|
+
const prep = ingress.run<{
|
|
26
|
+
terminal?: Response
|
|
27
|
+
headers?: ReadonlyArray<[string, string]>
|
|
28
|
+
}>(req, ip, null, (result, ctx) => {
|
|
29
|
+
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
30
|
+
if (terminal) return { terminal }
|
|
31
|
+
|
|
32
|
+
if (result.bodyTruncated) {
|
|
33
|
+
return { terminal: ingress.internalErrorResponse(ctx, result) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hv = result.headerVariant & ~HV_JSON
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
headers: ingress.responseHeaders(
|
|
40
|
+
hv,
|
|
41
|
+
ctx.requestIdHeader,
|
|
42
|
+
ctx.origin,
|
|
43
|
+
result.rateRemaining,
|
|
44
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (prep.terminal) return prep.terminal
|
|
50
|
+
|
|
51
|
+
const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? []
|
|
52
|
+
|
|
53
|
+
const requestedContentType = req.headers.get('content-type') ?? 'application/octet-stream'
|
|
54
|
+
|
|
55
|
+
const contentLengthHeader = req.headers.get('content-length')
|
|
56
|
+
const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
|
|
57
|
+
|
|
58
|
+
if (Number.isFinite(contentLength)) {
|
|
59
|
+
if (contentLength > maxBodyBytes) {
|
|
60
|
+
return new Response(ERROR_BODIES.body_too_large, {
|
|
61
|
+
status: 413,
|
|
62
|
+
headers: ingress.withContentType(baseHeaders, 'application/json'),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (contentLength <= 0 || req.body === null) {
|
|
67
|
+
return new Response(null, {
|
|
68
|
+
status: 200,
|
|
69
|
+
headers: ingress.withContentType(baseHeaders, requestedContentType),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new Response(req.body, {
|
|
74
|
+
status: 200,
|
|
75
|
+
headers: ingress.withContentType(baseHeaders, requestedContentType),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Chunked / unknown-length body: stream-read with the limit enforced
|
|
81
|
+
// WHILE reading and an overall deadline — never fully buffer an
|
|
82
|
+
// oversized or trickling body before rejecting it.
|
|
83
|
+
const bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
84
|
+
|
|
85
|
+
return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: ingress.withContentType(baseHeaders, requestedContentType),
|
|
88
|
+
})
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const code = (err as { code?: string } | null)?.code
|
|
91
|
+
const isTooLarge = code === 'BODY_TOO_LARGE'
|
|
92
|
+
const isTimeout = code === 'REQUEST_TIMEOUT'
|
|
93
|
+
|
|
94
|
+
return new Response(
|
|
95
|
+
isTooLarge
|
|
96
|
+
? ERROR_BODIES.body_too_large
|
|
97
|
+
: isTimeout
|
|
98
|
+
? ERROR_BODIES.request_timeout
|
|
99
|
+
: ERROR_BODIES.bad_request,
|
|
100
|
+
{
|
|
101
|
+
status: isTooLarge ? 413 : isTimeout ? 408 : 400,
|
|
102
|
+
headers: ingress.withContentType(baseHeaders, 'application/json'),
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/ingress/routes/fallback.ts — Pre-baked fallback handler.
|
|
2
|
+
|
|
3
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
+
import { resolveIp, type BakedHandlerOptions } from './common'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pre-baked fallback handler: 404 for unmatched routes / OPTIONS.
|
|
9
|
+
*/
|
|
10
|
+
export function fallbackHandler(
|
|
11
|
+
ingress: OptimizedIngressHandler,
|
|
12
|
+
opts: BakedHandlerOptions = {},
|
|
13
|
+
): (req: Request, srv?: unknown) => Response {
|
|
14
|
+
// Result→Response callback, built ONCE per handler (no per-request closure).
|
|
15
|
+
const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
|
|
16
|
+
const terminal = ingress.terminalResponse(undefined, result, ctx)
|
|
17
|
+
if (terminal) return terminal
|
|
18
|
+
|
|
19
|
+
return ingress.errorResponse(undefined, result, 404, 'not_found', 'Not found', ctx)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/ingress/routes/head.ts — Pre-baked HEAD read handler.
|
|
2
|
+
|
|
3
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
+
import { resolveIp, type BakedHandlerOptions } from './common'
|
|
6
|
+
import { secondsFromMs } from '../shared'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pre-baked HEAD read handler: headers only, no body.
|
|
10
|
+
*/
|
|
11
|
+
export function headHandler(
|
|
12
|
+
ingress: OptimizedIngressHandler,
|
|
13
|
+
opts: BakedHandlerOptions = {},
|
|
14
|
+
): (req: Request, srv?: unknown) => Response {
|
|
15
|
+
// Result→Response callback, built ONCE per handler (no per-request closure).
|
|
16
|
+
const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
|
|
17
|
+
const terminal = ingress.terminalResponse(undefined, result, ctx)
|
|
18
|
+
if (terminal) return terminal
|
|
19
|
+
|
|
20
|
+
if (result.bodyTruncated) {
|
|
21
|
+
return ingress.internalErrorResponse(ctx, result)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return new Response(null, {
|
|
25
|
+
status: 200,
|
|
26
|
+
headers: ingress.responseHeaders(
|
|
27
|
+
result.headerVariant,
|
|
28
|
+
ctx.requestIdHeader,
|
|
29
|
+
ctx.origin,
|
|
30
|
+
result.rateRemaining,
|
|
31
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
32
|
+
),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/ingress/routes/index.ts — Route-handler factory composer.
|
|
2
|
+
|
|
3
|
+
export * from './common'
|
|
4
|
+
export * from './read'
|
|
5
|
+
export * from './head'
|
|
6
|
+
export * from './json-write'
|
|
7
|
+
export * from './echo'
|
|
8
|
+
export * from './delete'
|
|
9
|
+
export * from './options'
|
|
10
|
+
export * from './fallback'
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// src/ingress/routes/json-write.ts — Pre-baked JSON-write handler
|
|
2
|
+
// (POST/PUT/PATCH).
|
|
3
|
+
|
|
4
|
+
import type { OptimizedIngressHandler } from '../types'
|
|
5
|
+
import { resolveIp, type BakedHandlerOptions } from './common'
|
|
6
|
+
import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS, secondsFromMs } from '../shared'
|
|
7
|
+
import { readBodyWithLimit } from '../body'
|
|
8
|
+
import { getAddon } from '../../native'
|
|
9
|
+
import { getBunFFI } from '../../native/ffi'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Zero-DOM JSON-validity check — the same native fast path the ingress
|
|
13
|
+
* pipeline uses internally (`json_valid_bytes`).
|
|
14
|
+
*
|
|
15
|
+
* Used by `jsonWriteHandler` as a fallback when the pipeline skipped JSON
|
|
16
|
+
* validation because neither `requireJsonBody` nor a `schema` is configured
|
|
17
|
+
* on the ingress (see the `bodyValidJson` handling in the route).
|
|
18
|
+
*
|
|
19
|
+
* bun:ffi is primary on Bun; the napi addon is the fallback (Node, forced
|
|
20
|
+
* `CASTRUM_FFI_MODE=napi`, or a failed ffi self-test).
|
|
21
|
+
*/
|
|
22
|
+
function isValidJsonBytes(bytes: Uint8Array): boolean {
|
|
23
|
+
const ffi = getBunFFI()
|
|
24
|
+
return ffi ? ffi.jsonValid(bytes) : getAddon().jsonValid(bytes)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pre-baked JSON-write handler (POST/PUT/PATCH): enforces Content-Type,
|
|
29
|
+
* content-length/body-size limits, JSON validity and (optionally) schema
|
|
30
|
+
* validation, then returns the ingress body JSON on success.
|
|
31
|
+
*/
|
|
32
|
+
export function jsonWriteHandler(
|
|
33
|
+
ingress: OptimizedIngressHandler,
|
|
34
|
+
opts: BakedHandlerOptions = {},
|
|
35
|
+
): (req: Request, srv?: unknown) => Promise<Response> {
|
|
36
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
37
|
+
const fallback = opts.fallback ?? ingress
|
|
38
|
+
const copyBody = opts.copyBody !== false
|
|
39
|
+
// Non-zero default: protect against slowloris / trickling bodies. Set
|
|
40
|
+
// `bodyTimeoutMs: 0` to disable.
|
|
41
|
+
const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
42
|
+
|
|
43
|
+
return async (req, srv) => {
|
|
44
|
+
const ip = resolveIp(req, srv, opts)
|
|
45
|
+
|
|
46
|
+
const contentType = req.headers.get('content-type') ?? ''
|
|
47
|
+
if (!contentType.includes('application/json')) {
|
|
48
|
+
return fallback.run(req, ip, null, (result, ctx) => {
|
|
49
|
+
const terminal = fallback.terminalResponse(req, result, ctx)
|
|
50
|
+
if (terminal) return terminal
|
|
51
|
+
|
|
52
|
+
return fallback.errorResponse(
|
|
53
|
+
req,
|
|
54
|
+
result,
|
|
55
|
+
415,
|
|
56
|
+
'unsupported_media_type',
|
|
57
|
+
'Content-Type must be application/json',
|
|
58
|
+
ctx,
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const contentLengthHeader = req.headers.get('content-length')
|
|
64
|
+
const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
|
|
65
|
+
|
|
66
|
+
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
67
|
+
return fallback.run(req, ip, null, (result, ctx) => {
|
|
68
|
+
const terminal = fallback.terminalResponse(req, result, ctx)
|
|
69
|
+
if (terminal) return terminal
|
|
70
|
+
|
|
71
|
+
return fallback.errorResponse(
|
|
72
|
+
req,
|
|
73
|
+
result,
|
|
74
|
+
413,
|
|
75
|
+
'body_too_large',
|
|
76
|
+
'Request body is too large',
|
|
77
|
+
ctx,
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let bodyBytes: Uint8Array
|
|
83
|
+
try {
|
|
84
|
+
// Stream-read with the limit enforced WHILE reading — a body that
|
|
85
|
+
// exceeds maxBodyBytes is rejected as soon as the limit is crossed,
|
|
86
|
+
// never fully buffered first.
|
|
87
|
+
bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const code = (err as { code?: string } | null)?.code
|
|
90
|
+
const isTooLarge = code === 'BODY_TOO_LARGE'
|
|
91
|
+
return fallback.run(req, ip, null, (result, ctx) => {
|
|
92
|
+
const terminal = fallback.terminalResponse(req, result, ctx)
|
|
93
|
+
if (terminal) return terminal
|
|
94
|
+
|
|
95
|
+
return fallback.errorResponse(
|
|
96
|
+
req,
|
|
97
|
+
result,
|
|
98
|
+
isTooLarge ? 413 : 408,
|
|
99
|
+
isTooLarge ? 'body_too_large' : 'request_timeout',
|
|
100
|
+
isTooLarge ? 'Request body is too large' : 'Request body read timed out',
|
|
101
|
+
ctx,
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return ingress.run(req, ip, bodyBytes, (result, ctx) => {
|
|
107
|
+
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
108
|
+
if (terminal) return terminal
|
|
109
|
+
|
|
110
|
+
if (result.bodyTruncated) {
|
|
111
|
+
return ingress.internalErrorResponse(ctx, result)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `bodyValidJson`/`schemaValid` are only computed when the pipeline ran
|
|
115
|
+
// JSON validation — i.e. when `requireJsonBody` is set or a `schema` is
|
|
116
|
+
// configured on the ingress. When `bodyValidJson` is false here,
|
|
117
|
+
// validation was skipped entirely (a genuine validation failure already
|
|
118
|
+
// short-circuits as a terminal 400 above). In that case enforce validity
|
|
119
|
+
// with the same zero-DOM native check, and schema validation trivially
|
|
120
|
+
// passes because no schema was configured (had one been, the pipeline
|
|
121
|
+
// would have run and set the flags).
|
|
122
|
+
if (result.bodyValidJson) {
|
|
123
|
+
if (!result.schemaValid) {
|
|
124
|
+
return ingress.errorResponse(
|
|
125
|
+
req,
|
|
126
|
+
result,
|
|
127
|
+
422,
|
|
128
|
+
'schema_validation_failed',
|
|
129
|
+
'Request body failed schema validation',
|
|
130
|
+
ctx,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
} else if (!isValidJsonBytes(bodyBytes)) {
|
|
134
|
+
return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const init: ResponseInit = {
|
|
138
|
+
status: 200,
|
|
139
|
+
headers: ingress.responseHeaders(
|
|
140
|
+
result.headerVariant,
|
|
141
|
+
ctx.requestIdHeader,
|
|
142
|
+
ctx.origin,
|
|
143
|
+
result.rateRemaining,
|
|
144
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return copyBody
|
|
149
|
+
? new Response(result.bodyJson(true), init)
|
|
150
|
+
: ingress.zeroCopyResponse(result, ctx, init)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
|
|
2
|
+
|
|
3
|
+
import type { OptimizedIngressHandler } from '../types'
|
|
4
|
+
import { resolveIp, type BakedHandlerOptions } from './common'
|
|
5
|
+
import { secondsFromMs } from '../shared'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pre-baked OPTIONS handler: serves the native pipeline's CORS preflight
|
|
9
|
+
* decision — 204 when the preflight is allowed, 403 (terminal) when denied —
|
|
10
|
+
* and a plain 204 for non-preflight OPTIONS.
|
|
11
|
+
*
|
|
12
|
+
* `createIngressServer` wires this for EVERY route that has a handler, so
|
|
13
|
+
* read-only routes also answer preflights (previously OPTIONS only reached the
|
|
14
|
+
* pipeline on write routes that also configured a fallback).
|
|
15
|
+
*/
|
|
16
|
+
export function optionsHandler(
|
|
17
|
+
ingress: OptimizedIngressHandler,
|
|
18
|
+
opts: BakedHandlerOptions = {},
|
|
19
|
+
): (req: Request, srv?: unknown) => Response {
|
|
20
|
+
return (req, srv) =>
|
|
21
|
+
ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
|
|
22
|
+
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
23
|
+
if (terminal) return terminal
|
|
24
|
+
|
|
25
|
+
return new Response(null, {
|
|
26
|
+
status: 204,
|
|
27
|
+
headers: ingress.responseHeaders(
|
|
28
|
+
result.headerVariant,
|
|
29
|
+
ctx.requestIdHeader,
|
|
30
|
+
ctx.origin,
|
|
31
|
+
result.rateRemaining,
|
|
32
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
33
|
+
),
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|