castrum 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -368
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +2763 -0
- package/dist/index.js +8707 -0
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +507 -0
- package/docs/BENCHMARKS.md +343 -0
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +81 -0
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +192 -0
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +746 -0
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +279 -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 +43 -0
- package/docs/bun-builtins-decision-matrix.md +115 -0
- package/index.ts +44 -8
- package/package.json +98 -33
- package/src/ingress/body.ts +289 -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 +639 -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 +68 -0
- package/src/ingress/index.ts +142 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +139 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +164 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +102 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +79 -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 +27 -0
- package/src/ingress/routes/index.ts +11 -0
- package/src/ingress/routes/json-write.ts +129 -0
- package/src/ingress/routes/options.ts +26 -0
- package/src/ingress/routes/read.ts +35 -0
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +475 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +191 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +220 -0
- package/src/integration/streaming.ts +78 -0
- package/src/integration/websocket.ts +93 -0
- package/src/loader/batch.ts +137 -0
- package/src/loader/cost.ts +105 -0
- package/src/loader/index.ts +745 -0
- package/src/loader/ops.ts +657 -0
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1775 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +240 -0
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +401 -0
- 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 +10 -0
- 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 +87 -0
- package/src/rust-ffi/context.ts +193 -0
- package/src/rust-ffi/index.ts +51 -541
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +28 -0
- package/src/rust-ffi/scalar/crypto.ts +346 -0
- package/src/rust-ffi/scalar/factories.ts +459 -0
- package/src/rust-ffi/scalar/hashing.ts +74 -0
- package/src/rust-ffi/scalar/http.ts +337 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +325 -0
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +61 -0
- package/src/rust-ffi/scalar/payload.ts +241 -0
- package/src/rust-ffi/text.ts +121 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +73 -0
- package/src/shared/adaptive.ts +67 -0
- package/src/shared/buffer-pool.ts +264 -0
- package/src/shared/bytes.ts +108 -9
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +75 -0
- package/src/shared/metrics.ts +247 -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/wire.ts +504 -0
- package/src/shared/proven.ts +289 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +29 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +15 -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/data/json-rows.ts +0 -33
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
- package/src/shared/packed.ts +0 -316
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/ingress/routes/common.ts — Shared route-handler options + helpers.
|
|
2
|
+
|
|
3
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
+
import { secondsFromMs } from '../shared'
|
|
6
|
+
|
|
7
|
+
/** Options accepted by the pre-baked route factories. */
|
|
8
|
+
export interface BakedHandlerOptions {
|
|
9
|
+
/** Resolve the client IP from the server object (e.g. `srv.requestIP`). */
|
|
10
|
+
getIp?: (req: Request, srv: unknown) => string | undefined
|
|
11
|
+
/** Copy body slices instead of sharing the native buffer. Default: true (safe). */
|
|
12
|
+
copyBody?: boolean
|
|
13
|
+
/** Maximum request body bytes for write/echo handlers. Default: 1 MiB. */
|
|
14
|
+
maxBodyBytes?: number
|
|
15
|
+
/**
|
|
16
|
+
* Overall deadline (ms) for reading the request body. Default: 30s
|
|
17
|
+
* (`DEFAULT_BODY_TIMEOUT_MS`). Set `0` to disable. Protects against
|
|
18
|
+
* slowloris / trickling bodies.
|
|
19
|
+
*/
|
|
20
|
+
bodyTimeoutMs?: number
|
|
21
|
+
/** Fallback handler used for write error paths. Defaults to the write ingress. */
|
|
22
|
+
fallback?: OptimizedIngressHandler
|
|
23
|
+
/**
|
|
24
|
+
* When true, route the JSON-validity fallback check through the higher-order
|
|
25
|
+
* loader (`loader("jsonValid")`) instead of the direct native call —
|
|
26
|
+
* exercising the loader's dispatch + counters on the real request path.
|
|
27
|
+
* Default: false (the direct call is marginally faster for a single request;
|
|
28
|
+
* the loader pays off for same-tick bursts / bulk work, see src/integration/batch.ts).
|
|
29
|
+
*/
|
|
30
|
+
enableLoader?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve the client IP for a route request via the configured getter. */
|
|
34
|
+
export function resolveIp(
|
|
35
|
+
req: Request,
|
|
36
|
+
srv: unknown,
|
|
37
|
+
opts: BakedHandlerOptions,
|
|
38
|
+
): string | undefined {
|
|
39
|
+
return opts.getIp ? opts.getIp(req, srv) : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the success `ResponseInit` (status + ratelimit headers) shared by
|
|
44
|
+
* every pre-baked success path. A plain function call, so the hot path keeps
|
|
45
|
+
* its single init-object allocation and nothing else.
|
|
46
|
+
*
|
|
47
|
+
* @param status Success status; defaults to 200.
|
|
48
|
+
*/
|
|
49
|
+
export function buildSuccessInit(
|
|
50
|
+
ingress: OptimizedIngressHandler,
|
|
51
|
+
result: BakedIngressResult,
|
|
52
|
+
ctx: BakedContext,
|
|
53
|
+
status = 200,
|
|
54
|
+
): ResponseInit {
|
|
55
|
+
return {
|
|
56
|
+
status,
|
|
57
|
+
headers: ingress.responseHeaders(
|
|
58
|
+
result.headerVariant,
|
|
59
|
+
ctx.requestIdHeader,
|
|
60
|
+
ctx.origin,
|
|
61
|
+
result.rateRemaining,
|
|
62
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Wrap a `(result, ctx) => Response` responder in the shared sync route
|
|
69
|
+
* runner: resolve the client IP once and dispatch through the native pipeline
|
|
70
|
+
* (`ingress.run` with a null body). Used by the read / head / fallback route
|
|
71
|
+
* factories whose responder never needs the request object.
|
|
72
|
+
*/
|
|
73
|
+
export function runBaked(
|
|
74
|
+
ingress: OptimizedIngressHandler,
|
|
75
|
+
opts: BakedHandlerOptions,
|
|
76
|
+
respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
|
|
77
|
+
): (req: Request, srv?: unknown) => Response {
|
|
78
|
+
return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
|
|
79
|
+
}
|
|
@@ -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 { runBaked, 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 runBaked(ingress, opts, respond)
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pre-baked HEAD read handler: headers only, no body.
|
|
9
|
+
*/
|
|
10
|
+
export function headHandler(
|
|
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
|
+
if (result.bodyTruncated) {
|
|
20
|
+
return ingress.internalErrorResponse(ctx, result)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return new Response(null, buildSuccessInit(ingress, result, ctx))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return runBaked(ingress, opts, respond)
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/ingress/routes/index.ts — Route-handler factory composer.
|
|
2
|
+
|
|
3
|
+
export * from './common'
|
|
4
|
+
export * from './delete'
|
|
5
|
+
export * from './echo'
|
|
6
|
+
export * from './fallback'
|
|
7
|
+
export * from './head'
|
|
8
|
+
export * from './json-write'
|
|
9
|
+
export * from './options'
|
|
10
|
+
export * from './read'
|
|
11
|
+
export * from './responder'
|
|
@@ -0,0 +1,129 @@
|
|
|
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 { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
|
|
6
|
+
import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } 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
|
+
// Content-Length / body-size enforcement happens INSIDE readBodyWithLimit
|
|
64
|
+
// (guard on): the declared length is checked before reading and re-checked
|
|
65
|
+
// after, so a client lying about Content-Length still gets a 413. Not
|
|
66
|
+
// re-reading the header here saves a per-POST headers.get on the hot path.
|
|
67
|
+
let bodyBytes: Uint8Array
|
|
68
|
+
try {
|
|
69
|
+
// Stream-read with the limit enforced WHILE reading — a body that
|
|
70
|
+
// exceeds maxBodyBytes is rejected as soon as the limit is crossed,
|
|
71
|
+
// never fully buffered first.
|
|
72
|
+
bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const code = (err as { code?: string } | null)?.code
|
|
75
|
+
const isTooLarge = code === 'BODY_TOO_LARGE'
|
|
76
|
+
return fallback.run(req, ip, null, (result, ctx) => {
|
|
77
|
+
const terminal = fallback.terminalResponse(req, result, ctx)
|
|
78
|
+
if (terminal) return terminal
|
|
79
|
+
|
|
80
|
+
return fallback.errorResponse(
|
|
81
|
+
req,
|
|
82
|
+
result,
|
|
83
|
+
isTooLarge ? 413 : 408,
|
|
84
|
+
isTooLarge ? 'body_too_large' : 'request_timeout',
|
|
85
|
+
isTooLarge ? 'Request body is too large' : 'Request body read timed out',
|
|
86
|
+
ctx,
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return ingress.run(req, ip, bodyBytes, (result, ctx) => {
|
|
92
|
+
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
93
|
+
if (terminal) return terminal
|
|
94
|
+
|
|
95
|
+
if (result.bodyTruncated) {
|
|
96
|
+
return ingress.internalErrorResponse(ctx, result)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// `bodyValidJson`/`schemaValid` are only computed when the pipeline ran
|
|
100
|
+
// JSON validation — i.e. when `requireJsonBody` is set or a `schema` is
|
|
101
|
+
// configured on the ingress. When `bodyValidJson` is false here,
|
|
102
|
+
// validation was skipped entirely (a genuine validation failure already
|
|
103
|
+
// short-circuits as a terminal 400 above). In that case enforce validity
|
|
104
|
+
// with the same zero-DOM native check, and schema validation trivially
|
|
105
|
+
// passes because no schema was configured (had one been, the pipeline
|
|
106
|
+
// would have run and set the flags).
|
|
107
|
+
if (result.bodyValidJson) {
|
|
108
|
+
if (!result.schemaValid) {
|
|
109
|
+
return ingress.errorResponse(
|
|
110
|
+
req,
|
|
111
|
+
result,
|
|
112
|
+
422,
|
|
113
|
+
'schema_validation_failed',
|
|
114
|
+
'Request body failed schema validation',
|
|
115
|
+
ctx,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
} else if (!isValidJsonBytes(bodyBytes)) {
|
|
119
|
+
return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const init = buildSuccessInit(ingress, result, ctx)
|
|
123
|
+
|
|
124
|
+
return copyBody
|
|
125
|
+
? new Response(result.bodyJson(true), init)
|
|
126
|
+
: ingress.zeroCopyResponse(result, ctx, init)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
|
|
2
|
+
|
|
3
|
+
import type { OptimizedIngressHandler } from '../types'
|
|
4
|
+
import { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pre-baked OPTIONS handler: serves the native pipeline's CORS preflight
|
|
8
|
+
* decision — 204 when the preflight is allowed, 403 (terminal) when denied —
|
|
9
|
+
* and a plain 204 for non-preflight OPTIONS.
|
|
10
|
+
*
|
|
11
|
+
* `createIngressServer` wires this for EVERY route that has a handler, so
|
|
12
|
+
* read-only routes also answer preflights (previously OPTIONS only reached the
|
|
13
|
+
* pipeline on write routes that also configured a fallback).
|
|
14
|
+
*/
|
|
15
|
+
export function optionsHandler(
|
|
16
|
+
ingress: OptimizedIngressHandler,
|
|
17
|
+
opts: BakedHandlerOptions = {},
|
|
18
|
+
): (req: Request, srv?: unknown) => Response {
|
|
19
|
+
return (req, srv) =>
|
|
20
|
+
ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
|
|
21
|
+
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
22
|
+
if (terminal) return terminal
|
|
23
|
+
|
|
24
|
+
return new Response(null, buildSuccessInit(ingress, result, ctx, 204))
|
|
25
|
+
})
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/ingress/routes/read.ts — Pre-baked GET read handler.
|
|
2
|
+
|
|
3
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
+
import { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pre-baked GET read handler: returns the ingress body JSON on success.
|
|
9
|
+
*/
|
|
10
|
+
export function readHandler(
|
|
11
|
+
ingress: OptimizedIngressHandler,
|
|
12
|
+
opts: BakedHandlerOptions = {},
|
|
13
|
+
): (req: Request, srv?: unknown) => Response {
|
|
14
|
+
const copyBody = opts.copyBody !== false
|
|
15
|
+
|
|
16
|
+
// Result→Response callback, built ONCE per handler (it depends only on the
|
|
17
|
+
// ingress handle + config, never on the per-request `req`), so the hot path
|
|
18
|
+
// allocates no fresh closure per request.
|
|
19
|
+
const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
|
|
20
|
+
const terminal = ingress.terminalResponse(undefined, result, ctx)
|
|
21
|
+
if (terminal) return terminal
|
|
22
|
+
|
|
23
|
+
if (result.bodyTruncated) {
|
|
24
|
+
return ingress.internalErrorResponse(ctx, result)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const init = buildSuccessInit(ingress, result, ctx)
|
|
28
|
+
|
|
29
|
+
return copyBody
|
|
30
|
+
? new Response(result.bodyJson(true), init)
|
|
31
|
+
: ingress.zeroCopyResponse(result, ctx, init)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return runBaked(ingress, opts, respond)
|
|
35
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/ingress/routes/responder.ts — JS responder bridge over the native
|
|
2
|
+
// pipeline ("native decides, JS formats the 2xx").
|
|
3
|
+
//
|
|
4
|
+
// The responder bridge is the castrum foundation for delegating a WHOLE
|
|
5
|
+
// request to the per-route native `IngressInner` while keeping the 2xx body in
|
|
6
|
+
// JS: the native pipeline runs parse/validate/CORS/rate-limit/limits and either
|
|
7
|
+
// produces a terminal (rejection) response — in the configured envelope style —
|
|
8
|
+
// or hands a decoded snapshot to a `NativeResponder` that builds the 2xx.
|
|
9
|
+
//
|
|
10
|
+
// The sync/async boundary is deliberate: `OptimizedIngressHandler.run()` is
|
|
11
|
+
// SYNC-only (`assertSyncCallback` rejects thenables), so the factory captures
|
|
12
|
+
// the decoded snapshot (requestId/query/cookies/body/verdicts) INSIDE the sync
|
|
13
|
+
// callback and calls the async `responder` OUTSIDE it, after the pooled output
|
|
14
|
+
// buffer has been released. `result.body` is the caller-provided request body
|
|
15
|
+
// (never the pooled output buffer), so the snapshot's `body` is safe to hold.
|
|
16
|
+
|
|
17
|
+
import { decoder } from '../../shared/bytes'
|
|
18
|
+
import { generateRequestId } from '../../shared/request-id'
|
|
19
|
+
import { readBodyWithLimit } from '../body'
|
|
20
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
21
|
+
import { errorCodeName, errorMessage } from '../errors'
|
|
22
|
+
import type { RouteHandler } from '../server'
|
|
23
|
+
import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
24
|
+
import { safeTerminalStatus } from '../status'
|
|
25
|
+
import type {
|
|
26
|
+
BakedContext,
|
|
27
|
+
NativeRequestContext,
|
|
28
|
+
NativeResponder,
|
|
29
|
+
OptimizedIngressHandler,
|
|
30
|
+
TerminalStyle,
|
|
31
|
+
} from '../types'
|
|
32
|
+
import { type BakedHandlerOptions, resolveIp } from './common'
|
|
33
|
+
|
|
34
|
+
/** Options for {@link nativeResponderRoute}. */
|
|
35
|
+
export interface ResponderRouteOptions extends BakedHandlerOptions {
|
|
36
|
+
/** Terminal envelope style. Default: `'castrum'` (the pre-baked wire). */
|
|
37
|
+
terminalStyle?: TerminalStyle
|
|
38
|
+
/**
|
|
39
|
+
* Read the request body and pass it to the native pipeline for validation
|
|
40
|
+
* (`requireJsonBody`/schema). Default `false` — the framework owns the body
|
|
41
|
+
* (the responder reads it via `ctx.body`); native only needs the bytes when
|
|
42
|
+
* a body stage is enabled in the route's `IngressHandlerOptions`.
|
|
43
|
+
*/
|
|
44
|
+
readBody?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ignex framework security posture for terminal responses. */
|
|
48
|
+
const IGNGEX_SECURITY_HEADERS: [string, string][] = [
|
|
49
|
+
['x-frame-options', 'DENY'],
|
|
50
|
+
['x-content-type-options', 'nosniff'],
|
|
51
|
+
['referrer-policy', 'no-referrer'],
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** Build the ignex-framework terminal envelope (`{error,status,code}` + security headers). */
|
|
55
|
+
function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Response {
|
|
56
|
+
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
57
|
+
if (preflightAllowed) {
|
|
58
|
+
return new Response(null, { status: 204, headers: IGNGEX_SECURITY_HEADERS })
|
|
59
|
+
}
|
|
60
|
+
const status = safeTerminalStatus(result)
|
|
61
|
+
const body = JSON.stringify({
|
|
62
|
+
error: errorMessage(status, result.errorCode),
|
|
63
|
+
status,
|
|
64
|
+
code: errorCodeName(result.errorCode),
|
|
65
|
+
})
|
|
66
|
+
const headers = new Headers(IGNGEX_SECURITY_HEADERS)
|
|
67
|
+
headers.set('content-type', 'application/json; charset=utf-8')
|
|
68
|
+
return new Response(body, { status, headers })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Parse a `queryJson()`/`cookiesJson()` section into an object (empty on failure). */
|
|
72
|
+
function parseSection(json: string): Record<string, unknown> {
|
|
73
|
+
try {
|
|
74
|
+
const v: unknown = JSON.parse(json)
|
|
75
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
76
|
+
? (v as Record<string, unknown>)
|
|
77
|
+
: {}
|
|
78
|
+
} catch {
|
|
79
|
+
return {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a responder route: native pipeline decides + rejects (terminal
|
|
85
|
+
* response in the chosen style); on success the JS `responder` builds the 2xx.
|
|
86
|
+
* Returns an async `RouteHandler` (`(req, srv?, params?) => Promise<Response>`).
|
|
87
|
+
*/
|
|
88
|
+
export function nativeResponderRoute(
|
|
89
|
+
ingress: OptimizedIngressHandler,
|
|
90
|
+
responder: NativeResponder,
|
|
91
|
+
opts: ResponderRouteOptions = {},
|
|
92
|
+
): RouteHandler {
|
|
93
|
+
const terminalStyle: TerminalStyle = opts.terminalStyle ?? 'castrum'
|
|
94
|
+
const readBody = opts.readBody ?? false
|
|
95
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
96
|
+
const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
97
|
+
|
|
98
|
+
return async (req, srv, _params) => {
|
|
99
|
+
const ip = resolveIp(req, srv, opts)
|
|
100
|
+
// The request-id the framework surfaces (same source as the pre-baked path's
|
|
101
|
+
// `ctx.requestIdHeader`): the baked result does not carry it, so generate it
|
|
102
|
+
// here — the native pipeline uses the same rid for its internal frame.
|
|
103
|
+
const requestId = decoder.decode(generateRequestId())
|
|
104
|
+
// Read the body ONLY when native validation needs it (the framework owns
|
|
105
|
+
// the body otherwise). A `BODY_TOO_LARGE`/`REQUEST_TIMEOUT` throw becomes
|
|
106
|
+
// a terminal 413/408 from the native path.
|
|
107
|
+
let body: Uint8Array | null = null
|
|
108
|
+
if (readBody) {
|
|
109
|
+
try {
|
|
110
|
+
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const code = (err as Error & { code?: string }).code
|
|
113
|
+
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
114
|
+
const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
|
|
115
|
+
const headers = new Headers(IGNGEX_SECURITY_HEADERS)
|
|
116
|
+
headers.set('content-type', 'application/json; charset=utf-8')
|
|
117
|
+
return new Response(payload, { status, headers })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let terminal: Response | null = null
|
|
122
|
+
let snapshot: NativeRequestContext | null = null
|
|
123
|
+
|
|
124
|
+
// Sync callback (never returns a thenable). Captures the decoded snapshot
|
|
125
|
+
// or the terminal response, then the async responder runs outside `run()`.
|
|
126
|
+
ingress.run<null>(req, ip, body, (result, ctx) => {
|
|
127
|
+
if (result.terminal || !result.ok) {
|
|
128
|
+
terminal =
|
|
129
|
+
terminalStyle === 'ignex'
|
|
130
|
+
? buildIgngexTerminal(result, ctx)
|
|
131
|
+
: ingress.terminalResponse(req, result, ctx)
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
const query = parseSection(result.queryJson())
|
|
135
|
+
const cookies = parseSection(result.cookiesJson())
|
|
136
|
+
snapshot = {
|
|
137
|
+
requestId,
|
|
138
|
+
status: result.status,
|
|
139
|
+
rateLimited: result.rateLimited,
|
|
140
|
+
retryAfterMs: result.retryAfterMs,
|
|
141
|
+
query: query as Record<string, string | string[]>,
|
|
142
|
+
cookies: cookies as Record<string, string>,
|
|
143
|
+
body: body ?? new Uint8Array(0),
|
|
144
|
+
ip,
|
|
145
|
+
req,
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (terminal !== null) {
|
|
151
|
+
return terminal
|
|
152
|
+
}
|
|
153
|
+
if (snapshot === null) {
|
|
154
|
+
// Native internal error (setInternalError) — the result was never OK.
|
|
155
|
+
return ingress.internalErrorResponse({ requestIdHeader: null, origin: null })
|
|
156
|
+
}
|
|
157
|
+
return responder(snapshot)
|
|
158
|
+
}
|
|
159
|
+
}
|