castrum 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +239 -5
- package/dist/index.js +3852 -3375
- package/docs/ARCHITECTURE.md +11 -9
- package/docs/BENCHMARKS.md +70 -38
- package/docs/CASE_STUDY.md +10 -7
- package/docs/ENVIRONMENT.md +16 -1
- package/docs/FFI_BUN_GUIDE.md +18 -10
- package/docs/GETTING_STARTED.md +5 -4
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +3 -2
- package/docs/INGRESS.md +6 -3
- package/docs/NATIVE-ROUTE.md +28 -4
- package/docs/REPO_MAP.md +60 -30
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/package.json +24 -23
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +45 -290
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/index.ts +12 -148
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +26 -2
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +8 -1
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +42 -2
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/json-write.ts +21 -18
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/server-node.ts +1 -1
- package/src/ingress/server.ts +38 -99
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +9 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/selftest.ts +11 -718
- package/src/native/ffi/types.ts +25 -0
- package/src/native/ffi.ts +36 -1320
- package/src/native/types/instances.ts +5 -0
- package/src/rust-ffi/packed.ts +38 -0
- package/src/rust-ffi/scalar/factories.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castrum",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
|
|
6
6
|
"type": "module",
|
|
@@ -81,35 +81,36 @@
|
|
|
81
81
|
"check:jsdoc": "bun scripts/check-jsdoc.ts",
|
|
82
82
|
"check:selection": "bun scripts/select-native.ts --check",
|
|
83
83
|
"check:clean": "bun scripts/check-clean.ts",
|
|
84
|
+
"gen:ai-map": "bun scripts/gen-ai-map.ts",
|
|
84
85
|
"audit": "bun audit",
|
|
85
86
|
"publish:manual": "node scripts/publish-manual.mjs",
|
|
86
87
|
"publish:manual:dry": "node scripts/publish-manual.mjs --dry-run",
|
|
87
88
|
"prepublishOnly": "bun run build:js && node scripts/prepublish.mjs",
|
|
88
|
-
"bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
89
|
-
"bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
90
|
-
"bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
91
|
-
"bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
92
|
-
"bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
93
|
-
"bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
94
|
-
"bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
95
|
-
"bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
96
|
-
"bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
97
|
-
"bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
98
|
-
"bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
99
|
-
"bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
100
|
-
"bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=
|
|
89
|
+
"bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts",
|
|
90
|
+
"bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts smoke",
|
|
91
|
+
"bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts stress",
|
|
92
|
+
"bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts heavy-json",
|
|
93
|
+
"bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts crud",
|
|
94
|
+
"bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts spike",
|
|
95
|
+
"bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts soak",
|
|
96
|
+
"bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts storm",
|
|
97
|
+
"bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts boundary",
|
|
98
|
+
"bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' elysia",
|
|
99
|
+
"bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' ingress",
|
|
100
|
+
"bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' bun",
|
|
101
|
+
"bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts 1",
|
|
101
102
|
"bench:http:ac": "bun bench/autocannon-stress.mjs",
|
|
102
103
|
"bench:http:stress:ac": "SCENARIO=03-stress bun bench/autocannon-stress.mjs",
|
|
103
104
|
"bench:startup": "bun run bench/startup.ts",
|
|
104
|
-
"bench:load": "bun run bench/load-phase.ts",
|
|
105
|
-
"bench:ffi": "bun run bench/ffi-all.ts",
|
|
106
|
-
"bench:ffi:load": "bun run bench/ffi-load.ts",
|
|
107
|
-
"bench:ffi:public": "bun run bench/ffi-public.ts",
|
|
108
|
-
"bench:ffi:workers": "bun run bench/ffi-workers.ts",
|
|
109
|
-
"bench:margin": "bun run bench/ffi-margin.ts",
|
|
110
|
-
"bench:router": "bun run bench/router-cost.ts",
|
|
111
|
-
"bench:ingress-cost": "bun run bench/ingress-cost.ts",
|
|
112
|
-
"bench:ingress-cost:post": "bun run bench/ingress-cost-post.ts",
|
|
105
|
+
"bench:load": "bun run bench/http/load-phase.ts",
|
|
106
|
+
"bench:ffi": "bun run bench/ffi/ffi-all.ts",
|
|
107
|
+
"bench:ffi:load": "bun run bench/ffi/ffi-load.ts",
|
|
108
|
+
"bench:ffi:public": "bun run bench/ffi/ffi-public.ts",
|
|
109
|
+
"bench:ffi:workers": "bun run bench/ffi/ffi-workers.ts",
|
|
110
|
+
"bench:margin": "bun run bench/ffi/ffi-margin.ts",
|
|
111
|
+
"bench:router": "bun run bench/cost/router-cost.ts",
|
|
112
|
+
"bench:ingress-cost": "bun run bench/cost/ingress-cost.ts",
|
|
113
|
+
"bench:ingress-cost:post": "bun run bench/cost/ingress-cost-post.ts",
|
|
113
114
|
"verify:native:batch": "bun run scripts/verify-native-batch.ts"
|
|
114
115
|
},
|
|
115
116
|
"dependencies": {},
|
|
@@ -110,8 +110,16 @@ export abstract class IngressResultBase {
|
|
|
110
110
|
*/
|
|
111
111
|
protected setRateWindow(rateLimit: number, rateLimited: boolean, view: DataView): void {
|
|
112
112
|
if (rateLimit > 0 || rateLimited) {
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
// i64 LE read as two u32 halves (lo + hi * 2^32) instead of
|
|
114
|
+
// `getBigUint64` — avoids the per-read BigInt boxing (measured ~10ns
|
|
115
|
+
// per read on the hot path). Bit-identical to `Number(getBigUint64(..))`
|
|
116
|
+
// for the unsigned interpretation and exact for epoch-ms (< 2^53).
|
|
117
|
+
const resetLo = view.getUint32(OUT_RATE_RESET, true)
|
|
118
|
+
const resetHi = view.getUint32(OUT_RATE_RESET + 4, true)
|
|
119
|
+
this.rateResetMs = resetLo + resetHi * 4294967296
|
|
120
|
+
const retryLo = view.getUint32(OUT_RETRY_AFTER, true)
|
|
121
|
+
const retryHi = view.getUint32(OUT_RETRY_AFTER + 4, true)
|
|
122
|
+
this.retryAfterMs = retryLo + retryHi * 4294967296
|
|
115
123
|
} else {
|
|
116
124
|
this.rateResetMs = 0
|
|
117
125
|
this.retryAfterMs = 0
|
package/src/ingress/handlers.ts
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
// The pre-baked pipeline is decomposed into task-focused submodules:
|
|
32
32
|
// - decode/baked-result.ts BakedIngressResult (zero-alloc decode)
|
|
33
33
|
// - response/error-bodies.ts pre-encoded error bodies
|
|
34
|
+
// - response/baked-response.ts pre-baked response builders (baked wire)
|
|
35
|
+
// - headers/security.ts security header merge
|
|
34
36
|
// - headers/baked-templates.ts ratelimit-* header templates
|
|
35
37
|
// - packing/gather-raw-headers.ts raw header gathering
|
|
36
38
|
// - routes/* route factories
|
|
@@ -39,23 +41,13 @@
|
|
|
39
41
|
import { getAddon } from '../native'
|
|
40
42
|
import { getBunFFI } from '../native/ffi'
|
|
41
43
|
import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
|
|
42
|
-
import { decoder,
|
|
44
|
+
import { decoder, viewForArrayBuffer } from '../shared/bytes'
|
|
43
45
|
import { createStructuredLogger } from '../shared/log'
|
|
44
46
|
import { generateRequestId } from '../shared/request-id'
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
|
|
48
|
-
HV_CORS_PREFLIGHT,
|
|
49
|
-
HV_CORS_SIMPLE,
|
|
50
|
-
HV_JSON,
|
|
51
|
-
HV_RATE_ACTIVE,
|
|
52
|
-
HV_RATE_LIMITED,
|
|
53
|
-
OUT_DATA_START,
|
|
54
|
-
} from './constants'
|
|
47
|
+
import { OUT_DATA_START } from './constants'
|
|
55
48
|
import { BakedIngressResult } from './decode/baked-result'
|
|
56
49
|
import { buildBakedHeaderTemplates } from './headers/baked-templates'
|
|
57
|
-
import
|
|
58
|
-
import { buildSecurityPairs } from './headers/hsts'
|
|
50
|
+
import { buildBakedSecurityEntries } from './headers/security'
|
|
59
51
|
import {
|
|
60
52
|
assertIngressOptionValues,
|
|
61
53
|
assertKnownIngressOptions,
|
|
@@ -64,7 +56,7 @@ import {
|
|
|
64
56
|
} from './options'
|
|
65
57
|
import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
|
|
66
58
|
import { IngressInputPacker } from './packing/input-packer'
|
|
67
|
-
import {
|
|
59
|
+
import { type BakedResponseState, buildBakedResponseBuilders } from './response/baked-response'
|
|
68
60
|
import {
|
|
69
61
|
assertSyncCallback,
|
|
70
62
|
buildHeaderPlan,
|
|
@@ -73,7 +65,6 @@ import {
|
|
|
73
65
|
type HeaderPlan,
|
|
74
66
|
METHOD_KIND,
|
|
75
67
|
METHOD_KIND_UNKNOWN,
|
|
76
|
-
secondsFromMs,
|
|
77
68
|
} from './shared'
|
|
78
69
|
import { safeTerminalStatus } from './status'
|
|
79
70
|
import type { BakedContext, OptimizedIngressHandler } from './types'
|
|
@@ -134,32 +125,6 @@ function pathForLog(req: Request): string {
|
|
|
134
125
|
}
|
|
135
126
|
}
|
|
136
127
|
|
|
137
|
-
/**
|
|
138
|
-
* Merge structured `options.security` (SecurityHeadersOptions) with raw
|
|
139
|
-
* `runtime.securityHeaders` pairs into the ordered entries the baked templates
|
|
140
|
-
* prepend to every response variant. Raw pairs win on name conflicts; names are
|
|
141
|
-
* lowercased for the template merge.
|
|
142
|
-
*
|
|
143
|
-
* The structured defaults (nosniff/DENY/no-referrer) are ONLY applied when the
|
|
144
|
-
* user explicitly opted into security on this path (`security !== undefined`) —
|
|
145
|
-
* the baked path's long-standing default is no security headers, and silently
|
|
146
|
-
* adding them would change every response.
|
|
147
|
-
*/
|
|
148
|
-
function buildBakedSecurityEntries(
|
|
149
|
-
security: SecurityHeadersOptions | undefined,
|
|
150
|
-
https: boolean | undefined,
|
|
151
|
-
raw: ReadonlyArray<[string, string]> | undefined,
|
|
152
|
-
): Array<[string, string]> {
|
|
153
|
-
const structured = security === undefined ? [] : buildSecurityPairs(security, https, true)
|
|
154
|
-
|
|
155
|
-
if (raw === undefined || raw.length === 0) return structured
|
|
156
|
-
|
|
157
|
-
const byName = new Map<string, string>()
|
|
158
|
-
for (const [k, v] of structured) byName.set(k, v)
|
|
159
|
-
for (const [k, v] of raw) byName.set(k.toLowerCase(), v)
|
|
160
|
-
return [...byName.entries()]
|
|
161
|
-
}
|
|
162
|
-
|
|
163
128
|
// ── Types ────────────────────────────────────────────────────────
|
|
164
129
|
// `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
|
|
165
130
|
// (shared with the route factories) and re-exported here for back-compat.
|
|
@@ -333,11 +298,12 @@ export function createIngressHandler(
|
|
|
333
298
|
// the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
|
|
334
299
|
const inputPacker = new IngressInputPacker()
|
|
335
300
|
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
301
|
+
// Shared per-request state with the response builders
|
|
302
|
+
// (response/baked-response.ts): `zeroCopyResponse` marks the pooled output
|
|
303
|
+
// buffer as borrowed so `run()` must not release it before the body is
|
|
304
|
+
// consumed. The result decoders share a single mutable object too, so this
|
|
305
|
+
// is the same pattern — one live request per handler at a time.
|
|
306
|
+
const responseState: BakedResponseState = { currentHandle: null, responseBorrowsBuffer: false }
|
|
341
307
|
|
|
342
308
|
// FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
|
|
343
309
|
// non-panic UB (a segfault still crashes the process), but a RECURRING
|
|
@@ -400,222 +366,18 @@ export function createIngressHandler(
|
|
|
400
366
|
rateLimitStr,
|
|
401
367
|
})
|
|
402
368
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const needsOrigin =
|
|
416
|
-
((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
417
|
-
const needsRate = (variant & HV_RATE_ACTIVE) !== 0
|
|
418
|
-
const needsRetry = (variant & HV_RATE_LIMITED) !== 0
|
|
419
|
-
|
|
420
|
-
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
421
|
-
// Frozen-readonly → mutable: callers only ever read the baked template
|
|
422
|
-
// (zero-copy steady-state path), so the widening is safe in practice.
|
|
423
|
-
return template as unknown as [string, string][]
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
|
|
427
|
-
// Steady-state CORS: only the origin varies — cache the augmented array
|
|
428
|
-
// per (variant, origin) so the Origin-present case (the common
|
|
429
|
-
// browser/bench case) stays allocation-free after the first hit for EACH
|
|
430
|
-
// distinct origin (a single-slot memo thrashed when clients alternate
|
|
431
|
-
// between allowed origins, allocating a fresh array on every switch).
|
|
432
|
-
const key = `${variant & 31}\u0000${origin as string}`
|
|
433
|
-
const cached = originHeaderCache.get(key)
|
|
434
|
-
if (cached !== undefined) {
|
|
435
|
-
return cached
|
|
436
|
-
}
|
|
437
|
-
const entries = new Array<[string, string]>(template.length + 1)
|
|
438
|
-
let i = 0
|
|
439
|
-
for (const pair of template) {
|
|
440
|
-
entries[i++] = pair
|
|
441
|
-
}
|
|
442
|
-
entries[i] = ['access-control-allow-origin', origin as string]
|
|
443
|
-
originHeaderCache.set(key, entries)
|
|
444
|
-
return entries
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
let extra = 0
|
|
448
|
-
if (needsRequestId) extra++
|
|
449
|
-
if (needsOrigin) extra++
|
|
450
|
-
if (needsRate) extra += 2
|
|
451
|
-
if (needsRetry) extra++
|
|
452
|
-
|
|
453
|
-
const entries = new Array<[string, string]>(template.length + extra)
|
|
454
|
-
let i = 0
|
|
455
|
-
|
|
456
|
-
for (const pair of template) {
|
|
457
|
-
entries[i++] = pair
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (needsRequestId) {
|
|
461
|
-
entries[i++] = ['x-request-id', requestIdHeader as string]
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (needsOrigin) {
|
|
465
|
-
entries[i++] = ['access-control-allow-origin', origin as string]
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (needsRate) {
|
|
469
|
-
entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
|
|
470
|
-
entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (needsRetry) {
|
|
474
|
-
entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
return entries
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function terminalHeaders(
|
|
481
|
-
variant: number,
|
|
482
|
-
ctx: BakedContext,
|
|
483
|
-
result: BakedIngressResult | null,
|
|
484
|
-
): [string, string][] {
|
|
485
|
-
const v = variant | HV_JSON
|
|
486
|
-
const requestIdHeader = ctx.requestIdHeader
|
|
487
|
-
const origin = ctx.origin
|
|
488
|
-
const rateResetSecs =
|
|
489
|
-
result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
|
|
490
|
-
const retryAfterSecs =
|
|
491
|
-
result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
|
|
492
|
-
|
|
493
|
-
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
494
|
-
const needsOrigin =
|
|
495
|
-
((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
496
|
-
const needsRate = (v & HV_RATE_ACTIVE) !== 0
|
|
497
|
-
const needsRetry = (v & HV_RATE_LIMITED) !== 0
|
|
498
|
-
|
|
499
|
-
// Steady state: no request-id/origin/rate extras — serve the pre-baked
|
|
500
|
-
// terminal template (JSON + `cache-control: no-store`) directly with zero
|
|
501
|
-
// per-response array allocation/copy.
|
|
502
|
-
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
503
|
-
// Frozen-readonly → mutable: the baked terminal template is only read.
|
|
504
|
-
return terminalTemplates[v & 31] as unknown as [string, string][]
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// Rare path: extras appended after the baked entries, then `cache-control`.
|
|
508
|
-
const base = responseHeaders(
|
|
509
|
-
v,
|
|
510
|
-
requestIdHeader,
|
|
511
|
-
origin,
|
|
512
|
-
result?.rateRemaining,
|
|
513
|
-
rateResetSecs,
|
|
514
|
-
retryAfterSecs,
|
|
515
|
-
)
|
|
516
|
-
|
|
517
|
-
const out = new Array<[string, string]>(base.length + 1)
|
|
518
|
-
let i = 0
|
|
519
|
-
for (const pair of base) {
|
|
520
|
-
out[i++] = pair
|
|
521
|
-
}
|
|
522
|
-
out[base.length] = ['cache-control', 'no-store']
|
|
523
|
-
return out
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function terminalResponse(
|
|
527
|
-
_req: Request | undefined,
|
|
528
|
-
result: BakedIngressResult,
|
|
529
|
-
ctx: BakedContext,
|
|
530
|
-
): Response | null {
|
|
531
|
-
if (!result.terminal) {
|
|
532
|
-
return null
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
536
|
-
|
|
537
|
-
if (preflightAllowed) {
|
|
538
|
-
return new Response(null, {
|
|
539
|
-
status: 204,
|
|
540
|
-
headers: responseHeaders(
|
|
541
|
-
result.headerVariant,
|
|
542
|
-
ctx.requestIdHeader,
|
|
543
|
-
ctx.origin,
|
|
544
|
-
result.rateRemaining,
|
|
545
|
-
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
546
|
-
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
547
|
-
),
|
|
548
|
-
})
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const status = safeTerminalStatus(result)
|
|
552
|
-
|
|
553
|
-
const body: Uint8Array =
|
|
554
|
-
result.errorCode === ERROR_CODE_RATE_LIMITED
|
|
555
|
-
? rateLimitedBody(result.retryAfterMs)
|
|
556
|
-
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
|
|
557
|
-
|
|
558
|
-
return new Response(body, {
|
|
559
|
-
status,
|
|
560
|
-
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
561
|
-
})
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function errorResponse(
|
|
565
|
-
_req: Request | undefined,
|
|
566
|
-
result: BakedIngressResult | null,
|
|
567
|
-
status: number,
|
|
568
|
-
code: string,
|
|
569
|
-
message: string,
|
|
570
|
-
ctx: BakedContext,
|
|
571
|
-
): Response {
|
|
572
|
-
const body =
|
|
573
|
-
ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
|
|
574
|
-
|
|
575
|
-
return new Response(body, {
|
|
576
|
-
status,
|
|
577
|
-
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
578
|
-
})
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
582
|
-
return new Response(ERROR_BODIES.internal, {
|
|
583
|
-
status: 500,
|
|
584
|
-
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
585
|
-
})
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function withContentType(
|
|
589
|
-
headers: ReadonlyArray<[string, string]>,
|
|
590
|
-
contentType: string,
|
|
591
|
-
): [string, string][] {
|
|
592
|
-
const out = new Array<[string, string]>(headers.length + 1)
|
|
593
|
-
let i = 0
|
|
594
|
-
for (const pair of headers) {
|
|
595
|
-
out[i++] = pair
|
|
596
|
-
}
|
|
597
|
-
out[headers.length] = ['content-type', contentType]
|
|
598
|
-
return out
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
function zeroCopyResponse(
|
|
602
|
-
result: BakedIngressResult,
|
|
603
|
-
_ctx: BakedContext,
|
|
604
|
-
init: ResponseInit,
|
|
605
|
-
): Response {
|
|
606
|
-
if (currentHandle === null) {
|
|
607
|
-
// `zeroCopyResponse` must only run inside a live `run()` callback (a
|
|
608
|
-
// pooled output buffer must be in flight). Called outside `run()` the
|
|
609
|
-
// result has already been invalidated and `result.bodyJson` is empty —
|
|
610
|
-
// throw instead of silently serving an empty body.
|
|
611
|
-
throw new Error(
|
|
612
|
-
'zeroCopyResponse() can only be called from within a run() callback ' +
|
|
613
|
-
'(the pooled output buffer is only live during run()).',
|
|
614
|
-
)
|
|
615
|
-
}
|
|
616
|
-
responseBorrowsBuffer = true
|
|
617
|
-
return pooledBodyResponse(currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
|
|
618
|
-
}
|
|
369
|
+
// Response builders, pre-bound to this handler's header templates, request-id
|
|
370
|
+
// policy, CORS cache and shared per-request state. This is the extracted
|
|
371
|
+
// response/terminal family (see response/baked-response.ts) — identical
|
|
372
|
+
// bodies, now living next to response/error-bodies.ts for navigability.
|
|
373
|
+
const responseBuilders = buildBakedResponseBuilders({
|
|
374
|
+
headerTemplates,
|
|
375
|
+
terminalTemplates,
|
|
376
|
+
emitRequestIdHeader,
|
|
377
|
+
zeroCopyTimeoutMs,
|
|
378
|
+
originHeaderCache,
|
|
379
|
+
state: responseState,
|
|
380
|
+
})
|
|
619
381
|
|
|
620
382
|
const result = new BakedIngressResult()
|
|
621
383
|
const ctx: BakedContext = {
|
|
@@ -648,8 +410,8 @@ export function createIngressHandler(
|
|
|
648
410
|
// full_sync family paid on every request. The response wire format is
|
|
649
411
|
// unchanged because both entries run the identical `handle_packed` core.
|
|
650
412
|
let handle: PooledBuffer | null = null
|
|
651
|
-
currentHandle = null
|
|
652
|
-
responseBorrowsBuffer = false
|
|
413
|
+
responseState.currentHandle = null
|
|
414
|
+
responseState.responseBorrowsBuffer = false
|
|
653
415
|
|
|
654
416
|
try {
|
|
655
417
|
// Pack the request frame (inside the try: an unexpected packing failure
|
|
@@ -669,7 +431,7 @@ export function createIngressHandler(
|
|
|
669
431
|
// Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
|
|
670
432
|
// via setInternalError — never an uncaught exception out of run().
|
|
671
433
|
handle = outputPool.acquire(outputBufferSize)
|
|
672
|
-
currentHandle = handle
|
|
434
|
+
responseState.currentHandle = handle
|
|
673
435
|
|
|
674
436
|
// Write into the pooled buffer — no per-request allocation. Bun
|
|
675
437
|
// PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
|
|
@@ -718,13 +480,7 @@ export function createIngressHandler(
|
|
|
718
480
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
719
481
|
}
|
|
720
482
|
} else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
|
|
721
|
-
const input = inputPacker.packParts(
|
|
722
|
-
methodKind,
|
|
723
|
-
packUrl,
|
|
724
|
-
packIp,
|
|
725
|
-
ridBytes,
|
|
726
|
-
packedHeaders,
|
|
727
|
-
)
|
|
483
|
+
const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
|
|
728
484
|
try {
|
|
729
485
|
written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
|
|
730
486
|
ffiFailures = 0
|
|
@@ -738,13 +494,7 @@ export function createIngressHandler(
|
|
|
738
494
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
739
495
|
}
|
|
740
496
|
} else {
|
|
741
|
-
const input = inputPacker.packParts(
|
|
742
|
-
methodKind,
|
|
743
|
-
packUrl,
|
|
744
|
-
packIp,
|
|
745
|
-
ridBytes,
|
|
746
|
-
packedHeaders,
|
|
747
|
-
)
|
|
497
|
+
const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
|
|
748
498
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
749
499
|
}
|
|
750
500
|
|
|
@@ -797,14 +547,14 @@ export function createIngressHandler(
|
|
|
797
547
|
// request accounting completes (metrics in_flight must not leak), then
|
|
798
548
|
// rethrow for the caller. The native-failure path above does NOT rethrow,
|
|
799
549
|
// so this fires at most once per request.
|
|
800
|
-
if (responseBorrowsBuffer && handle !== null) {
|
|
550
|
+
if (responseState.responseBorrowsBuffer && handle !== null) {
|
|
801
551
|
// `zeroCopyResponse` was called but the Response never escaped (the
|
|
802
552
|
// callback threw): the stream that would release the pooled buffer was
|
|
803
553
|
// never delivered. Release it here so the buffer isn't stuck in flight
|
|
804
554
|
// forever.
|
|
805
555
|
handle.release()
|
|
806
556
|
handle = null
|
|
807
|
-
responseBorrowsBuffer = false
|
|
557
|
+
responseState.responseBorrowsBuffer = false
|
|
808
558
|
}
|
|
809
559
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
810
560
|
runtime.onError?.(req, requestIdStr, error)
|
|
@@ -820,21 +570,26 @@ export function createIngressHandler(
|
|
|
820
570
|
// A zero-copy Response owns the handle until its body is consumed; in
|
|
821
571
|
// every other case (copy mode, terminal/error responses) the buffer is
|
|
822
572
|
// safe to return to the pool immediately.
|
|
823
|
-
if (handle !== null && !responseBorrowsBuffer) {
|
|
573
|
+
if (handle !== null && !responseState.responseBorrowsBuffer) {
|
|
824
574
|
handle.release()
|
|
825
575
|
}
|
|
826
|
-
currentHandle = null
|
|
576
|
+
responseState.currentHandle = null
|
|
827
577
|
}
|
|
828
578
|
}
|
|
829
579
|
|
|
580
|
+
// Native JSON-validity capability for the pure route factories: the zero-DOM
|
|
581
|
+
// `json_valid` fast path (same one the pipeline uses), routed through the
|
|
582
|
+
// primary bun:ffi transport with the napi fallback. Exposed on the handler so
|
|
583
|
+
// `routes/json-write.ts` validates request bodies via DI — it must not import
|
|
584
|
+
// the native layer (purity boundary).
|
|
585
|
+
const nativeJsonValid = (bytes: Uint8Array): boolean =>
|
|
586
|
+
bunFFI !== null && typeof bunFFI.jsonValid === 'function'
|
|
587
|
+
? bunFFI.jsonValid(bytes)
|
|
588
|
+
: addon.jsonValid(bytes)
|
|
589
|
+
|
|
830
590
|
return {
|
|
831
591
|
run,
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
terminalResponse,
|
|
835
|
-
errorResponse,
|
|
836
|
-
internalErrorResponse,
|
|
837
|
-
withContentType,
|
|
838
|
-
zeroCopyResponse,
|
|
592
|
+
...responseBuilders,
|
|
593
|
+
jsonValid: nativeJsonValid,
|
|
839
594
|
}
|
|
840
595
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/ingress/headers/security.ts — baked-path security header merge
|
|
2
|
+
//
|
|
3
|
+
// Merges the structured `options.security` (SecurityHeadersOptions) with raw
|
|
4
|
+
// `runtime.securityHeaders` pairs into the ordered entries the pre-baked
|
|
5
|
+
// handler prepends to every response variant. Raw pairs win on name conflicts;
|
|
6
|
+
// names are lowercased for the template merge.
|
|
7
|
+
|
|
8
|
+
import type { SecurityHeadersOptions } from './hsts'
|
|
9
|
+
import { buildSecurityPairs } from './hsts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Merge structured `options.security` with raw `runtime.securityHeaders` pairs
|
|
13
|
+
* into the ordered entries the baked templates prepend to every response
|
|
14
|
+
* variant. Raw pairs win on name conflicts; names are lowercased for the
|
|
15
|
+
* template merge.
|
|
16
|
+
*
|
|
17
|
+
* The structured defaults (nosniff/DENY/no-referrer) are ONLY applied when the
|
|
18
|
+
* user explicitly opted into security on this path (`security !== undefined`) —
|
|
19
|
+
* the baked path's long-standing default is no security headers, and silently
|
|
20
|
+
* adding them would change every response.
|
|
21
|
+
*
|
|
22
|
+
* @param security structured `SecurityHeadersOptions` from `options.security`
|
|
23
|
+
* (the same shape `createIngress` honors)
|
|
24
|
+
* @param https whether HTTPS is pinned (drives HSTS defaults)
|
|
25
|
+
* @param raw raw `runtime.securityHeaders` pairs (names lowercased)
|
|
26
|
+
* @returns ordered `[name, value]` entries for the baked header templates
|
|
27
|
+
*/
|
|
28
|
+
export function buildBakedSecurityEntries(
|
|
29
|
+
security: SecurityHeadersOptions | undefined,
|
|
30
|
+
https: boolean | undefined,
|
|
31
|
+
raw: ReadonlyArray<[string, string]> | undefined,
|
|
32
|
+
): Array<[string, string]> {
|
|
33
|
+
const structured = security === undefined ? [] : buildSecurityPairs(security, https, true)
|
|
34
|
+
|
|
35
|
+
if (raw === undefined || raw.length === 0) return structured
|
|
36
|
+
|
|
37
|
+
const byName = new Map<string, string>()
|
|
38
|
+
for (const [k, v] of structured) byName.set(k, v)
|
|
39
|
+
for (const [k, v] of raw) byName.set(k.toLowerCase(), v)
|
|
40
|
+
return [...byName.entries()]
|
|
41
|
+
}
|