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,102 @@
|
|
|
1
|
+
// src/ingress/headers/baked-templates.ts — Header-template builder (pre-baked
|
|
2
|
+
// path).
|
|
3
|
+
//
|
|
4
|
+
// Precomputes the 32 header-variant templates used by the pre-baked handler
|
|
5
|
+
// path (`ratelimit-*` header names, benchmark wire format).
|
|
6
|
+
//
|
|
7
|
+
// NOTE: this is the pre-baked-path template builder. The fast path has its own
|
|
8
|
+
// builder (./fast-templates.ts) with a different wire format (x-ratelimit-*) —
|
|
9
|
+
// do not unify them (see AGENTS.md).
|
|
10
|
+
|
|
11
|
+
import { HV_JSON, HV_CORS_SIMPLE, HV_CORS_PREFLIGHT, HV_RATE_ACTIVE } from '../constants'
|
|
12
|
+
|
|
13
|
+
/** Inputs needed to build the pre-baked header templates. */
|
|
14
|
+
export interface BakedTemplateParams {
|
|
15
|
+
securityEntries: ReadonlyArray<[string, string]>
|
|
16
|
+
cors?: {
|
|
17
|
+
allowOrigin?: string[]
|
|
18
|
+
allowMethods?: string[]
|
|
19
|
+
allowHeaders?: string[]
|
|
20
|
+
exposeHeaders?: string[]
|
|
21
|
+
allowCredentials?: boolean
|
|
22
|
+
maxAge?: number
|
|
23
|
+
}
|
|
24
|
+
corsAllowMethods: string
|
|
25
|
+
corsAllowHeaders: string
|
|
26
|
+
corsExposeHeaders: string
|
|
27
|
+
corsMaxAge: string
|
|
28
|
+
rateLimitStr: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the frozen, variant-indexed header templates.
|
|
33
|
+
*
|
|
34
|
+
* Returns two 32-entry sets:
|
|
35
|
+
* - `regular`: the base templates.
|
|
36
|
+
* - `terminal`: every regular template with `cache-control: no-store` appended
|
|
37
|
+
* (the header set used for terminal/error responses). Baking it in means the
|
|
38
|
+
* steady-state error path can return a frozen array with no per-response
|
|
39
|
+
* allocation or copy.
|
|
40
|
+
*/
|
|
41
|
+
export function buildBakedHeaderTemplates(params: BakedTemplateParams): {
|
|
42
|
+
regular: ReadonlyArray<ReadonlyArray<[string, string]>>
|
|
43
|
+
terminal: ReadonlyArray<ReadonlyArray<[string, string]>>
|
|
44
|
+
} {
|
|
45
|
+
const {
|
|
46
|
+
securityEntries,
|
|
47
|
+
cors,
|
|
48
|
+
corsAllowMethods,
|
|
49
|
+
corsAllowHeaders,
|
|
50
|
+
corsExposeHeaders,
|
|
51
|
+
corsMaxAge,
|
|
52
|
+
rateLimitStr,
|
|
53
|
+
} = params
|
|
54
|
+
|
|
55
|
+
const regular = Object.freeze(
|
|
56
|
+
Array.from({ length: 32 }, (_, variant) => {
|
|
57
|
+
const entries: [string, string][] = [...securityEntries]
|
|
58
|
+
|
|
59
|
+
if ((variant & HV_JSON) !== 0) {
|
|
60
|
+
entries.push(['content-type', 'application/json'])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ((variant & HV_CORS_SIMPLE) !== 0) {
|
|
64
|
+
entries.push(['vary', 'Origin'])
|
|
65
|
+
if (cors?.allowCredentials) {
|
|
66
|
+
entries.push(['access-control-allow-credentials', 'true'])
|
|
67
|
+
}
|
|
68
|
+
if (corsExposeHeaders.length > 0) {
|
|
69
|
+
entries.push(['access-control-expose-headers', corsExposeHeaders])
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if ((variant & HV_CORS_PREFLIGHT) !== 0) {
|
|
74
|
+
entries.push([
|
|
75
|
+
'vary',
|
|
76
|
+
'Origin, Access-Control-Request-Method, Access-Control-Request-Headers',
|
|
77
|
+
])
|
|
78
|
+
if (cors?.allowCredentials) {
|
|
79
|
+
entries.push(['access-control-allow-credentials', 'true'])
|
|
80
|
+
}
|
|
81
|
+
entries.push(['access-control-allow-methods', corsAllowMethods])
|
|
82
|
+
entries.push(['access-control-allow-headers', corsAllowHeaders])
|
|
83
|
+
entries.push(['access-control-max-age', corsMaxAge])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if ((variant & HV_RATE_ACTIVE) !== 0) {
|
|
87
|
+
entries.push(['ratelimit-limit', rateLimitStr])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return Object.freeze(entries)
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const terminal = Object.freeze(
|
|
95
|
+
regular.map((t) => {
|
|
96
|
+
const entries: [string, string][] = [...t, ['cache-control', 'no-store']]
|
|
97
|
+
return Object.freeze(entries)
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return { regular, terminal }
|
|
102
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/ingress/headers/cors.ts — CORS static-string computation (fast path).
|
|
2
|
+
//
|
|
3
|
+
// Precomputes the static parts of the CORS policy (joined allow-lists, wildcard
|
|
4
|
+
// detection, credentials flag) once per handler so per-request header building
|
|
5
|
+
// never re-joins arrays.
|
|
6
|
+
|
|
7
|
+
/** User-facing CORS configuration. */
|
|
8
|
+
export interface CorsOptions {
|
|
9
|
+
allowOrigin?: string[]
|
|
10
|
+
allowMethods?: string[]
|
|
11
|
+
allowHeaders?: string[]
|
|
12
|
+
exposeHeaders?: string[]
|
|
13
|
+
allowCredentials?: boolean
|
|
14
|
+
maxAge?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Precomputed, immutable CORS policy strings. */
|
|
18
|
+
export interface CorsStaticStrings {
|
|
19
|
+
readonly allowMethodsJoined: string
|
|
20
|
+
readonly allowHeadersJoined: string
|
|
21
|
+
readonly exposeHeadersJoined: string
|
|
22
|
+
readonly maxAgeString: string | null
|
|
23
|
+
readonly isWildcard: boolean
|
|
24
|
+
readonly credentials: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Compute the static CORS strings for a config, or `null` when disabled. */
|
|
28
|
+
export function buildCorsStaticStrings(cors: CorsOptions | undefined): CorsStaticStrings | null {
|
|
29
|
+
if (!cors) return null
|
|
30
|
+
|
|
31
|
+
const isWildcard =
|
|
32
|
+
!cors.allowOrigin || cors.allowOrigin.length === 0 || cors.allowOrigin.includes('*')
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
allowMethodsJoined: cors.allowMethods?.length
|
|
36
|
+
? cors.allowMethods.join(', ')
|
|
37
|
+
: 'GET, HEAD, POST',
|
|
38
|
+
allowHeadersJoined: cors.allowHeaders?.length ? cors.allowHeaders.join(', ') : '',
|
|
39
|
+
exposeHeadersJoined: cors.exposeHeaders?.length ? cors.exposeHeaders.join(', ') : '',
|
|
40
|
+
maxAgeString: typeof cors.maxAge === 'number' ? String(cors.maxAge) : null,
|
|
41
|
+
isWildcard,
|
|
42
|
+
credentials: cors.allowCredentials === true,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// src/ingress/headers/fast-templates.ts — Header-template system (fast path).
|
|
2
|
+
//
|
|
3
|
+
// Precomputes, per ingress handler, the full cross product of header variants
|
|
4
|
+
// (HV_* bits) → header pairs, plus the per-request builder that applies the
|
|
5
|
+
// dynamic parts (origin echo, rate-limit counters, dynamic HSTS, request-id).
|
|
6
|
+
//
|
|
7
|
+
// NOTE: this is the fast-path (x-ratelimit-* / {"error":{...}}) template
|
|
8
|
+
// builder. The pre-baked path has its own builder (./baked-templates.ts) with
|
|
9
|
+
// a different wire format — do not unify them (see AGENTS.md).
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
HV_COUNT,
|
|
13
|
+
HV_CORS_SIMPLE,
|
|
14
|
+
HV_CORS_PREFLIGHT,
|
|
15
|
+
HV_RATE_ACTIVE,
|
|
16
|
+
HV_RATE_LIMITED,
|
|
17
|
+
} from '../constants'
|
|
18
|
+
import { buildCorsStaticStrings, type CorsOptions, type CorsStaticStrings } from './cors'
|
|
19
|
+
import { buildHstsValue, buildSecurityPairs, type SecurityHeadersOptions } from './hsts'
|
|
20
|
+
import { secondsFromMs } from '../shared'
|
|
21
|
+
|
|
22
|
+
/** A single precomputed header-variant template. */
|
|
23
|
+
export interface HeaderTemplate {
|
|
24
|
+
readonly entries: ReadonlyArray<readonly [string, string]>
|
|
25
|
+
readonly needsOriginEcho: boolean
|
|
26
|
+
readonly needsRateDynamic: boolean
|
|
27
|
+
readonly needsRetryAfter: boolean
|
|
28
|
+
readonly needsDynamicHsts: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fallback used only when a variant template is unexpectedly absent. All 32
|
|
33
|
+
* variants are always built, so this never serves a real response.
|
|
34
|
+
*/
|
|
35
|
+
const EMPTY_HEADER_TEMPLATE: HeaderTemplate = Object.freeze({
|
|
36
|
+
entries: [],
|
|
37
|
+
needsOriginEcho: false,
|
|
38
|
+
needsRateDynamic: false,
|
|
39
|
+
needsRetryAfter: false,
|
|
40
|
+
needsDynamicHsts: false,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** Precomputed per-handler response context. */
|
|
44
|
+
export interface ResponseBuildContext {
|
|
45
|
+
readonly templates: HeaderTemplate[]
|
|
46
|
+
readonly corsStatic: CorsStaticStrings | null
|
|
47
|
+
readonly hstsValue: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the full response context (header templates + static CORS strings +
|
|
52
|
+
* HSTS value) for a handler configuration.
|
|
53
|
+
*/
|
|
54
|
+
export function buildResponseContext(options: {
|
|
55
|
+
cors?: CorsOptions
|
|
56
|
+
security?: SecurityHeadersOptions
|
|
57
|
+
https?: boolean
|
|
58
|
+
enableSecurityHeaders?: boolean
|
|
59
|
+
rateLimit?: { limit?: number }
|
|
60
|
+
}): ResponseBuildContext {
|
|
61
|
+
const templates = buildHeaderTemplates(options)
|
|
62
|
+
const corsStatic = buildCorsStaticStrings(options.cors)
|
|
63
|
+
const hstsValue = buildHstsValue(options.security ?? {})
|
|
64
|
+
|
|
65
|
+
return { templates, corsStatic, hstsValue }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildHeaderTemplates(options: {
|
|
69
|
+
cors?: CorsOptions
|
|
70
|
+
security?: SecurityHeadersOptions
|
|
71
|
+
https?: boolean
|
|
72
|
+
enableSecurityHeaders?: boolean
|
|
73
|
+
rateLimit?: { limit?: number }
|
|
74
|
+
}): HeaderTemplate[] {
|
|
75
|
+
const templates: HeaderTemplate[] = new Array(HV_COUNT)
|
|
76
|
+
|
|
77
|
+
const sec = options.security ?? {}
|
|
78
|
+
const securityEnabled = options.enableSecurityHeaders !== false
|
|
79
|
+
const corsStatic = buildCorsStaticStrings(options.cors)
|
|
80
|
+
const hstsValue = buildHstsValue(sec)
|
|
81
|
+
const hstsIsDynamic = options.https === undefined && hstsValue !== null
|
|
82
|
+
|
|
83
|
+
// Single source of truth for security-option → header pairs (shared with the
|
|
84
|
+
// pre-baked handlers.ts path — see buildSecurityPairs in ./hsts.ts), so a
|
|
85
|
+
// security option can never be honored on one path and ignored on the other.
|
|
86
|
+
const securityPairs = buildSecurityPairs(sec, options.https, securityEnabled)
|
|
87
|
+
|
|
88
|
+
const corsVaryPairs: Array<[string, string]> = []
|
|
89
|
+
|
|
90
|
+
if (corsStatic) {
|
|
91
|
+
corsVaryPairs.push(['vary', 'Origin'])
|
|
92
|
+
|
|
93
|
+
if (!corsStatic.isWildcard) {
|
|
94
|
+
corsVaryPairs.push(['vary', 'Access-Control-Request-Method'])
|
|
95
|
+
corsVaryPairs.push(['vary', 'Access-Control-Request-Headers'])
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const corsPolicyPairs: Array<[string, string]> = []
|
|
100
|
+
|
|
101
|
+
if (corsStatic) {
|
|
102
|
+
if (corsStatic.credentials) {
|
|
103
|
+
corsPolicyPairs.push(['access-control-allow-credentials', 'true'])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (corsStatic.exposeHeadersJoined) {
|
|
107
|
+
corsPolicyPairs.push(['access-control-expose-headers', corsStatic.exposeHeadersJoined])
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const corsPreflightPairs: Array<[string, string]> = []
|
|
112
|
+
|
|
113
|
+
if (corsStatic) {
|
|
114
|
+
corsPreflightPairs.push(['access-control-allow-methods', corsStatic.allowMethodsJoined])
|
|
115
|
+
|
|
116
|
+
if (corsStatic.allowHeadersJoined) {
|
|
117
|
+
corsPreflightPairs.push(['access-control-allow-headers', corsStatic.allowHeadersJoined])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (corsStatic.maxAgeString) {
|
|
121
|
+
corsPreflightPairs.push(['access-control-max-age', corsStatic.maxAgeString])
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rateLimitCeiling: Array<[string, string]> = []
|
|
126
|
+
|
|
127
|
+
if (options.rateLimit?.limit && options.rateLimit.limit > 0) {
|
|
128
|
+
rateLimitCeiling.push(['x-ratelimit-limit', String(options.rateLimit.limit)])
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (let variant = 0; variant < HV_COUNT; variant++) {
|
|
132
|
+
const isCorsSimple = (variant & HV_CORS_SIMPLE) !== 0
|
|
133
|
+
const isCorsPreflight = (variant & HV_CORS_PREFLIGHT) !== 0
|
|
134
|
+
const isRateActive = (variant & HV_RATE_ACTIVE) !== 0
|
|
135
|
+
const isRateLimited = (variant & HV_RATE_LIMITED) !== 0
|
|
136
|
+
|
|
137
|
+
const entries: Array<[string, string]> = []
|
|
138
|
+
|
|
139
|
+
if (securityEnabled) {
|
|
140
|
+
for (const pair of securityPairs) {
|
|
141
|
+
entries.push(pair)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const pair of corsVaryPairs) {
|
|
146
|
+
entries.push(pair)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isCorsSimple || isCorsPreflight) {
|
|
150
|
+
for (const pair of corsPolicyPairs) {
|
|
151
|
+
entries.push(pair)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (isCorsPreflight) {
|
|
156
|
+
for (const pair of corsPreflightPairs) {
|
|
157
|
+
entries.push(pair)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isRateActive) {
|
|
162
|
+
for (const pair of rateLimitCeiling) {
|
|
163
|
+
entries.push(pair)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
templates[variant] = {
|
|
168
|
+
entries,
|
|
169
|
+
needsOriginEcho: isCorsSimple || isCorsPreflight,
|
|
170
|
+
needsRateDynamic: isRateActive,
|
|
171
|
+
needsRetryAfter: isRateLimited,
|
|
172
|
+
needsDynamicHsts: hstsIsDynamic,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return templates
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the response headers for a decoded result, applying the dynamic parts
|
|
181
|
+
* (request-id, origin echo, rate-limit counters, retry-after, dynamic HSTS).
|
|
182
|
+
*/
|
|
183
|
+
export function headersForResult(
|
|
184
|
+
ctx: ResponseBuildContext,
|
|
185
|
+
r: {
|
|
186
|
+
readonly headerVariant: number
|
|
187
|
+
readonly corsAllowed: boolean
|
|
188
|
+
readonly rateRemaining: number
|
|
189
|
+
readonly rateResetMs: number
|
|
190
|
+
readonly retryAfterMs: number
|
|
191
|
+
readonly https: boolean
|
|
192
|
+
},
|
|
193
|
+
req: Request,
|
|
194
|
+
requestId: string,
|
|
195
|
+
): Headers {
|
|
196
|
+
const template =
|
|
197
|
+
ctx.templates[r.headerVariant & 0x1f] ?? ctx.templates[0] ?? EMPTY_HEADER_TEMPLATE
|
|
198
|
+
|
|
199
|
+
const headers = new Headers()
|
|
200
|
+
|
|
201
|
+
for (const [name, value] of template.entries) {
|
|
202
|
+
headers.append(name, value)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (requestId) {
|
|
206
|
+
headers.set('x-request-id', requestId)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (template.needsOriginEcho && r.corsAllowed && ctx.corsStatic) {
|
|
210
|
+
const origin = req.headers.get('origin')
|
|
211
|
+
|
|
212
|
+
if (origin) {
|
|
213
|
+
if (ctx.corsStatic.isWildcard && !ctx.corsStatic.credentials) {
|
|
214
|
+
headers.set('access-control-allow-origin', '*')
|
|
215
|
+
} else {
|
|
216
|
+
headers.set('access-control-allow-origin', origin)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (template.needsRateDynamic) {
|
|
222
|
+
headers.set('x-ratelimit-remaining', String(Math.max(0, r.rateRemaining)))
|
|
223
|
+
|
|
224
|
+
if (r.rateResetMs > 0) {
|
|
225
|
+
headers.set('x-ratelimit-reset', String(secondsFromMs(r.rateResetMs)))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (template.needsRetryAfter && r.retryAfterMs > 0) {
|
|
230
|
+
headers.set('retry-after', String(Math.max(1, secondsFromMs(r.retryAfterMs))))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (template.needsDynamicHsts && r.https && ctx.hstsValue) {
|
|
234
|
+
headers.set('strict-transport-security', ctx.hstsValue)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return headers
|
|
238
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/ingress/headers/hsts.ts — Strict-Transport-Security value builder
|
|
2
|
+
// (fast path).
|
|
3
|
+
|
|
4
|
+
/** User-facing security-headers configuration. */
|
|
5
|
+
export interface SecurityHeadersOptions {
|
|
6
|
+
contentSecurityPolicy?: string
|
|
7
|
+
hsts?: boolean
|
|
8
|
+
hstsMaxAge?: number
|
|
9
|
+
hstsIncludeSubdomains?: boolean
|
|
10
|
+
hstsPreload?: boolean
|
|
11
|
+
frameOptions?: string
|
|
12
|
+
nosniff?: boolean
|
|
13
|
+
referrerPolicy?: string
|
|
14
|
+
coep?: string
|
|
15
|
+
coop?: string
|
|
16
|
+
corp?: string
|
|
17
|
+
xssProtection?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build the HSTS header value, or `null` when HSTS is not configured. */
|
|
21
|
+
export function buildHstsValue(sec: SecurityHeadersOptions): string | null {
|
|
22
|
+
const wantHsts =
|
|
23
|
+
sec.hsts === true ||
|
|
24
|
+
sec.hstsMaxAge !== undefined ||
|
|
25
|
+
sec.hstsIncludeSubdomains === true ||
|
|
26
|
+
sec.hstsPreload === true
|
|
27
|
+
|
|
28
|
+
if (!wantHsts) return null
|
|
29
|
+
|
|
30
|
+
const maxAge = sec.hstsMaxAge ?? 31_536_000
|
|
31
|
+
let value = `max-age=${maxAge}`
|
|
32
|
+
|
|
33
|
+
if (sec.hstsIncludeSubdomains) value += '; includeSubDomains'
|
|
34
|
+
if (sec.hstsPreload) value += '; preload'
|
|
35
|
+
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert structured `security` options into ordered `[name, value][]` pairs.
|
|
41
|
+
*
|
|
42
|
+
* Shared by BOTH header-template paths (`fast-templates.ts` and the pre-baked
|
|
43
|
+
* `handlers.ts`) so a security option is honored consistently — it can never
|
|
44
|
+
* be applied on one path and silently ignored on the other (the historic
|
|
45
|
+
* "security-preheat gotcha"). `https === true` gates the static HSTS pair
|
|
46
|
+
* (dynamic HSTS — `https` undefined — is handled per-request by the fast-path
|
|
47
|
+
* response builder); `enabled === false` yields no pairs.
|
|
48
|
+
*/
|
|
49
|
+
export function buildSecurityPairs(
|
|
50
|
+
security: SecurityHeadersOptions | undefined,
|
|
51
|
+
https: boolean | undefined,
|
|
52
|
+
enabled: boolean,
|
|
53
|
+
): Array<[string, string]> {
|
|
54
|
+
if (!enabled) return []
|
|
55
|
+
const sec = security ?? {}
|
|
56
|
+
const pairs: Array<[string, string]> = []
|
|
57
|
+
|
|
58
|
+
if (sec.nosniff !== false) {
|
|
59
|
+
pairs.push(['x-content-type-options', 'nosniff'])
|
|
60
|
+
}
|
|
61
|
+
pairs.push(['x-frame-options', sec.frameOptions ?? 'DENY'])
|
|
62
|
+
pairs.push(['referrer-policy', sec.referrerPolicy ?? 'no-referrer'])
|
|
63
|
+
if (sec.xssProtection) {
|
|
64
|
+
pairs.push(['x-xss-protection', sec.xssProtection])
|
|
65
|
+
}
|
|
66
|
+
if (sec.contentSecurityPolicy) {
|
|
67
|
+
pairs.push(['content-security-policy', sec.contentSecurityPolicy])
|
|
68
|
+
}
|
|
69
|
+
if (sec.coep) {
|
|
70
|
+
pairs.push(['cross-origin-embedder-policy', sec.coep])
|
|
71
|
+
}
|
|
72
|
+
if (sec.coop) {
|
|
73
|
+
pairs.push(['cross-origin-opener-policy', sec.coop])
|
|
74
|
+
}
|
|
75
|
+
if (sec.corp) {
|
|
76
|
+
pairs.push(['cross-origin-resource-policy', sec.corp])
|
|
77
|
+
}
|
|
78
|
+
const hstsValue = buildHstsValue(sec)
|
|
79
|
+
if (hstsValue !== null && https === true) {
|
|
80
|
+
pairs.push(['strict-transport-security', hstsValue])
|
|
81
|
+
}
|
|
82
|
+
return pairs
|
|
83
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/ingress/health.ts — Liveness / readiness / health probe route factories.
|
|
2
|
+
//
|
|
3
|
+
// Enterprise-grade operators need orchestrator probes (Kubernetes `livenessProbe`
|
|
4
|
+
// / `readinessProbe`). These factories return route handlers compatible with the
|
|
5
|
+
// pre-baked route spec — serve them from `createIngressServer` (Bun) or
|
|
6
|
+
// `createIngressServerNode` (Node):
|
|
7
|
+
//
|
|
8
|
+
// createIngressServer({ routes: {
|
|
9
|
+
// "/healthz": { read: livenessHandler() },
|
|
10
|
+
// "/readyz": { read: readinessHandler(async () => db.ping() === "ok") },
|
|
11
|
+
// "/livez": { read: healthHandler() },
|
|
12
|
+
// }});
|
|
13
|
+
//
|
|
14
|
+
// The liveness probe NEVER touches dependencies (process alive? -> 200).
|
|
15
|
+
// The readiness probe runs the optional `check` (deps up? -> 200/503).
|
|
16
|
+
// The health probe is an alias of liveness (process up) plus optional check.
|
|
17
|
+
|
|
18
|
+
export interface ReadinessResult {
|
|
19
|
+
status: number
|
|
20
|
+
body: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function jsonResponse(status: number, body: Record<string, unknown>): Response {
|
|
24
|
+
return new Response(JSON.stringify(body), {
|
|
25
|
+
status,
|
|
26
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Liveness probe: `200 {"status":"ok"}` as long as the process is alive.
|
|
32
|
+
* Never touches dependencies.
|
|
33
|
+
*/
|
|
34
|
+
export function livenessHandler() {
|
|
35
|
+
return (_req: Request): Response => jsonResponse(200, { status: 'ok' })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Readiness probe: runs the optional async `check` (dependency readiness).
|
|
40
|
+
* Returns `200 {"status":"ready"}` when the check passes (or no check is
|
|
41
|
+
* given), `503 {"status":"not_ready"}` when it throws or returns false.
|
|
42
|
+
*/
|
|
43
|
+
export function readinessHandler(check?: () => boolean | Promise<boolean>) {
|
|
44
|
+
return async (_req: Request): Promise<Response> => {
|
|
45
|
+
if (!check) return jsonResponse(200, { status: 'ready' })
|
|
46
|
+
try {
|
|
47
|
+
const ok = await check()
|
|
48
|
+
return ok
|
|
49
|
+
? jsonResponse(200, { status: 'ready' })
|
|
50
|
+
: jsonResponse(503, { status: 'not_ready' })
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return jsonResponse(503, {
|
|
53
|
+
status: 'not_ready',
|
|
54
|
+
error: err instanceof Error ? err.message : String(err),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Combined health probe: liveness by default, plus an optional readiness
|
|
62
|
+
* `check`. When a `check` is provided the response is 503 on failure
|
|
63
|
+
* (readiness semantics), otherwise always 200.
|
|
64
|
+
*/
|
|
65
|
+
export function healthHandler(check?: () => boolean | Promise<boolean>) {
|
|
66
|
+
return check ? readinessHandler(check) : livenessHandler()
|
|
67
|
+
}
|