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,136 @@
|
|
|
1
|
+
// src/ingress/context.ts — Result snapshotting + synthetic/error context
|
|
2
|
+
// builders for the async ingress API.
|
|
3
|
+
|
|
4
|
+
import type { ResponseBuildContext } from './headers/fast-templates'
|
|
5
|
+
import { buildTerminalResponse } from './response/terminal'
|
|
6
|
+
import { ERR_CODE_INTERNAL, ERR_CODE_RATE_LIMITED, HV_JSON, HV_CORS_SIMPLE } from './constants'
|
|
7
|
+
import type { IngressOptions, IngressResult, IngressContext } from './types'
|
|
8
|
+
|
|
9
|
+
const EMPTY_BODY = new Uint8Array(0)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Deep-snapshot a result so the caller can use it after `run()` returns.
|
|
13
|
+
*
|
|
14
|
+
* Note on aliasing: the returned `body` field aliases the request body buffer
|
|
15
|
+
* (`r.body`) — it is NOT copied — while `bodyJson()` returns a stable copy
|
|
16
|
+
* (captured once at snapshot time, safe after the live result is invalidated).
|
|
17
|
+
* In the async `createIngress` path the aliased buffer is the fully-
|
|
18
|
+
* materialized request body, which lives as long as the request and is never
|
|
19
|
+
* reused, so the alias is safe. Treat `body` as read-only; use `bodyJson()`
|
|
20
|
+
* for a defensive copy.
|
|
21
|
+
*/
|
|
22
|
+
export function snapshotResult(r: IngressResult): IngressResult {
|
|
23
|
+
const cookies = r.cookiesJson()
|
|
24
|
+
const query = r.queryJson()
|
|
25
|
+
const bodyJson = r.bodyJson().slice()
|
|
26
|
+
const body = r.body
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
status: r.status,
|
|
30
|
+
verdict: r.verdict,
|
|
31
|
+
flags: r.flags,
|
|
32
|
+
errorCode: r.errorCode,
|
|
33
|
+
terminal: r.terminal,
|
|
34
|
+
ok: r.ok,
|
|
35
|
+
https: r.https,
|
|
36
|
+
trustedProxy: r.trustedProxy,
|
|
37
|
+
hasCookies: r.hasCookies,
|
|
38
|
+
hasQuery: r.hasQuery,
|
|
39
|
+
bodyValidJson: r.bodyValidJson,
|
|
40
|
+
schemaValid: r.schemaValid,
|
|
41
|
+
corsAllowed: r.corsAllowed,
|
|
42
|
+
isPreflight: r.isPreflight,
|
|
43
|
+
rateLimited: r.rateLimited,
|
|
44
|
+
rateLimit: r.rateLimit,
|
|
45
|
+
rateRemaining: r.rateRemaining,
|
|
46
|
+
rateResetMs: r.rateResetMs,
|
|
47
|
+
retryAfterMs: r.retryAfterMs,
|
|
48
|
+
body,
|
|
49
|
+
headerVariant: r.headerVariant,
|
|
50
|
+
requestId: r.requestId,
|
|
51
|
+
bodyTruncated: r.bodyTruncated,
|
|
52
|
+
|
|
53
|
+
cookiesJson: () => cookies,
|
|
54
|
+
queryJson: () => query,
|
|
55
|
+
bodyJson: () => bodyJson,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Build a synthetic (terminal) context for body-size / error short-circuits. */
|
|
60
|
+
export function syntheticContext(
|
|
61
|
+
req: Request,
|
|
62
|
+
requestId: string,
|
|
63
|
+
options: IngressOptions,
|
|
64
|
+
responseCtx: ResponseBuildContext,
|
|
65
|
+
status: number,
|
|
66
|
+
errorCode: number,
|
|
67
|
+
): IngressContext {
|
|
68
|
+
const corsAllowed = staticCorsAllowed(options, req)
|
|
69
|
+
const variant = HV_JSON | (corsAllowed ? HV_CORS_SIMPLE : 0)
|
|
70
|
+
|
|
71
|
+
const base: IngressResult = {
|
|
72
|
+
status,
|
|
73
|
+
verdict: 1,
|
|
74
|
+
flags: 0,
|
|
75
|
+
errorCode,
|
|
76
|
+
terminal: true,
|
|
77
|
+
ok: false,
|
|
78
|
+
https: options.https === true,
|
|
79
|
+
trustedProxy: options.trustProxy === true || options.trustedProxies?.enabled === true,
|
|
80
|
+
hasCookies: false,
|
|
81
|
+
hasQuery: false,
|
|
82
|
+
bodyValidJson: false,
|
|
83
|
+
schemaValid: false,
|
|
84
|
+
corsAllowed,
|
|
85
|
+
isPreflight: false,
|
|
86
|
+
rateLimited: errorCode === ERR_CODE_RATE_LIMITED,
|
|
87
|
+
rateLimit: options.rateLimit?.limit ?? 0,
|
|
88
|
+
rateRemaining: 0,
|
|
89
|
+
rateResetMs: 0,
|
|
90
|
+
retryAfterMs: 0,
|
|
91
|
+
body: EMPTY_BODY,
|
|
92
|
+
headerVariant: variant,
|
|
93
|
+
requestId,
|
|
94
|
+
bodyTruncated: false,
|
|
95
|
+
|
|
96
|
+
cookiesJson: () => '{}',
|
|
97
|
+
queryJson: () => '{}',
|
|
98
|
+
bodyJson: () => EMPTY_BODY,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
...base,
|
|
103
|
+
response: buildTerminalResponse(responseCtx, base, req, requestId),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Build the internal-error context (status 500). */
|
|
108
|
+
export function internalContext(
|
|
109
|
+
req: Request,
|
|
110
|
+
requestId: string,
|
|
111
|
+
options: IngressOptions,
|
|
112
|
+
responseCtx: ResponseBuildContext,
|
|
113
|
+
): IngressContext {
|
|
114
|
+
return syntheticContext(req, requestId, options, responseCtx, 500, ERR_CODE_INTERNAL)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Statically determine whether the configured CORS policy allows `req`. */
|
|
118
|
+
export function staticCorsAllowed(options: IngressOptions, req: Request): boolean {
|
|
119
|
+
const cors = options.cors
|
|
120
|
+
if (!cors) return false
|
|
121
|
+
|
|
122
|
+
const origin = req.headers.get('origin')
|
|
123
|
+
if (!origin) return false
|
|
124
|
+
|
|
125
|
+
const list = cors.allowOrigin
|
|
126
|
+
|
|
127
|
+
if (!list || list.length === 0) {
|
|
128
|
+
return cors.allowCredentials !== true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (list.includes('*')) {
|
|
132
|
+
return cors.allowCredentials !== true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return list.includes(origin)
|
|
136
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// src/ingress/decode/baked-result.ts — Zero-alloc result decoder (pre-baked
|
|
2
|
+
// path).
|
|
3
|
+
//
|
|
4
|
+
// Decodes the native Ingress packed output (OUT_* layout in ./constants.ts)
|
|
5
|
+
// into the pre-baked handler result, which carries the benchmark wire format
|
|
6
|
+
// ({"ok":false,"error":{...}}).
|
|
7
|
+
//
|
|
8
|
+
// NOTE: this is the pre-baked-path decoder with its own status normalization.
|
|
9
|
+
// The fast path has its own decoder (./fast-result.ts) — do not unify them
|
|
10
|
+
// (see AGENTS.md). The two decoders DO share their section/rate-limit plumbing
|
|
11
|
+
// via ./result-base.ts.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
OUT_VERDICT,
|
|
15
|
+
OUT_FLAGS,
|
|
16
|
+
OUT_RATE_LIMIT,
|
|
17
|
+
OUT_RATE_REMAINING,
|
|
18
|
+
OUT_COOKIES_JSON_LEN,
|
|
19
|
+
OUT_QUERY_JSON_LEN,
|
|
20
|
+
OUT_HEADER_VARIANT,
|
|
21
|
+
OUT_BODY_JSON_LEN,
|
|
22
|
+
OUT_DATA_START,
|
|
23
|
+
FLAG_BODY_VALID_JSON,
|
|
24
|
+
FLAG_SCHEMA_VALID,
|
|
25
|
+
FLAG_CORS_ALLOWED,
|
|
26
|
+
FLAG_IS_PREFLIGHT,
|
|
27
|
+
FLAG_RATE_LIMITED,
|
|
28
|
+
FLAG_TRUSTED_PROXY,
|
|
29
|
+
FLAG_BODY_TRUNCATED,
|
|
30
|
+
HV_JSON,
|
|
31
|
+
ERR_CODE_INTERNAL as ERROR_CODE_INTERNAL,
|
|
32
|
+
} from '../constants'
|
|
33
|
+
import { sectionLayout } from './packed-sections'
|
|
34
|
+
import { isValidResponseStatus } from '../status'
|
|
35
|
+
import { IngressResultBase } from './result-base'
|
|
36
|
+
|
|
37
|
+
/** Zero-alloc, reusable result decoder for the pre-baked handler path. */
|
|
38
|
+
export class BakedIngressResult extends IngressResultBase {
|
|
39
|
+
refresh(buf: Uint8Array, body: Uint8Array, view: DataView): void {
|
|
40
|
+
// Defensive: the native core always writes the full fixed header
|
|
41
|
+
// (>= OUT_DATA_START bytes) before returning `written`. The cached
|
|
42
|
+
// whole-buffer DataView (see viewForArrayBuffer) would otherwise decode
|
|
43
|
+
// stale bytes if this contract is ever violated — treat as internal error.
|
|
44
|
+
if (buf.byteLength < OUT_DATA_START) {
|
|
45
|
+
this.setInternalError()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
this.body = body
|
|
49
|
+
|
|
50
|
+
const h0 = view.getUint32(OUT_VERDICT, true)
|
|
51
|
+
const h1 = view.getUint32(OUT_FLAGS, true)
|
|
52
|
+
const h2 = view.getUint32(OUT_RATE_LIMIT, true)
|
|
53
|
+
const h3 = view.getUint32(OUT_RATE_REMAINING, true)
|
|
54
|
+
|
|
55
|
+
const cookiesLenRaw = view.getUint32(OUT_COOKIES_JSON_LEN, true)
|
|
56
|
+
const queryLenRaw = view.getUint32(OUT_QUERY_JSON_LEN, true)
|
|
57
|
+
const headerVariant = view.getUint8(OUT_HEADER_VARIANT)
|
|
58
|
+
const bodyJsonLenRaw = view.getUint32(OUT_BODY_JSON_LEN, true)
|
|
59
|
+
|
|
60
|
+
// Bounds-checked section offsets shared with the fast decoder: a
|
|
61
|
+
// malformed/truncated buffer can never produce slices past its end.
|
|
62
|
+
const layout = sectionLayout(buf.byteLength, cookiesLenRaw, queryLenRaw, bodyJsonLenRaw)
|
|
63
|
+
|
|
64
|
+
if (h0 === 0 && h1 === 0) {
|
|
65
|
+
this.verdict = 1
|
|
66
|
+
this.errorCode = ERROR_CODE_INTERNAL
|
|
67
|
+
this.status = 500
|
|
68
|
+
} else {
|
|
69
|
+
this.verdict = h0 & 0xff
|
|
70
|
+
this.errorCode = (h0 >>> 8) & 0xff
|
|
71
|
+
|
|
72
|
+
const rawStatus = (h0 >>> 16) & 0xffff
|
|
73
|
+
// Reuse the shared status-validity helper instead of re-inlining the
|
|
74
|
+
// 101/200-599 check (single source of truth in ../status.ts).
|
|
75
|
+
this.status = isValidResponseStatus(rawStatus) ? rawStatus : 500
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const flags = h1
|
|
79
|
+
|
|
80
|
+
this.rateRemaining = h3
|
|
81
|
+
|
|
82
|
+
this.setRateWindow(h2, (flags & FLAG_RATE_LIMITED) !== 0, view)
|
|
83
|
+
|
|
84
|
+
this.headerVariant = headerVariant
|
|
85
|
+
|
|
86
|
+
// The baked path trusts the already-clamped section lengths, so pass the
|
|
87
|
+
// safe body len here (the fast path re-checks bounds on read instead).
|
|
88
|
+
this.setSections(buf, layout, layout.safeBodyJsonLen)
|
|
89
|
+
|
|
90
|
+
this.updateOkTerminal()
|
|
91
|
+
|
|
92
|
+
this.isPreflight = (flags & FLAG_IS_PREFLIGHT) !== 0
|
|
93
|
+
this.corsAllowed = (flags & FLAG_CORS_ALLOWED) !== 0
|
|
94
|
+
this.rateLimited = (flags & FLAG_RATE_LIMITED) !== 0
|
|
95
|
+
this.trustedProxy = (flags & FLAG_TRUSTED_PROXY) !== 0
|
|
96
|
+
this.bodyValidJson = (flags & FLAG_BODY_VALID_JSON) !== 0
|
|
97
|
+
this.schemaValid = (flags & FLAG_SCHEMA_VALID) !== 0
|
|
98
|
+
|
|
99
|
+
this.bodyTruncated = (flags & FLAG_BODY_TRUNCATED) !== 0 || layout.truncated
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
invalidate(): void {
|
|
103
|
+
// The baked empty state is neutral (0/0/0) — exactly what resetShared sets.
|
|
104
|
+
this.resetShared()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setInternalError(): void {
|
|
108
|
+
this.invalidate()
|
|
109
|
+
this.status = 500
|
|
110
|
+
this.verdict = 1
|
|
111
|
+
this.errorCode = ERROR_CODE_INTERNAL
|
|
112
|
+
this.headerVariant = HV_JSON
|
|
113
|
+
this.terminal = true
|
|
114
|
+
this.ok = false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
bodyJson(copy: boolean): Uint8Array {
|
|
118
|
+
const slice = this.bodyJsonSlice()
|
|
119
|
+
return (copy ? slice.slice() : slice) as Uint8Array
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// src/ingress/decode/fast-result.ts — Zero-alloc result decoder (fast path).
|
|
2
|
+
//
|
|
3
|
+
// Decodes the native Ingress packed output (OUT_* layout in ./constants.ts)
|
|
4
|
+
// lazily: slices are stored, strings are decoded only on demand via
|
|
5
|
+
// cookiesJson()/queryJson()/bodyJson().
|
|
6
|
+
//
|
|
7
|
+
// NOTE: this is the fast-path decoder ({"error":{...}} wire format). The
|
|
8
|
+
// pre-baked path has its own decoder (./baked-result.ts) with a different
|
|
9
|
+
// wire format and status normalization — do not unify them (see AGENTS.md).
|
|
10
|
+
// The two decoders DO share their section/rate-limit plumbing via
|
|
11
|
+
// ./result-base.ts.
|
|
12
|
+
|
|
13
|
+
import { viewForArrayBuffer } from '../../shared/bytes'
|
|
14
|
+
import {
|
|
15
|
+
OUT_VERDICT,
|
|
16
|
+
OUT_ERROR_CODE,
|
|
17
|
+
OUT_STATUS,
|
|
18
|
+
OUT_FLAGS,
|
|
19
|
+
OUT_RATE_LIMIT,
|
|
20
|
+
OUT_RATE_REMAINING,
|
|
21
|
+
OUT_COOKIES_JSON_LEN,
|
|
22
|
+
OUT_QUERY_JSON_LEN,
|
|
23
|
+
OUT_HEADER_VARIANT,
|
|
24
|
+
OUT_BODY_JSON_LEN,
|
|
25
|
+
OUT_DATA_START,
|
|
26
|
+
FLAG_HAS_COOKIES,
|
|
27
|
+
FLAG_HAS_QUERY,
|
|
28
|
+
FLAG_BODY_VALID_JSON,
|
|
29
|
+
FLAG_SCHEMA_VALID,
|
|
30
|
+
FLAG_CORS_ALLOWED,
|
|
31
|
+
FLAG_IS_PREFLIGHT,
|
|
32
|
+
FLAG_RATE_LIMITED,
|
|
33
|
+
FLAG_HTTPS,
|
|
34
|
+
FLAG_TRUSTED_PROXY,
|
|
35
|
+
FLAG_BODY_TRUNCATED,
|
|
36
|
+
ERR_CODE_INTERNAL,
|
|
37
|
+
} from '../constants'
|
|
38
|
+
import { normalizeResponseStatus } from '../status'
|
|
39
|
+
import { sectionLayout } from './packed-sections'
|
|
40
|
+
import { IngressResultBase } from './result-base'
|
|
41
|
+
|
|
42
|
+
/** Zero-alloc, lazy-decoding view over a native Ingress output buffer. */
|
|
43
|
+
export class FastIngressResult extends IngressResultBase {
|
|
44
|
+
// Fast path's empty state is an internal-error shape (500 / verdict 1 /
|
|
45
|
+
// internal error-code). The baked decoder's empty state is neutral (0/0/0).
|
|
46
|
+
override status = 500
|
|
47
|
+
override verdict = 1
|
|
48
|
+
override errorCode = ERR_CODE_INTERNAL
|
|
49
|
+
flags = 0
|
|
50
|
+
https = false
|
|
51
|
+
hasCookies = false
|
|
52
|
+
hasQuery = false
|
|
53
|
+
rateLimit = 0
|
|
54
|
+
requestId = ''
|
|
55
|
+
|
|
56
|
+
refresh(buf: Uint8Array, body: Uint8Array, requestId: string): void {
|
|
57
|
+
// Defensive: the native core always writes the full fixed header
|
|
58
|
+
// (>= OUT_DATA_START bytes) before returning `written`. The cached
|
|
59
|
+
// whole-buffer DataView below would otherwise decode stale bytes if this
|
|
60
|
+
// contract is ever violated — treat it as an internal error instead.
|
|
61
|
+
if (buf.byteLength < OUT_DATA_START) {
|
|
62
|
+
this.setInternalError(requestId)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
this._buf = buf
|
|
66
|
+
this.body = body
|
|
67
|
+
this.requestId = requestId
|
|
68
|
+
|
|
69
|
+
// Cached per-ArrayBuffer DataView (respects the subarray byteOffset): no
|
|
70
|
+
// per-request view alloc on the offset-0 production buffers.
|
|
71
|
+
const dv = viewForArrayBuffer(buf.buffer, buf.byteOffset)
|
|
72
|
+
|
|
73
|
+
const rawVerdict = dv.getUint8(OUT_VERDICT)
|
|
74
|
+
const rawErrorCode = dv.getUint8(OUT_ERROR_CODE)
|
|
75
|
+
const rawStatus = dv.getUint16(OUT_STATUS, true)
|
|
76
|
+
const flags = dv.getUint32(OUT_FLAGS, true)
|
|
77
|
+
|
|
78
|
+
const rateLimit = dv.getUint32(OUT_RATE_LIMIT, true)
|
|
79
|
+
const rateRemaining = dv.getUint32(OUT_RATE_REMAINING, true)
|
|
80
|
+
|
|
81
|
+
const cookiesJsonLen = dv.getUint32(OUT_COOKIES_JSON_LEN, true)
|
|
82
|
+
const queryJsonLen = dv.getUint32(OUT_QUERY_JSON_LEN, true)
|
|
83
|
+
|
|
84
|
+
const headerVariant = dv.getUint8(OUT_HEADER_VARIANT)
|
|
85
|
+
const bodyJsonLen = dv.getUint32(OUT_BODY_JSON_LEN, true)
|
|
86
|
+
|
|
87
|
+
const preflightAllowed = (flags & FLAG_IS_PREFLIGHT) !== 0 && (flags & FLAG_CORS_ALLOWED) !== 0
|
|
88
|
+
|
|
89
|
+
const normalizedStatus = normalizeResponseStatus(rawStatus, rawErrorCode, preflightAllowed)
|
|
90
|
+
|
|
91
|
+
const uninitialized = rawVerdict === 0 && rawErrorCode === 0 && rawStatus === 0 && flags === 0
|
|
92
|
+
|
|
93
|
+
if (uninitialized) {
|
|
94
|
+
this.status = 500
|
|
95
|
+
this.verdict = 1
|
|
96
|
+
this.errorCode = ERR_CODE_INTERNAL
|
|
97
|
+
} else {
|
|
98
|
+
this.status = normalizedStatus
|
|
99
|
+
this.verdict = rawVerdict
|
|
100
|
+
this.errorCode = rawErrorCode
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.flags = flags
|
|
104
|
+
this.rateLimit = rateLimit
|
|
105
|
+
this.rateRemaining = rateRemaining
|
|
106
|
+
|
|
107
|
+
this.setRateWindow(rateLimit, (flags & FLAG_RATE_LIMITED) !== 0, dv)
|
|
108
|
+
|
|
109
|
+
this.headerVariant = headerVariant
|
|
110
|
+
|
|
111
|
+
this.updateOkTerminal()
|
|
112
|
+
|
|
113
|
+
this.https = (flags & FLAG_HTTPS) !== 0
|
|
114
|
+
this.trustedProxy = (flags & FLAG_TRUSTED_PROXY) !== 0
|
|
115
|
+
this.hasCookies = (flags & FLAG_HAS_COOKIES) !== 0
|
|
116
|
+
this.hasQuery = (flags & FLAG_HAS_QUERY) !== 0
|
|
117
|
+
this.bodyValidJson = (flags & FLAG_BODY_VALID_JSON) !== 0
|
|
118
|
+
this.schemaValid = (flags & FLAG_SCHEMA_VALID) !== 0
|
|
119
|
+
this.corsAllowed = (flags & FLAG_CORS_ALLOWED) !== 0
|
|
120
|
+
this.isPreflight = (flags & FLAG_IS_PREFLIGHT) !== 0
|
|
121
|
+
this.rateLimited = (flags & FLAG_RATE_LIMITED) !== 0
|
|
122
|
+
|
|
123
|
+
// Bounds-checked section offsets shared with the pre-baked decoder: a
|
|
124
|
+
// malformed/truncated buffer can never produce slices past its end.
|
|
125
|
+
const layout = sectionLayout(buf.byteLength, cookiesJsonLen, queryJsonLen, bodyJsonLen)
|
|
126
|
+
|
|
127
|
+
// Store slices for lazy decode instead of eagerly decoding strings. The
|
|
128
|
+
// fast path trusts the RAW declared body length and re-checks bounds on
|
|
129
|
+
// read (bodyJson()), so pass the raw declared length here.
|
|
130
|
+
this.setSections(buf, layout, bodyJsonLen)
|
|
131
|
+
|
|
132
|
+
// Parity with the baked decoder: any declared section (cookies, query,
|
|
133
|
+
// OR body) that overran the buffer counts as truncated.
|
|
134
|
+
this.bodyTruncated = (flags & FLAG_BODY_TRUNCATED) !== 0 || layout.truncated
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
invalidate(): void {
|
|
138
|
+
this.resetShared()
|
|
139
|
+
// Fast path's empty state is an internal-error shape (500/1/internal).
|
|
140
|
+
this.status = 500
|
|
141
|
+
this.verdict = 1
|
|
142
|
+
this.errorCode = ERR_CODE_INTERNAL
|
|
143
|
+
this.flags = 0
|
|
144
|
+
this.https = false
|
|
145
|
+
this.hasCookies = false
|
|
146
|
+
this.hasQuery = false
|
|
147
|
+
this.rateLimit = 0
|
|
148
|
+
this.requestId = ''
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setInternalError(requestId: string): void {
|
|
152
|
+
this.invalidate()
|
|
153
|
+
this.requestId = requestId
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Bounds-checked body-JSON section, or an empty view when absent. */
|
|
157
|
+
bodyJson(): Uint8Array {
|
|
158
|
+
return this.bodyJsonSlice()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/ingress/decode/packed-sections.ts — Shared bounds-checked section layout.
|
|
2
|
+
//
|
|
3
|
+
// Both decoders (fast-result.ts and baked-result.ts) walk the same OUT_* data
|
|
4
|
+
// region: [cookies JSON][query JSON][body JSON], each u32-length-prefixed in
|
|
5
|
+
// the fixed header. This helper centralizes the bounds-checked offset math so
|
|
6
|
+
// a malformed/truncated buffer can never produce slices past its end.
|
|
7
|
+
|
|
8
|
+
import { OUT_DATA_START } from '../constants'
|
|
9
|
+
|
|
10
|
+
/** Bounds-checked section offsets for one ingress output buffer. */
|
|
11
|
+
export interface PackedSectionLayout {
|
|
12
|
+
/** Cookie-JSON length clamped to the buffer (0 if none / out of range). */
|
|
13
|
+
safeCookiesLen: number
|
|
14
|
+
/** Byte offset where the query-JSON section begins. */
|
|
15
|
+
queryStart: number
|
|
16
|
+
/** Query-JSON length clamped to the buffer (0 if none / out of range). */
|
|
17
|
+
safeQueryLen: number
|
|
18
|
+
/** Byte offset where the body-JSON section begins. */
|
|
19
|
+
bodyJsonStart: number
|
|
20
|
+
/** Body-JSON length clamped to the buffer (0 if none / out of range). */
|
|
21
|
+
safeBodyJsonLen: number
|
|
22
|
+
/** Whether any declared section length overran the buffer. */
|
|
23
|
+
truncated: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute bounds-checked section offsets for an ingress output buffer.
|
|
28
|
+
*
|
|
29
|
+
* Given the raw u32 section lengths (already read from the fixed header) and
|
|
30
|
+
* the actual buffer length, returns safe (clamped-to-buffer) lengths and the
|
|
31
|
+
* body-JSON start offset. Any length that would run past the end of the
|
|
32
|
+
* buffer is clamped to 0 and reported via `truncated`, so callers never read
|
|
33
|
+
* stale or out-of-bounds bytes.
|
|
34
|
+
*/
|
|
35
|
+
export function sectionLayout(
|
|
36
|
+
bufLen: number,
|
|
37
|
+
cookiesLen: number,
|
|
38
|
+
queryLen: number,
|
|
39
|
+
bodyLen: number,
|
|
40
|
+
): PackedSectionLayout {
|
|
41
|
+
const safeCookiesLen = cookiesLen > 0 && OUT_DATA_START + cookiesLen <= bufLen ? cookiesLen : 0
|
|
42
|
+
const queryStart = OUT_DATA_START + safeCookiesLen
|
|
43
|
+
const safeQueryLen = queryLen > 0 && queryStart + queryLen <= bufLen ? queryLen : 0
|
|
44
|
+
const bodyJsonStart = queryStart + safeQueryLen
|
|
45
|
+
const safeBodyJsonLen = bodyLen > 0 && bodyJsonStart + bodyLen <= bufLen ? bodyLen : 0
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
safeCookiesLen,
|
|
49
|
+
queryStart,
|
|
50
|
+
safeQueryLen,
|
|
51
|
+
bodyJsonStart,
|
|
52
|
+
safeBodyJsonLen,
|
|
53
|
+
truncated:
|
|
54
|
+
safeCookiesLen !== cookiesLen || safeQueryLen !== queryLen || safeBodyJsonLen !== bodyLen,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// src/ingress/decode/result-base.ts — Shared zero-alloc decoder plumbing.
|
|
2
|
+
//
|
|
3
|
+
// Both ingress result decoders (fast-result.ts, baked-result.ts) walk the SAME
|
|
4
|
+
// native OUT_* layout and share: lazy cookie/query JSON section decoding, the
|
|
5
|
+
// bounds-checked body-JSON slice, the rate-limit window fields, and the
|
|
6
|
+
// flag-derived booleans. This base holds that identical plumbing so the two
|
|
7
|
+
// paths cannot drift on section handling.
|
|
8
|
+
//
|
|
9
|
+
// The WIRE FORMAT and STATUS NORMALIZATION intentionally differ between the
|
|
10
|
+
// two decoders ({"error":{...}} fast path vs {"ok":false,...} baked path) —
|
|
11
|
+
// those stay in the subclasses (see AGENTS.md; do not unify them).
|
|
12
|
+
|
|
13
|
+
import { decoder } from '../../shared/bytes'
|
|
14
|
+
import { OUT_DATA_START, OUT_RATE_RESET, OUT_RETRY_AFTER } from '../constants'
|
|
15
|
+
import type { PackedSectionLayout } from './packed-sections'
|
|
16
|
+
|
|
17
|
+
const EMPTY_BODY = new Uint8Array(0)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Zero-alloc, reusable result decoder shared base.
|
|
21
|
+
*
|
|
22
|
+
* Holds the fields and lazy-decoding helpers that both the fast-path
|
|
23
|
+
* (`FastIngressResult`) and pre-baked (`BakedIngressResult`) decoders consume
|
|
24
|
+
* identically from the native OUT_* output buffer. Concrete subclasses own the
|
|
25
|
+
* status normalization, the exposed extras (requestId, flags, ...) and the
|
|
26
|
+
* wire-format-specific body/error accessors.
|
|
27
|
+
*/
|
|
28
|
+
export abstract class IngressResultBase {
|
|
29
|
+
/** Raw native verdict byte (0 = accepted). */
|
|
30
|
+
verdict = 0
|
|
31
|
+
/** Raw native error-code byte. */
|
|
32
|
+
errorCode = 0
|
|
33
|
+
/** Normalized HTTP status. */
|
|
34
|
+
status = 0
|
|
35
|
+
/** Header template variant byte emitted by the native core. */
|
|
36
|
+
headerVariant = 0
|
|
37
|
+
/** Remaining rate-limit budget (count). */
|
|
38
|
+
rateRemaining = 0
|
|
39
|
+
/** Rate-limit window reset (ms epoch). */
|
|
40
|
+
rateResetMs = 0
|
|
41
|
+
/** Retry-After value (ms). */
|
|
42
|
+
retryAfterMs = 0
|
|
43
|
+
/** True when the pipeline returned a terminal (error) verdict. */
|
|
44
|
+
terminal = true
|
|
45
|
+
/** True when the pipeline accepted the request (verdict 0 + 2xx/3xx). */
|
|
46
|
+
ok = false
|
|
47
|
+
/** True when the request was a CORS preflight. */
|
|
48
|
+
isPreflight = false
|
|
49
|
+
/** True when the CORS engine allowed the request origin. */
|
|
50
|
+
corsAllowed = false
|
|
51
|
+
/** True when the request was rate-limited. */
|
|
52
|
+
rateLimited = false
|
|
53
|
+
/** True when the request came from a trusted proxy. */
|
|
54
|
+
trustedProxy = false
|
|
55
|
+
/** True when the request body parsed as valid JSON. */
|
|
56
|
+
bodyValidJson = false
|
|
57
|
+
/** True when the request body validated against the ingress schema. */
|
|
58
|
+
schemaValid = false
|
|
59
|
+
/** True when a declared output section overran the buffer. */
|
|
60
|
+
bodyTruncated = false
|
|
61
|
+
/** Request body bytes (may be an empty view). */
|
|
62
|
+
body: Uint8Array = EMPTY_BODY
|
|
63
|
+
|
|
64
|
+
protected _buf: Uint8Array = EMPTY_BODY
|
|
65
|
+
protected _bodyJsonStart = OUT_DATA_START
|
|
66
|
+
protected _bodyJsonLen = 0
|
|
67
|
+
private _cookiesBuf: Uint8Array | null = null
|
|
68
|
+
private _queryBuf: Uint8Array | null = null
|
|
69
|
+
private _cookiesDecoded: string | null = null
|
|
70
|
+
private _queryDecoded: string | null = null
|
|
71
|
+
|
|
72
|
+
/** Reset the shared fields to their empty state (call from subclass `invalidate`). */
|
|
73
|
+
protected resetShared(): void {
|
|
74
|
+
this.verdict = 0
|
|
75
|
+
this.errorCode = 0
|
|
76
|
+
this.status = 0
|
|
77
|
+
this.headerVariant = 0
|
|
78
|
+
this.rateRemaining = 0
|
|
79
|
+
this.rateResetMs = 0
|
|
80
|
+
this.retryAfterMs = 0
|
|
81
|
+
this.terminal = true
|
|
82
|
+
this.ok = false
|
|
83
|
+
this.isPreflight = false
|
|
84
|
+
this.corsAllowed = false
|
|
85
|
+
this.rateLimited = false
|
|
86
|
+
this.trustedProxy = false
|
|
87
|
+
this.bodyValidJson = false
|
|
88
|
+
this.schemaValid = false
|
|
89
|
+
this.bodyTruncated = false
|
|
90
|
+
this.body = EMPTY_BODY
|
|
91
|
+
this._buf = EMPTY_BODY
|
|
92
|
+
this._bodyJsonStart = OUT_DATA_START
|
|
93
|
+
this._bodyJsonLen = 0
|
|
94
|
+
this._cookiesBuf = null
|
|
95
|
+
this._queryBuf = null
|
|
96
|
+
this._cookiesDecoded = null
|
|
97
|
+
this._queryDecoded = null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Recompute `terminal`/`ok` from the (subclass-set) verdict + status. */
|
|
101
|
+
protected updateOkTerminal(): void {
|
|
102
|
+
this.terminal = this.verdict !== 0 || this.status >= 400
|
|
103
|
+
this.ok = this.verdict === 0 && this.status >= 200 && this.status < 400
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Store the rate-limit window fields. Both decoders read the same OUT_*
|
|
108
|
+
* slots and gate them on the same conditions (a positive budget OR the
|
|
109
|
+
* rate-limited flag), so the read cannot drift between paths.
|
|
110
|
+
*/
|
|
111
|
+
protected setRateWindow(rateLimit: number, rateLimited: boolean, view: DataView): void {
|
|
112
|
+
if (rateLimit > 0 || rateLimited) {
|
|
113
|
+
this.rateResetMs = Number(view.getBigUint64(OUT_RATE_RESET, true))
|
|
114
|
+
this.retryAfterMs = Number(view.getBigUint64(OUT_RETRY_AFTER, true))
|
|
115
|
+
} else {
|
|
116
|
+
this.rateResetMs = 0
|
|
117
|
+
this.retryAfterMs = 0
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Store the bounds-checked cookie/query/body JSON sections from `layout`.
|
|
123
|
+
*
|
|
124
|
+
* `bodyJsonLen` is the body-JSON length to trust for `bodyJson()`: the fast
|
|
125
|
+
* path passes the RAW declared length (it re-checks bounds on read), the
|
|
126
|
+
* baked path passes the already-clamped `layout.safeBodyJsonLen`. Sections
|
|
127
|
+
* are stored as slices and decoded lazily by `cookiesJson()`/`queryJson()`.
|
|
128
|
+
*/
|
|
129
|
+
protected setSections(buf: Uint8Array, layout: PackedSectionLayout, bodyJsonLen: number): void {
|
|
130
|
+
this._buf = buf
|
|
131
|
+
this._bodyJsonStart = layout.bodyJsonStart
|
|
132
|
+
this._bodyJsonLen = bodyJsonLen
|
|
133
|
+
this._cookiesBuf =
|
|
134
|
+
layout.safeCookiesLen > 0
|
|
135
|
+
? buf.subarray(OUT_DATA_START, OUT_DATA_START + layout.safeCookiesLen)
|
|
136
|
+
: null
|
|
137
|
+
this._cookiesDecoded = null
|
|
138
|
+
this._queryBuf =
|
|
139
|
+
layout.safeQueryLen > 0
|
|
140
|
+
? buf.subarray(layout.queryStart, layout.queryStart + layout.safeQueryLen)
|
|
141
|
+
: null
|
|
142
|
+
this._queryDecoded = null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Decoded cookies-JSON section, or `"{}"` when absent. */
|
|
146
|
+
cookiesJson(): string {
|
|
147
|
+
if (this._cookiesDecoded !== null) return this._cookiesDecoded
|
|
148
|
+
if (this._cookiesBuf !== null) {
|
|
149
|
+
this._cookiesDecoded = decoder.decode(this._cookiesBuf)
|
|
150
|
+
return this._cookiesDecoded
|
|
151
|
+
}
|
|
152
|
+
return '{}'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Decoded query-JSON section, or `"{}"` when absent. */
|
|
156
|
+
queryJson(): string {
|
|
157
|
+
if (this._queryDecoded !== null) return this._queryDecoded
|
|
158
|
+
if (this._queryBuf !== null) {
|
|
159
|
+
this._queryDecoded = decoder.decode(this._queryBuf)
|
|
160
|
+
return this._queryDecoded
|
|
161
|
+
}
|
|
162
|
+
return '{}'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Bounds-checked body-JSON slice (empty when absent / out of range). */
|
|
166
|
+
protected bodyJsonSlice(): Uint8Array {
|
|
167
|
+
if (this._bodyJsonLen === 0) return EMPTY_BODY
|
|
168
|
+
const end = this._bodyJsonStart + this._bodyJsonLen
|
|
169
|
+
if (end > this._buf.byteLength) return EMPTY_BODY
|
|
170
|
+
return this._buf.subarray(this._bodyJsonStart, end)
|
|
171
|
+
}
|
|
172
|
+
}
|