castrum 0.9.0 → 0.9.2
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 +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1653 -1817
- package/dist/index.js +6172 -4294
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +111 -17
- package/docs/BENCHMARKS.md +117 -49
- package/docs/CASE_STUDY.md +243 -0
- package/docs/ENVIRONMENT.md +24 -7
- package/docs/FFI_BUN_GUIDE.md +335 -0
- package/docs/GETTING_STARTED.md +18 -34
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +82 -0
- package/docs/INGRESS.md +11 -7
- package/docs/NATIVE-ROUTE.md +77 -0
- package/docs/REPO_MAP.md +103 -53
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +41 -28
- package/src/ingress/body.ts +167 -27
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +116 -285
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +45 -169
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +110 -22
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +20 -7
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +277 -0
- package/src/ingress/routes/common.ts +50 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +26 -47
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +7 -7
- package/src/ingress/server.ts +83 -112
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +44 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -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/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +22 -0
- package/src/native/ffi/types.ts +499 -0
- package/src/native/ffi.ts +307 -1357
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +279 -0
- package/src/native/types.ts +41 -238
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +1 -1
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +40 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +257 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/{packed.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- package/src/shared/bench-classify.ts +0 -209
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
|
|
@@ -42,18 +44,10 @@ import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
|
|
|
42
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'
|
|
50
|
+
import { buildBakedSecurityEntries } from './headers/security'
|
|
57
51
|
import {
|
|
58
52
|
assertIngressOptionValues,
|
|
59
53
|
assertKnownIngressOptions,
|
|
@@ -62,9 +56,7 @@ import {
|
|
|
62
56
|
} from './options'
|
|
63
57
|
import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
|
|
64
58
|
import { IngressInputPacker } from './packing/input-packer'
|
|
65
|
-
import {
|
|
66
|
-
import type { SecurityHeadersOptions } from './headers/hsts'
|
|
67
|
-
import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './response/error-bodies'
|
|
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'
|
|
@@ -84,10 +75,13 @@ export type { IngressHandlerOptions } from './options'
|
|
|
84
75
|
export { ERROR_BODIES } from './response/error-bodies'
|
|
85
76
|
export type { BakedHandlerOptions } from './routes'
|
|
86
77
|
export {
|
|
78
|
+
deleteHandler,
|
|
87
79
|
echoHandler,
|
|
88
80
|
fallbackHandler,
|
|
89
81
|
headHandler,
|
|
90
82
|
jsonWriteHandler,
|
|
83
|
+
nativeResponderRoute,
|
|
84
|
+
optionsHandler,
|
|
91
85
|
readHandler,
|
|
92
86
|
} from './routes'
|
|
93
87
|
export type {
|
|
@@ -102,7 +96,6 @@ export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
|
|
|
102
96
|
// Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
|
|
103
97
|
export { createIngressServerNode } from './server-node'
|
|
104
98
|
|
|
105
|
-
const encoder = new TextEncoder()
|
|
106
99
|
const EMPTY_BODY = new Uint8Array(0)
|
|
107
100
|
const EMPTY_IP = '0.0.0.0'
|
|
108
101
|
|
|
@@ -132,37 +125,12 @@ function pathForLog(req: Request): string {
|
|
|
132
125
|
}
|
|
133
126
|
}
|
|
134
127
|
|
|
135
|
-
/**
|
|
136
|
-
* Merge structured `options.security` (SecurityHeadersOptions) with raw
|
|
137
|
-
* `runtime.securityHeaders` pairs into the ordered entries the baked templates
|
|
138
|
-
* prepend to every response variant. Raw pairs win on name conflicts; names are
|
|
139
|
-
* lowercased for the template merge.
|
|
140
|
-
*
|
|
141
|
-
* The structured defaults (nosniff/DENY/no-referrer) are ONLY applied when the
|
|
142
|
-
* user explicitly opted into security on this path (`security !== undefined`) —
|
|
143
|
-
* the baked path's long-standing default is no security headers, and silently
|
|
144
|
-
* adding them would change every response.
|
|
145
|
-
*/
|
|
146
|
-
function buildBakedSecurityEntries(
|
|
147
|
-
security: SecurityHeadersOptions | undefined,
|
|
148
|
-
https: boolean | undefined,
|
|
149
|
-
raw: ReadonlyArray<[string, string]> | undefined,
|
|
150
|
-
): Array<[string, string]> {
|
|
151
|
-
const structured = security === undefined ? [] : buildSecurityPairs(security, https, true)
|
|
152
|
-
|
|
153
|
-
if (raw === undefined || raw.length === 0) return structured
|
|
154
|
-
|
|
155
|
-
const byName = new Map<string, string>()
|
|
156
|
-
for (const [k, v] of structured) byName.set(k, v)
|
|
157
|
-
for (const [k, v] of raw) byName.set(k.toLowerCase(), v)
|
|
158
|
-
return [...byName.entries()]
|
|
159
|
-
}
|
|
160
|
-
|
|
161
128
|
// ── Types ────────────────────────────────────────────────────────
|
|
162
129
|
// `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
|
|
163
130
|
// (shared with the route factories) and re-exported here for back-compat.
|
|
164
131
|
export type { BakedContext, OptimizedIngressHandler } from './types'
|
|
165
132
|
|
|
133
|
+
/** Runtime configuration for the pre-baked handler path (`createIngressHandler`). */
|
|
166
134
|
export interface BakedIngressRuntime {
|
|
167
135
|
/** Emit an `x-request-id` header on responses. Default: false. */
|
|
168
136
|
emitRequestIdHeader?: boolean
|
|
@@ -274,6 +242,21 @@ export function createIngressHandler(
|
|
|
274
242
|
const limit = rateLimit?.limit
|
|
275
243
|
const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
|
|
276
244
|
|
|
245
|
+
// Minimal-processing flags: skip per-request JS UTF-8 encoding of the URL/IP
|
|
246
|
+
// when the native pipeline (rust/ingress/pipeline.rs) provably cannot read
|
|
247
|
+
// them — mirror its consumption exactly, and KEEP IN SYNC:
|
|
248
|
+
// - URL is consumed iff parseQuery (extract_query), emitMetadataJson
|
|
249
|
+
// (extract_path into the envelope) or https is unpinned (detect_https
|
|
250
|
+
// scans the scheme prefix). Otherwise an empty URL section is safe.
|
|
251
|
+
// - IP is consumed iff rate limiting is active (resolve_client_ip /
|
|
252
|
+
// rate_key) or a proxy-trust mode is configured (socket_is_trusted
|
|
253
|
+
// parses it). Otherwise the IP is dropped from the frame (logs/hooks
|
|
254
|
+
// still see the real `ip` — this only skips the frame encode).
|
|
255
|
+
const trustEnabled = options.trustProxy === true || options.trustedProxies?.enabled === true
|
|
256
|
+
const urlNeeded =
|
|
257
|
+
options.parseQuery === true || options.emitMetadataJson === true || options.https === undefined
|
|
258
|
+
const ipNeeded = rateEnabled || trustEnabled
|
|
259
|
+
|
|
277
260
|
// Shared with the fast path — proxy extraction is driven by trust config
|
|
278
261
|
// (trustProxy / trustedProxies), not by whether rate limiting is enabled.
|
|
279
262
|
const headerPlan: HeaderPlan = buildHeaderPlan(options)
|
|
@@ -315,11 +298,12 @@ export function createIngressHandler(
|
|
|
315
298
|
// the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
|
|
316
299
|
const inputPacker = new IngressInputPacker()
|
|
317
300
|
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
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 }
|
|
323
307
|
|
|
324
308
|
// FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
|
|
325
309
|
// non-panic UB (a segfault still crashes the process), but a RECURRING
|
|
@@ -382,219 +366,18 @@ export function createIngressHandler(
|
|
|
382
366
|
rateLimitStr,
|
|
383
367
|
})
|
|
384
368
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const needsOrigin =
|
|
398
|
-
((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
399
|
-
const needsRate = (variant & HV_RATE_ACTIVE) !== 0
|
|
400
|
-
const needsRetry = (variant & HV_RATE_LIMITED) !== 0
|
|
401
|
-
|
|
402
|
-
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
403
|
-
return template as unknown as [string, string][]
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
|
|
407
|
-
// Steady-state CORS: only the origin varies — cache the augmented array
|
|
408
|
-
// per (variant, origin) so the Origin-present case (the common
|
|
409
|
-
// browser/bench case) stays allocation-free after the first hit for EACH
|
|
410
|
-
// distinct origin (a single-slot memo thrashed when clients alternate
|
|
411
|
-
// between allowed origins, allocating a fresh array on every switch).
|
|
412
|
-
const key = `${variant & 31}\u0000${origin as string}`
|
|
413
|
-
const cached = originHeaderCache.get(key)
|
|
414
|
-
if (cached !== undefined) {
|
|
415
|
-
return cached
|
|
416
|
-
}
|
|
417
|
-
const entries = new Array<[string, string]>(template.length + 1)
|
|
418
|
-
let i = 0
|
|
419
|
-
for (const pair of template) {
|
|
420
|
-
entries[i++] = pair
|
|
421
|
-
}
|
|
422
|
-
entries[i] = ['access-control-allow-origin', origin as string]
|
|
423
|
-
originHeaderCache.set(key, entries)
|
|
424
|
-
return entries
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
let extra = 0
|
|
428
|
-
if (needsRequestId) extra++
|
|
429
|
-
if (needsOrigin) extra++
|
|
430
|
-
if (needsRate) extra += 2
|
|
431
|
-
if (needsRetry) extra++
|
|
432
|
-
|
|
433
|
-
const entries = new Array<[string, string]>(template.length + extra)
|
|
434
|
-
let i = 0
|
|
435
|
-
|
|
436
|
-
for (const pair of template) {
|
|
437
|
-
entries[i++] = pair
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (needsRequestId) {
|
|
441
|
-
entries[i++] = ['x-request-id', requestIdHeader as string]
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (needsOrigin) {
|
|
445
|
-
entries[i++] = ['access-control-allow-origin', origin as string]
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
if (needsRate) {
|
|
449
|
-
entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
|
|
450
|
-
entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (needsRetry) {
|
|
454
|
-
entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
return entries
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function terminalHeaders(
|
|
461
|
-
variant: number,
|
|
462
|
-
ctx: BakedContext,
|
|
463
|
-
result: BakedIngressResult | null,
|
|
464
|
-
): [string, string][] {
|
|
465
|
-
const v = variant | HV_JSON
|
|
466
|
-
const requestIdHeader = ctx.requestIdHeader
|
|
467
|
-
const origin = ctx.origin
|
|
468
|
-
const rateResetSecs =
|
|
469
|
-
result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
|
|
470
|
-
const retryAfterSecs =
|
|
471
|
-
result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
|
|
472
|
-
|
|
473
|
-
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
474
|
-
const needsOrigin =
|
|
475
|
-
((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
476
|
-
const needsRate = (v & HV_RATE_ACTIVE) !== 0
|
|
477
|
-
const needsRetry = (v & HV_RATE_LIMITED) !== 0
|
|
478
|
-
|
|
479
|
-
// Steady state: no request-id/origin/rate extras — serve the pre-baked
|
|
480
|
-
// terminal template (JSON + `cache-control: no-store`) directly with zero
|
|
481
|
-
// per-response array allocation/copy.
|
|
482
|
-
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
483
|
-
return terminalTemplates[v & 31] as unknown as [string, string][]
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Rare path: extras appended after the baked entries, then `cache-control`.
|
|
487
|
-
const base = responseHeaders(
|
|
488
|
-
v,
|
|
489
|
-
requestIdHeader,
|
|
490
|
-
origin,
|
|
491
|
-
result?.rateRemaining,
|
|
492
|
-
rateResetSecs,
|
|
493
|
-
retryAfterSecs,
|
|
494
|
-
)
|
|
495
|
-
|
|
496
|
-
const out = new Array<[string, string]>(base.length + 1)
|
|
497
|
-
let i = 0
|
|
498
|
-
for (const pair of base) {
|
|
499
|
-
out[i++] = pair
|
|
500
|
-
}
|
|
501
|
-
out[base.length] = ['cache-control', 'no-store']
|
|
502
|
-
return out
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function terminalResponse(
|
|
506
|
-
_req: Request | undefined,
|
|
507
|
-
result: BakedIngressResult,
|
|
508
|
-
ctx: BakedContext,
|
|
509
|
-
): Response | null {
|
|
510
|
-
if (!result.terminal) {
|
|
511
|
-
return null
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
515
|
-
|
|
516
|
-
if (preflightAllowed) {
|
|
517
|
-
return new Response(null, {
|
|
518
|
-
status: 204,
|
|
519
|
-
headers: responseHeaders(
|
|
520
|
-
result.headerVariant,
|
|
521
|
-
ctx.requestIdHeader,
|
|
522
|
-
ctx.origin,
|
|
523
|
-
result.rateRemaining,
|
|
524
|
-
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
525
|
-
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
526
|
-
),
|
|
527
|
-
})
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const status = safeTerminalStatus(result)
|
|
531
|
-
|
|
532
|
-
const body: Uint8Array =
|
|
533
|
-
result.errorCode === ERROR_CODE_RATE_LIMITED
|
|
534
|
-
? rateLimitedBody(result.retryAfterMs)
|
|
535
|
-
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
|
|
536
|
-
|
|
537
|
-
return new Response(body, {
|
|
538
|
-
status,
|
|
539
|
-
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
540
|
-
})
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function errorResponse(
|
|
544
|
-
_req: Request | undefined,
|
|
545
|
-
result: BakedIngressResult | null,
|
|
546
|
-
status: number,
|
|
547
|
-
code: string,
|
|
548
|
-
message: string,
|
|
549
|
-
ctx: BakedContext,
|
|
550
|
-
): Response {
|
|
551
|
-
const body =
|
|
552
|
-
ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
|
|
553
|
-
|
|
554
|
-
return new Response(body, {
|
|
555
|
-
status,
|
|
556
|
-
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
557
|
-
})
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
561
|
-
return new Response(ERROR_BODIES.internal, {
|
|
562
|
-
status: 500,
|
|
563
|
-
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
564
|
-
})
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function withContentType(
|
|
568
|
-
headers: ReadonlyArray<[string, string]>,
|
|
569
|
-
contentType: string,
|
|
570
|
-
): [string, string][] {
|
|
571
|
-
const out = new Array<[string, string]>(headers.length + 1)
|
|
572
|
-
let i = 0
|
|
573
|
-
for (const pair of headers) {
|
|
574
|
-
out[i++] = pair
|
|
575
|
-
}
|
|
576
|
-
out[headers.length] = ['content-type', contentType]
|
|
577
|
-
return out
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function zeroCopyResponse(
|
|
581
|
-
result: BakedIngressResult,
|
|
582
|
-
_ctx: BakedContext,
|
|
583
|
-
init: ResponseInit,
|
|
584
|
-
): Response {
|
|
585
|
-
if (currentHandle === null) {
|
|
586
|
-
// `zeroCopyResponse` must only run inside a live `run()` callback (a
|
|
587
|
-
// pooled output buffer must be in flight). Called outside `run()` the
|
|
588
|
-
// result has already been invalidated and `result.bodyJson` is empty —
|
|
589
|
-
// throw instead of silently serving an empty body.
|
|
590
|
-
throw new Error(
|
|
591
|
-
'zeroCopyResponse() can only be called from within a run() callback ' +
|
|
592
|
-
'(the pooled output buffer is only live during run()).',
|
|
593
|
-
)
|
|
594
|
-
}
|
|
595
|
-
responseBorrowsBuffer = true
|
|
596
|
-
return pooledBodyResponse(currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
|
|
597
|
-
}
|
|
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
|
+
})
|
|
598
381
|
|
|
599
382
|
const result = new BakedIngressResult()
|
|
600
383
|
const ctx: BakedContext = {
|
|
@@ -627,8 +410,8 @@ export function createIngressHandler(
|
|
|
627
410
|
// full_sync family paid on every request. The response wire format is
|
|
628
411
|
// unchanged because both entries run the identical `handle_packed` core.
|
|
629
412
|
let handle: PooledBuffer | null = null
|
|
630
|
-
currentHandle = null
|
|
631
|
-
responseBorrowsBuffer = false
|
|
413
|
+
responseState.currentHandle = null
|
|
414
|
+
responseState.responseBorrowsBuffer = false
|
|
632
415
|
|
|
633
416
|
try {
|
|
634
417
|
// Pack the request frame (inside the try: an unexpected packing failure
|
|
@@ -637,25 +420,67 @@ export function createIngressHandler(
|
|
|
637
420
|
// above, so pass it in to avoid a second `req.headers.get('origin')`
|
|
638
421
|
// native→JS string conversion on the hot path.
|
|
639
422
|
const packedHeaders = gatherRawHeadersPacked(req, headerPlan, methodKind, ctx.origin)
|
|
640
|
-
//
|
|
641
|
-
// `
|
|
642
|
-
//
|
|
643
|
-
|
|
423
|
+
// Minimal processing: when the native pipeline cannot read the URL/IP
|
|
424
|
+
// (see `urlNeeded`/`ipNeeded`), pass an empty URL / omit the IP so we
|
|
425
|
+
// skip the per-request JS UTF-8 encode entirely (the frame still carries
|
|
426
|
+
// a valid 0-length section the native bounds-checks).
|
|
427
|
+
const packUrl = urlNeeded ? req.url : ''
|
|
428
|
+
const packIp = ipNeeded ? ip : undefined
|
|
644
429
|
|
|
645
430
|
try {
|
|
646
431
|
// Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
|
|
647
432
|
// via setInternalError — never an uncaught exception out of run().
|
|
648
433
|
handle = outputPool.acquire(outputBufferSize)
|
|
649
|
-
currentHandle = handle
|
|
650
|
-
|
|
651
|
-
// Write into the pooled buffer — no per-request allocation.
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
434
|
+
responseState.currentHandle = handle
|
|
435
|
+
|
|
436
|
+
// Write into the pooled buffer — no per-request allocation. Bun
|
|
437
|
+
// PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
|
|
438
|
+
// passes `url`/`ip` as `bun:ffi` `cstring` args — the engine transcodes
|
|
439
|
+
// the JS strings to call-scoped UTF-8 in-engine, so we skip the JS
|
|
440
|
+
// frame assembly + `Buffer.write` encode for the URL/IP (same wire
|
|
441
|
+
// format: both paths run the shared `handle_components` core). Falls
|
|
442
|
+
// back to the packed frame (FFI or napi) on any failure / stale addon.
|
|
443
|
+
// If an ffi call throws (native reported 0 = error, or a panic
|
|
444
|
+
// `panic_guard` contained), re-dispatch through napi once and, after
|
|
445
|
+
// repeated failures, permanently disable ffi for this handler.
|
|
657
446
|
let written: number
|
|
658
|
-
if (
|
|
447
|
+
if (
|
|
448
|
+
bunFFI !== null &&
|
|
449
|
+
ingressPtr !== 0 &&
|
|
450
|
+
!ffiDisabled &&
|
|
451
|
+
typeof bunFFI.ingressHandleComponents === 'function'
|
|
452
|
+
) {
|
|
453
|
+
try {
|
|
454
|
+
written = bunFFI.ingressHandleComponents(
|
|
455
|
+
ingressPtr,
|
|
456
|
+
methodKind,
|
|
457
|
+
packUrl,
|
|
458
|
+
packIp ?? '',
|
|
459
|
+
ridBytes,
|
|
460
|
+
packedHeaders,
|
|
461
|
+
body,
|
|
462
|
+
handle.buffer,
|
|
463
|
+
)
|
|
464
|
+
ffiFailures = 0
|
|
465
|
+
} catch {
|
|
466
|
+
ffiFailures++
|
|
467
|
+
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
468
|
+
ffiDisabled = true
|
|
469
|
+
}
|
|
470
|
+
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
471
|
+
// identical — a transient ffi panic still serves the request. The
|
|
472
|
+
// packed frame respects the same url/ip minimal-processing.
|
|
473
|
+
const input = inputPacker.packParts(
|
|
474
|
+
methodKind,
|
|
475
|
+
packUrl,
|
|
476
|
+
packIp,
|
|
477
|
+
ridBytes,
|
|
478
|
+
packedHeaders,
|
|
479
|
+
)
|
|
480
|
+
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
481
|
+
}
|
|
482
|
+
} else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
|
|
483
|
+
const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
|
|
659
484
|
try {
|
|
660
485
|
written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
|
|
661
486
|
ffiFailures = 0
|
|
@@ -669,6 +494,7 @@ export function createIngressHandler(
|
|
|
669
494
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
670
495
|
}
|
|
671
496
|
} else {
|
|
497
|
+
const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
|
|
672
498
|
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
673
499
|
}
|
|
674
500
|
|
|
@@ -721,14 +547,14 @@ export function createIngressHandler(
|
|
|
721
547
|
// request accounting completes (metrics in_flight must not leak), then
|
|
722
548
|
// rethrow for the caller. The native-failure path above does NOT rethrow,
|
|
723
549
|
// so this fires at most once per request.
|
|
724
|
-
if (responseBorrowsBuffer && handle !== null) {
|
|
550
|
+
if (responseState.responseBorrowsBuffer && handle !== null) {
|
|
725
551
|
// `zeroCopyResponse` was called but the Response never escaped (the
|
|
726
552
|
// callback threw): the stream that would release the pooled buffer was
|
|
727
553
|
// never delivered. Release it here so the buffer isn't stuck in flight
|
|
728
554
|
// forever.
|
|
729
555
|
handle.release()
|
|
730
556
|
handle = null
|
|
731
|
-
responseBorrowsBuffer = false
|
|
557
|
+
responseState.responseBorrowsBuffer = false
|
|
732
558
|
}
|
|
733
559
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
734
560
|
runtime.onError?.(req, requestIdStr, error)
|
|
@@ -744,21 +570,26 @@ export function createIngressHandler(
|
|
|
744
570
|
// A zero-copy Response owns the handle until its body is consumed; in
|
|
745
571
|
// every other case (copy mode, terminal/error responses) the buffer is
|
|
746
572
|
// safe to return to the pool immediately.
|
|
747
|
-
if (handle !== null && !responseBorrowsBuffer) {
|
|
573
|
+
if (handle !== null && !responseState.responseBorrowsBuffer) {
|
|
748
574
|
handle.release()
|
|
749
575
|
}
|
|
750
|
-
currentHandle = null
|
|
576
|
+
responseState.currentHandle = null
|
|
751
577
|
}
|
|
752
578
|
}
|
|
753
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
|
+
|
|
754
590
|
return {
|
|
755
591
|
run,
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
terminalResponse,
|
|
759
|
-
errorResponse,
|
|
760
|
-
internalErrorResponse,
|
|
761
|
-
withContentType,
|
|
762
|
-
zeroCopyResponse,
|
|
592
|
+
...responseBuilders,
|
|
593
|
+
jsonValid: nativeJsonValid,
|
|
763
594
|
}
|
|
764
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
|
+
}
|
package/src/ingress/health.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// The readiness probe runs the optional `check` (deps up? -> 200/503).
|
|
16
16
|
// The health probe is an alias of liveness (process up) plus optional check.
|
|
17
17
|
|
|
18
|
+
/** Outcome of a readiness check: HTTP status + plain-text body. */
|
|
18
19
|
export interface ReadinessResult {
|
|
19
20
|
status: number
|
|
20
21
|
body: string
|