castrum 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -368
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +2763 -0
- package/dist/index.js +8707 -0
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +507 -0
- package/docs/BENCHMARKS.md +343 -0
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +81 -0
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +192 -0
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +746 -0
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +279 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +43 -0
- package/docs/bun-builtins-decision-matrix.md +115 -0
- package/index.ts +44 -8
- package/package.json +98 -33
- package/src/ingress/body.ts +289 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +639 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +68 -0
- package/src/ingress/index.ts +142 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +139 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +164 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +102 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +79 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +27 -0
- package/src/ingress/routes/index.ts +11 -0
- package/src/ingress/routes/json-write.ts +129 -0
- package/src/ingress/routes/options.ts +26 -0
- package/src/ingress/routes/read.ts +35 -0
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +475 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +191 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +220 -0
- package/src/integration/streaming.ts +78 -0
- package/src/integration/websocket.ts +93 -0
- package/src/loader/batch.ts +137 -0
- package/src/loader/cost.ts +105 -0
- package/src/loader/index.ts +745 -0
- package/src/loader/ops.ts +657 -0
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1775 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +240 -0
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +401 -0
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +87 -0
- package/src/rust-ffi/context.ts +193 -0
- package/src/rust-ffi/index.ts +51 -541
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +28 -0
- package/src/rust-ffi/scalar/crypto.ts +346 -0
- package/src/rust-ffi/scalar/factories.ts +459 -0
- package/src/rust-ffi/scalar/hashing.ts +74 -0
- package/src/rust-ffi/scalar/http.ts +337 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +325 -0
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +61 -0
- package/src/rust-ffi/scalar/payload.ts +241 -0
- package/src/rust-ffi/text.ts +121 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +73 -0
- package/src/shared/adaptive.ts +67 -0
- package/src/shared/buffer-pool.ts +264 -0
- package/src/shared/bytes.ts +108 -9
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +75 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/packed/wire.ts +504 -0
- package/src/shared/proven.ts +289 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +29 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +15 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/data/json-rows.ts +0 -33
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
- package/src/shared/packed.ts +0 -316
package/src/ingress/handlers.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/ingress/handlers.ts — Pre-baked ingress handler functions
|
|
2
2
|
//
|
|
3
3
|
// A ready-to-use convenience layer for consuming the optimized ingress pipeline
|
|
4
|
-
// (native Ingress.
|
|
4
|
+
// (native Ingress.handleRequestPacked) with zero boilerplate. Any system can
|
|
5
5
|
// wire up ingress in a few lines:
|
|
6
6
|
//
|
|
7
7
|
// import {
|
|
@@ -27,443 +27,378 @@
|
|
|
27
27
|
// This module deliberately keeps the benchmark's wire format ({"ok":false,
|
|
28
28
|
// "error":{...}} bodies, `ratelimit-*` headers). It does NOT reuse fast.ts's
|
|
29
29
|
// response builders because those emit a different payload shape.
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
//
|
|
31
|
+
// The pre-baked pipeline is decomposed into task-focused submodules:
|
|
32
|
+
// - decode/baked-result.ts BakedIngressResult (zero-alloc decode)
|
|
33
|
+
// - response/error-bodies.ts pre-encoded error bodies
|
|
34
|
+
// - headers/baked-templates.ts ratelimit-* header templates
|
|
35
|
+
// - packing/gather-raw-headers.ts raw header gathering
|
|
36
|
+
// - routes/* route factories
|
|
37
|
+
// - server.ts Bun.serve builder
|
|
38
|
+
|
|
39
|
+
import { getAddon } from '../native'
|
|
40
|
+
import { getBunFFI } from '../native/ffi'
|
|
41
|
+
import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
|
|
42
|
+
import { decoder, encoder, viewForArrayBuffer } from '../shared/bytes'
|
|
43
|
+
import { createStructuredLogger } from '../shared/log'
|
|
44
|
+
import { generateRequestId } from '../shared/request-id'
|
|
45
|
+
import { pooledBodyResponse } from '../shared/response'
|
|
32
46
|
import {
|
|
33
|
-
|
|
34
|
-
OUT_FLAGS,
|
|
35
|
-
OUT_RATE_LIMIT,
|
|
36
|
-
OUT_RATE_REMAINING,
|
|
37
|
-
OUT_RATE_RESET,
|
|
38
|
-
OUT_RETRY_AFTER,
|
|
39
|
-
OUT_COOKIES_JSON_LEN,
|
|
40
|
-
OUT_QUERY_JSON_LEN,
|
|
41
|
-
OUT_HEADER_VARIANT,
|
|
42
|
-
OUT_BODY_JSON_LEN,
|
|
43
|
-
OUT_DATA_START,
|
|
44
|
-
FLAG_BODY_VALID_JSON,
|
|
45
|
-
FLAG_SCHEMA_VALID,
|
|
46
|
-
FLAG_CORS_ALLOWED,
|
|
47
|
-
FLAG_IS_PREFLIGHT,
|
|
48
|
-
FLAG_RATE_LIMITED,
|
|
49
|
-
FLAG_TRUSTED_PROXY,
|
|
50
|
-
FLAG_BODY_TRUNCATED,
|
|
51
|
-
HV_JSON,
|
|
52
|
-
HV_CORS_SIMPLE,
|
|
47
|
+
ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
|
|
53
48
|
HV_CORS_PREFLIGHT,
|
|
49
|
+
HV_CORS_SIMPLE,
|
|
50
|
+
HV_JSON,
|
|
54
51
|
HV_RATE_ACTIVE,
|
|
55
52
|
HV_RATE_LIMITED,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
ERR_CODE_REQUEST_TOO_LARGE as ERROR_CODE_REQUEST_TOO_LARGE,
|
|
63
|
-
ERR_CODE_INTERNAL as ERROR_CODE_INTERNAL,
|
|
64
|
-
} from "./constants";
|
|
53
|
+
OUT_DATA_START,
|
|
54
|
+
} from './constants'
|
|
55
|
+
import { BakedIngressResult } from './decode/baked-result'
|
|
56
|
+
import { buildBakedHeaderTemplates } from './headers/baked-templates'
|
|
57
|
+
import type { SecurityHeadersOptions } from './headers/hsts'
|
|
58
|
+
import { buildSecurityPairs } from './headers/hsts'
|
|
65
59
|
import {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
assertIngressOptionValues,
|
|
61
|
+
assertKnownIngressOptions,
|
|
62
|
+
type IngressHandlerOptions,
|
|
63
|
+
warnTrustProxyDeprecated,
|
|
64
|
+
} from './options'
|
|
65
|
+
import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
|
|
66
|
+
import { IngressInputPacker } from './packing/input-packer'
|
|
67
|
+
import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './response/error-bodies'
|
|
68
|
+
import {
|
|
69
|
+
assertSyncCallback,
|
|
70
|
+
buildHeaderPlan,
|
|
71
|
+
clampIngressBufferSize,
|
|
72
|
+
DEFAULT_BAKED_OUTPUT_BUFFER_SIZE,
|
|
70
73
|
type HeaderPlan,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
METHOD_KIND,
|
|
75
|
+
METHOD_KIND_UNKNOWN,
|
|
76
|
+
secondsFromMs,
|
|
77
|
+
} from './shared'
|
|
78
|
+
import { safeTerminalStatus } from './status'
|
|
79
|
+
import type { BakedContext, OptimizedIngressHandler } from './types'
|
|
80
|
+
|
|
81
|
+
// ── Re-exports (back-compat: preserve handlers.ts's original public surface) ──
|
|
82
|
+
export { BakedIngressResult } from './decode/baked-result'
|
|
83
|
+
export type { IngressHandlerOptions } from './options'
|
|
84
|
+
export { ERROR_BODIES } from './response/error-bodies'
|
|
85
|
+
export type { BakedHandlerOptions } from './routes'
|
|
86
|
+
export {
|
|
87
|
+
deleteHandler,
|
|
88
|
+
echoHandler,
|
|
89
|
+
fallbackHandler,
|
|
90
|
+
headHandler,
|
|
91
|
+
jsonWriteHandler,
|
|
92
|
+
nativeResponderRoute,
|
|
93
|
+
optionsHandler,
|
|
94
|
+
readHandler,
|
|
95
|
+
} from './routes'
|
|
96
|
+
export type {
|
|
97
|
+
BakedRoute,
|
|
98
|
+
BakedServer,
|
|
99
|
+
CreateIngressServerOptions,
|
|
100
|
+
GracefulShutdownOptions,
|
|
101
|
+
ServerHandle,
|
|
102
|
+
} from './server'
|
|
103
|
+
export { createIngressServer, gracefulShutdown } from './server'
|
|
104
|
+
export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
|
|
105
|
+
// Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
|
|
106
|
+
export { createIngressServerNode } from './server-node'
|
|
107
|
+
|
|
108
|
+
const EMPTY_BODY = new Uint8Array(0)
|
|
109
|
+
const EMPTY_IP = '0.0.0.0'
|
|
77
110
|
|
|
78
111
|
/** Sentinel value used by Rust to mean "rate limiting disabled". */
|
|
79
|
-
export const RATE_LIMIT_U32_MAX = 4_294_967_295
|
|
80
|
-
|
|
81
|
-
const DEFAULT_OUTPUT_BUF_SIZE = 131_072;
|
|
82
|
-
|
|
83
|
-
const MAX_COOKIE_HEADER_BYTES = 8192;
|
|
84
|
-
const MAX_SMALL_HEADER_BYTES = 2048;
|
|
85
|
-
const MAX_XFF_HEADER_BYTES = 8192;
|
|
112
|
+
export const RATE_LIMIT_U32_MAX = 4_294_967_295
|
|
86
113
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
export const ERROR_BODIES: Record<string, Uint8Array> = {
|
|
95
|
-
not_found: staticErrorBody("not_found", "Not found"),
|
|
96
|
-
unsupported_media_type: staticErrorBody(
|
|
97
|
-
"unsupported_media_type",
|
|
98
|
-
"Content-Type must be application/json",
|
|
99
|
-
),
|
|
100
|
-
body_too_large: staticErrorBody("body_too_large", "Request body is too large"),
|
|
101
|
-
invalid_json: staticErrorBody("invalid_json", "Invalid JSON body"),
|
|
102
|
-
schema_validation_failed: staticErrorBody(
|
|
103
|
-
"schema_validation_failed",
|
|
104
|
-
"Request body failed schema validation",
|
|
105
|
-
),
|
|
106
|
-
cors_preflight_not_allowed: staticErrorBody(
|
|
107
|
-
"cors_preflight_not_allowed",
|
|
108
|
-
"CORS preflight not allowed",
|
|
109
|
-
),
|
|
110
|
-
bad_request: staticErrorBody("bad_request", "Bad request"),
|
|
111
|
-
request_too_large: staticErrorBody("request_too_large", "Request too large"),
|
|
112
|
-
rate_limited: staticErrorBody("rate_limited", "Too Many Requests"),
|
|
113
|
-
internal: staticErrorBody("internal_error", "Internal server error"),
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const ERROR_CODE_BODIES: (Uint8Array | undefined)[] = [];
|
|
117
|
-
ERROR_CODE_BODIES[ERROR_CODE_CORS_PREFLIGHT] =
|
|
118
|
-
ERROR_BODIES.cors_preflight_not_allowed;
|
|
119
|
-
ERROR_CODE_BODIES[ERROR_CODE_RATE_LIMITED] = ERROR_BODIES.rate_limited;
|
|
120
|
-
ERROR_CODE_BODIES[ERROR_CODE_BODY_TOO_LARGE] = ERROR_BODIES.body_too_large;
|
|
121
|
-
ERROR_CODE_BODIES[ERROR_CODE_INVALID_JSON] = ERROR_BODIES.invalid_json;
|
|
122
|
-
ERROR_CODE_BODIES[ERROR_CODE_SCHEMA_VALIDATION] =
|
|
123
|
-
ERROR_BODIES.schema_validation_failed;
|
|
124
|
-
ERROR_CODE_BODIES[ERROR_CODE_BAD_REQUEST] = ERROR_BODIES.bad_request;
|
|
125
|
-
ERROR_CODE_BODIES[ERROR_CODE_REQUEST_TOO_LARGE] =
|
|
126
|
-
ERROR_BODIES.request_too_large;
|
|
127
|
-
ERROR_CODE_BODIES[ERROR_CODE_INTERNAL] = ERROR_BODIES.internal;
|
|
128
|
-
|
|
129
|
-
const RATE_LIMIT_BODY_PREFIX = encoder.encode(
|
|
130
|
-
'{"ok":false,"error":{"code":"rate_limited","message":"Too Many Requests","retry_after_ms":',
|
|
131
|
-
);
|
|
132
|
-
const RATE_LIMIT_BODY_SUFFIX = encoder.encode("}}");
|
|
133
|
-
|
|
134
|
-
function rateLimitedBody(retryAfterMs: number): Uint8Array {
|
|
135
|
-
const digits = encoder.encode(String(Math.max(0, Math.floor(retryAfterMs))));
|
|
136
|
-
const out = new Uint8Array(
|
|
137
|
-
RATE_LIMIT_BODY_PREFIX.byteLength +
|
|
138
|
-
digits.byteLength +
|
|
139
|
-
RATE_LIMIT_BODY_SUFFIX.byteLength,
|
|
140
|
-
);
|
|
141
|
-
out.set(RATE_LIMIT_BODY_PREFIX, 0);
|
|
142
|
-
out.set(digits, RATE_LIMIT_BODY_PREFIX.byteLength);
|
|
143
|
-
out.set(
|
|
144
|
-
RATE_LIMIT_BODY_SUFFIX,
|
|
145
|
-
RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength,
|
|
146
|
-
);
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── Zero-alloc result wrapper ──
|
|
151
|
-
export class BakedIngressResult {
|
|
152
|
-
status = 0;
|
|
153
|
-
verdict = 0;
|
|
154
|
-
errorCode = 0;
|
|
155
|
-
headerVariant = 0;
|
|
156
|
-
rateRemaining = 0;
|
|
157
|
-
rateResetMs = 0;
|
|
158
|
-
retryAfterMs = 0;
|
|
159
|
-
terminal = true;
|
|
160
|
-
ok = false;
|
|
161
|
-
isPreflight = false;
|
|
162
|
-
corsAllowed = false;
|
|
163
|
-
rateLimited = false;
|
|
164
|
-
trustedProxy = false;
|
|
165
|
-
bodyValidJson = false;
|
|
166
|
-
schemaValid = false;
|
|
167
|
-
bodyTruncated = false;
|
|
168
|
-
body: Uint8Array = EMPTY_BODY;
|
|
169
|
-
|
|
170
|
-
private _buf: Uint8Array = EMPTY_BODY;
|
|
171
|
-
private _bodyJsonStart = OUT_DATA_START;
|
|
172
|
-
private _bodyJsonLen = 0;
|
|
173
|
-
|
|
174
|
-
refresh(buf: Uint8Array, body: Uint8Array, view: DataView): void {
|
|
175
|
-
this._buf = buf;
|
|
176
|
-
this.body = body;
|
|
177
|
-
|
|
178
|
-
const h0 = view.getUint32(OUT_VERDICT, true);
|
|
179
|
-
const h1 = view.getUint32(OUT_FLAGS, true);
|
|
180
|
-
const h2 = view.getUint32(OUT_RATE_LIMIT, true);
|
|
181
|
-
const h3 = view.getUint32(OUT_RATE_REMAINING, true);
|
|
182
|
-
|
|
183
|
-
const cookiesLenRaw = view.getUint32(OUT_COOKIES_JSON_LEN, true);
|
|
184
|
-
const queryLenRaw = view.getUint32(OUT_QUERY_JSON_LEN, true);
|
|
185
|
-
const headerVariant = view.getUint8(OUT_HEADER_VARIANT);
|
|
186
|
-
const bodyJsonLenRaw = view.getUint32(OUT_BODY_JSON_LEN, true);
|
|
187
|
-
|
|
188
|
-
const safeCookiesLen =
|
|
189
|
-
OUT_DATA_START + cookiesLenRaw <= buf.byteLength ? cookiesLenRaw : 0;
|
|
190
|
-
const queryStart = OUT_DATA_START + safeCookiesLen;
|
|
191
|
-
const safeQueryLen =
|
|
192
|
-
queryStart + queryLenRaw <= buf.byteLength ? queryLenRaw : 0;
|
|
193
|
-
const bodyJsonStart = queryStart + safeQueryLen;
|
|
194
|
-
const safeBodyJsonLen =
|
|
195
|
-
bodyJsonStart + bodyJsonLenRaw <= buf.byteLength ? bodyJsonLenRaw : 0;
|
|
196
|
-
|
|
197
|
-
if (h0 === 0 && h1 === 0) {
|
|
198
|
-
this.verdict = 1;
|
|
199
|
-
this.errorCode = ERROR_CODE_INTERNAL;
|
|
200
|
-
this.status = 500;
|
|
201
|
-
} else {
|
|
202
|
-
this.verdict = h0 & 0xff;
|
|
203
|
-
this.errorCode = (h0 >>> 8) & 0xff;
|
|
204
|
-
|
|
205
|
-
const rawStatus = (h0 >>> 16) & 0xffff;
|
|
206
|
-
const validStatus =
|
|
207
|
-
rawStatus === 101 || (rawStatus >= 200 && rawStatus <= 599);
|
|
208
|
-
this.status = validStatus ? rawStatus : 500;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const flags = h1;
|
|
212
|
-
|
|
213
|
-
this.rateRemaining = h3;
|
|
214
|
-
|
|
215
|
-
if (h2 > 0 || (flags & FLAG_RATE_LIMITED) !== 0) {
|
|
216
|
-
this.rateResetMs = Number(view.getBigUint64(OUT_RATE_RESET, true));
|
|
217
|
-
this.retryAfterMs = Number(view.getBigUint64(OUT_RETRY_AFTER, true));
|
|
218
|
-
} else {
|
|
219
|
-
this.rateResetMs = 0;
|
|
220
|
-
this.retryAfterMs = 0;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
this.headerVariant = headerVariant;
|
|
224
|
-
this._bodyJsonStart = bodyJsonStart;
|
|
225
|
-
this._bodyJsonLen = safeBodyJsonLen;
|
|
226
|
-
|
|
227
|
-
this.terminal = this.verdict !== 0 || this.status >= 400;
|
|
228
|
-
this.ok = this.verdict === 0 && this.status >= 200 && this.status < 400;
|
|
229
|
-
|
|
230
|
-
this.isPreflight = (flags & FLAG_IS_PREFLIGHT) !== 0;
|
|
231
|
-
this.corsAllowed = (flags & FLAG_CORS_ALLOWED) !== 0;
|
|
232
|
-
this.rateLimited = (flags & FLAG_RATE_LIMITED) !== 0;
|
|
233
|
-
this.trustedProxy = (flags & FLAG_TRUSTED_PROXY) !== 0;
|
|
234
|
-
this.bodyValidJson = (flags & FLAG_BODY_VALID_JSON) !== 0;
|
|
235
|
-
this.schemaValid = (flags & FLAG_SCHEMA_VALID) !== 0;
|
|
236
|
-
|
|
237
|
-
this.bodyTruncated =
|
|
238
|
-
(flags & FLAG_BODY_TRUNCATED) !== 0 ||
|
|
239
|
-
safeCookiesLen !== cookiesLenRaw ||
|
|
240
|
-
safeQueryLen !== queryLenRaw ||
|
|
241
|
-
safeBodyJsonLen !== bodyJsonLenRaw;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
invalidate(): void {
|
|
245
|
-
this.status = 0;
|
|
246
|
-
this.verdict = 0;
|
|
247
|
-
this.errorCode = 0;
|
|
248
|
-
this.headerVariant = 0;
|
|
249
|
-
this.rateRemaining = 0;
|
|
250
|
-
this.rateResetMs = 0;
|
|
251
|
-
this.retryAfterMs = 0;
|
|
252
|
-
this.terminal = true;
|
|
253
|
-
this.ok = false;
|
|
254
|
-
this.isPreflight = false;
|
|
255
|
-
this.corsAllowed = false;
|
|
256
|
-
this.rateLimited = false;
|
|
257
|
-
this.trustedProxy = false;
|
|
258
|
-
this.bodyValidJson = false;
|
|
259
|
-
this.schemaValid = false;
|
|
260
|
-
this.bodyTruncated = false;
|
|
261
|
-
this.body = EMPTY_BODY;
|
|
262
|
-
this._buf = EMPTY_BODY;
|
|
263
|
-
this._bodyJsonStart = OUT_DATA_START;
|
|
264
|
-
this._bodyJsonLen = 0;
|
|
265
|
-
}
|
|
114
|
+
/**
|
|
115
|
+
* Consecutive `bun:ffi` ingress failures before this handler permanently
|
|
116
|
+
* falls back to the napi addon (a panic that `panic_guard` contained, or a
|
|
117
|
+
* bun:ffi regression that throws). Low enough to recover fast from a flaky
|
|
118
|
+
* transport, high enough that a single transient failure never flips it.
|
|
119
|
+
*/
|
|
120
|
+
const MAX_FFI_FAILURES = 3
|
|
266
121
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Best-effort URL pathname extraction for log/error lines.
|
|
124
|
+
*
|
|
125
|
+
* This runs inside catch/finally paths, so a malformed `req.url` must never
|
|
126
|
+
* throw out of the handler (an uncaught error here would bypass `onError`).
|
|
127
|
+
* Falls back to the raw URL string when parsing fails.
|
|
128
|
+
*/
|
|
129
|
+
function pathForLog(req: Request): string {
|
|
130
|
+
try {
|
|
131
|
+
return new URL(req.url).pathname
|
|
132
|
+
} catch {
|
|
133
|
+
return req.url
|
|
275
134
|
}
|
|
135
|
+
}
|
|
276
136
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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()]
|
|
290
161
|
}
|
|
291
162
|
|
|
292
163
|
// ── Types ────────────────────────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
164
|
+
// `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
|
|
165
|
+
// (shared with the route factories) and re-exported here for back-compat.
|
|
166
|
+
export type { BakedContext, OptimizedIngressHandler } from './types'
|
|
297
167
|
|
|
168
|
+
/** Runtime configuration for the pre-baked handler path (`createIngressHandler`). */
|
|
298
169
|
export interface BakedIngressRuntime {
|
|
299
170
|
/** Emit an `x-request-id` header on responses. Default: false. */
|
|
300
|
-
emitRequestIdHeader?: boolean
|
|
171
|
+
emitRequestIdHeader?: boolean
|
|
301
172
|
/** Enable security headers on responses. Default: true. */
|
|
302
|
-
enableSecurityHeaders?: boolean
|
|
303
|
-
/**
|
|
304
|
-
|
|
173
|
+
enableSecurityHeaders?: boolean
|
|
174
|
+
/**
|
|
175
|
+
* Ordered `[name, value]` security headers (names are lowercased).
|
|
176
|
+
* NOTE: this PRE-BAKED path takes RAW `[name, value]` pairs here; the
|
|
177
|
+
* fast/async paths (`createIngressFast`/`createIngress`) instead take a
|
|
178
|
+
* structured `security: SecurityHeadersOptions` object (CSP/HSTS builders).
|
|
179
|
+
* The two paths intentionally use different option shapes (see
|
|
180
|
+
* docs/INGRESS.md).
|
|
181
|
+
*/
|
|
182
|
+
securityHeaders?: ReadonlyArray<[string, string]>
|
|
305
183
|
/** Native output buffer size in bytes. Default: 131072. */
|
|
306
|
-
outputBufferSize?: number
|
|
184
|
+
outputBufferSize?: number
|
|
185
|
+
/** Invoked before a request is processed (for tracing/context hooks). */
|
|
186
|
+
onRequest?: (req: Request, requestId: string, ip: string | undefined) => void
|
|
307
187
|
/** Invoked after a Response is produced (for metrics/logging hooks). */
|
|
308
|
-
onResponse?: (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
) => void
|
|
188
|
+
onResponse?: (req: Request, result: BakedIngressResult, status: number, requestId: string) => void
|
|
189
|
+
/**
|
|
190
|
+
* Invoked when the native pipeline fails (the request becomes a 500).
|
|
191
|
+
* Native failures are otherwise silent — wire this to your error tracker.
|
|
192
|
+
*/
|
|
193
|
+
onError?: (req: Request, requestId: string, error: Error) => void
|
|
194
|
+
/**
|
|
195
|
+
* Emit one structured JSON line per request/error via the built-in logger
|
|
196
|
+
* (gated by `CASTRUM_LOG_LEVEL`). Default: false.
|
|
197
|
+
*/
|
|
198
|
+
structuredLog?: boolean
|
|
199
|
+
/**
|
|
200
|
+
* Custom structured logger (e.g. with a custom stream or extra fields).
|
|
201
|
+
* When provided, request/error lines go here instead of the built-in stderr
|
|
202
|
+
* logger and `structuredLog` is implicitly enabled.
|
|
203
|
+
*/
|
|
204
|
+
logger?: ReturnType<typeof createStructuredLogger>
|
|
205
|
+
/**
|
|
206
|
+
* Hard cap on concurrently borrowed output buffers (zero-copy responses not
|
|
207
|
+
* yet consumed). Bounds memory when zero-copy responses are consumed
|
|
208
|
+
* slowly; when exceeded, requests fail with a 500 (pool exhaustion) instead
|
|
209
|
+
* of allocating unbounded temporaries. 0 (default) is unlimited — see
|
|
210
|
+
* `BufferPool.maxInFlight`.
|
|
211
|
+
*/
|
|
212
|
+
maxInFlight?: number
|
|
213
|
+
/**
|
|
214
|
+
* Abandonment guard (ms) for zero-copy responses: if a response body is
|
|
215
|
+
* neither pulled nor cancelled within this window, its pooled buffer is
|
|
216
|
+
* released and the stream closed. Bounds memory under abandoned (unread)
|
|
217
|
+
* zero-copy responses. 0 (default) disables it — see `pooledBodyResponse`.
|
|
218
|
+
*/
|
|
219
|
+
zeroCopyTimeoutMs?: number
|
|
314
220
|
}
|
|
315
221
|
|
|
222
|
+
// ── Optimized ingress factory ─────────────────────────────────────
|
|
223
|
+
|
|
316
224
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
225
|
+
* Create a pre-baked ingress handler (path 2).
|
|
226
|
+
*
|
|
227
|
+
* The request frame (url/ip/rid/headers) is packed in JS via `IngressInputPacker`
|
|
228
|
+
* + `gatherRawHeadersPacked` and driven through the same native core as the
|
|
229
|
+
* fast path (`Ingress.handleRequestPacked`), writing into a pooled output
|
|
230
|
+
* buffer (no per-request allocation). Responses use the benchmark wire format
|
|
231
|
+
* (`{"ok":false,"error":{...}}` + `ratelimit-*` headers) — see AGENTS.md for
|
|
232
|
+
* why this differs from `createIngressFast`.
|
|
233
|
+
*
|
|
234
|
+
* Returns an `OptimizedIngressHandler` whose response-builder methods are
|
|
235
|
+
* bound to this handler's configuration. Pair it with the route factories
|
|
236
|
+
* (`readHandler`, `jsonWriteHandler`, ...) or `createIngressServer`.
|
|
321
237
|
*/
|
|
322
|
-
export interface OptimizedIngressHandler {
|
|
323
|
-
run<T>(
|
|
324
|
-
req: Request,
|
|
325
|
-
ip: string | undefined,
|
|
326
|
-
body: Uint8Array | null,
|
|
327
|
-
fn: (result: BakedIngressResult, ctx: BakedContext) => T,
|
|
328
|
-
): T;
|
|
329
|
-
|
|
330
|
-
responseHeaders(
|
|
331
|
-
variant: number,
|
|
332
|
-
requestIdHeader: string | null,
|
|
333
|
-
origin: string | null,
|
|
334
|
-
rateRemaining?: number,
|
|
335
|
-
rateResetSecs?: number,
|
|
336
|
-
retryAfterSecs?: number,
|
|
337
|
-
): [string, string][];
|
|
338
|
-
|
|
339
|
-
terminalHeaders(
|
|
340
|
-
variant: number,
|
|
341
|
-
ctx: BakedContext,
|
|
342
|
-
result: BakedIngressResult | null,
|
|
343
|
-
): [string, string][];
|
|
344
|
-
|
|
345
|
-
terminalResponse(
|
|
346
|
-
req: Request,
|
|
347
|
-
result: BakedIngressResult,
|
|
348
|
-
ctx: BakedContext,
|
|
349
|
-
): Response | null;
|
|
350
|
-
|
|
351
|
-
errorResponse(
|
|
352
|
-
req: Request,
|
|
353
|
-
result: BakedIngressResult | null,
|
|
354
|
-
status: number,
|
|
355
|
-
code: string,
|
|
356
|
-
message: string,
|
|
357
|
-
ctx: BakedContext,
|
|
358
|
-
): Response;
|
|
359
|
-
|
|
360
|
-
internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response;
|
|
361
|
-
|
|
362
|
-
withContentType(
|
|
363
|
-
headers: ReadonlyArray<[string, string]>,
|
|
364
|
-
contentType: string,
|
|
365
|
-
): [string, string][];
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// ── Optimized ingress factory ─────────────────────────────────────
|
|
369
238
|
export function createIngressHandler(
|
|
370
|
-
options:
|
|
239
|
+
options: IngressHandlerOptions = {},
|
|
371
240
|
runtime: BakedIngressRuntime = {},
|
|
372
241
|
): OptimizedIngressHandler {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
242
|
+
// Fail fast on a misspelled option key — same guard as the fast path.
|
|
243
|
+
assertKnownIngressOptions(options, 'createIngressHandler')
|
|
244
|
+
assertIngressOptionValues(options, 'createIngressHandler')
|
|
245
|
+
if (options.trustProxy === true) {
|
|
246
|
+
warnTrustProxyDeprecated()
|
|
376
247
|
}
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
);
|
|
248
|
+
// Lazy: the native addon is only needed once a handler is created.
|
|
249
|
+
const addon = getAddon()
|
|
250
|
+
const NativeIngress = addon.Ingress
|
|
251
|
+
if (typeof NativeIngress !== 'function') {
|
|
252
|
+
throw new Error('Native Ingress class missing. Rebuild the Rust addon.')
|
|
383
253
|
}
|
|
384
254
|
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
typeof limit === "number" && limit !== RATE_LIMIT_U32_MAX && limit > 0;
|
|
390
|
-
|
|
391
|
-
const headerPlan: HeaderPlan = {
|
|
392
|
-
cookie: options.parseCookies === true,
|
|
393
|
-
cors: options.cors != null,
|
|
394
|
-
proxy: trust && rateEnabled,
|
|
395
|
-
proto: trust && options.https === undefined,
|
|
396
|
-
};
|
|
255
|
+
const handler = new NativeIngress(options)
|
|
256
|
+
if (typeof handler.handleRequestPacked !== 'function') {
|
|
257
|
+
throw new Error('Native Ingress.handleRequestPacked missing. Rebuild the Rust addon.')
|
|
258
|
+
}
|
|
397
259
|
|
|
398
|
-
|
|
399
|
-
|
|
260
|
+
// Bun fast path: drive the SAME native pipeline through `bun:ffi` (opaque
|
|
261
|
+
// inner handle from `ingressInnerPtr`), cutting the per-request N-API
|
|
262
|
+
// crossing. The handle is valid only while `handler` is alive — this closure
|
|
263
|
+
// holds `handler` for the handler's lifetime, so it can never dangle. Falls
|
|
264
|
+
// back to napi when ffi is unavailable, the addon lacks the pointer method,
|
|
265
|
+
// or the handle is 0 (i.e. the state was dropped).
|
|
266
|
+
const bunFFI = getBunFFI()
|
|
267
|
+
// The opaque handle is a u64 under the 2^53 number range, so convert it to a
|
|
268
|
+
// plain JS number ONCE here — bun:ffi converts a BigInt argument to a number
|
|
269
|
+
// on every call anyway (and docs note BigInt is slower), so passing a number
|
|
270
|
+
// removes that per-request conversion on the hottest path.
|
|
271
|
+
const ingressPtr =
|
|
272
|
+
bunFFI !== null && typeof handler.ingressInnerPtr === 'function'
|
|
273
|
+
? Number(handler.ingressInnerPtr())
|
|
274
|
+
: 0
|
|
275
|
+
|
|
276
|
+
const rateLimit = options.rateLimit as { limit?: number } | undefined
|
|
277
|
+
const limit = rateLimit?.limit
|
|
278
|
+
const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
|
|
279
|
+
|
|
280
|
+
// Minimal-processing flags: skip per-request JS UTF-8 encoding of the URL/IP
|
|
281
|
+
// when the native pipeline (rust/ingress/pipeline.rs) provably cannot read
|
|
282
|
+
// them — mirror its consumption exactly, and KEEP IN SYNC:
|
|
283
|
+
// - URL is consumed iff parseQuery (extract_query), emitMetadataJson
|
|
284
|
+
// (extract_path into the envelope) or https is unpinned (detect_https
|
|
285
|
+
// scans the scheme prefix). Otherwise an empty URL section is safe.
|
|
286
|
+
// - IP is consumed iff rate limiting is active (resolve_client_ip /
|
|
287
|
+
// rate_key) or a proxy-trust mode is configured (socket_is_trusted
|
|
288
|
+
// parses it). Otherwise the IP is dropped from the frame (logs/hooks
|
|
289
|
+
// still see the real `ip` — this only skips the frame encode).
|
|
290
|
+
const trustEnabled = options.trustProxy === true || options.trustedProxies?.enabled === true
|
|
291
|
+
const urlNeeded =
|
|
292
|
+
options.parseQuery === true || options.emitMetadataJson === true || options.https === undefined
|
|
293
|
+
const ipNeeded = rateEnabled || trustEnabled
|
|
294
|
+
|
|
295
|
+
// Shared with the fast path — proxy extraction is driven by trust config
|
|
296
|
+
// (trustProxy / trustedProxies), not by whether rate limiting is enabled.
|
|
297
|
+
const headerPlan: HeaderPlan = buildHeaderPlan(options)
|
|
298
|
+
|
|
299
|
+
const emitRequestIdHeader = runtime.emitRequestIdHeader === true
|
|
300
|
+
const zeroCopyTimeoutMs = runtime.zeroCopyTimeoutMs ?? 0
|
|
301
|
+
const outputBufferSize = clampIngressBufferSize(
|
|
302
|
+
runtime.outputBufferSize ?? DEFAULT_BAKED_OUTPUT_BUFFER_SIZE,
|
|
303
|
+
OUT_DATA_START,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
// Built-in structured logger (opt-in; gated by CASTRUM_LOG_LEVEL). A custom
|
|
307
|
+
// `runtime.logger` overrides it and implies structuredLog:true.
|
|
308
|
+
const logger =
|
|
309
|
+
runtime.logger ?? (runtime.structuredLog === true ? createStructuredLogger() : null)
|
|
310
|
+
|
|
311
|
+
// Only materialize the request-id string when a consumer needs it, avoiding
|
|
312
|
+
// one string allocation + UTF-8 decode per request in deployments with no
|
|
313
|
+
// hooks/logging. The bench wires `onResponse`, so the decode stays hot there;
|
|
314
|
+
// lean servers with no hooks skip it entirely.
|
|
315
|
+
const needRequestIdString = !!(
|
|
316
|
+
emitRequestIdHeader ||
|
|
317
|
+
runtime.onRequest ||
|
|
318
|
+
runtime.onError ||
|
|
319
|
+
runtime.onResponse ||
|
|
320
|
+
logger
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
// Reusable output-buffer pool: eliminates the per-request output-buffer
|
|
324
|
+
// allocation by reusing buffers across requests.
|
|
325
|
+
// `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
|
|
326
|
+
const outputPool = new BufferPool({
|
|
327
|
+
initialSize: outputBufferSize,
|
|
328
|
+
maxInFlight: runtime.maxInFlight,
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Reusable packed-input builder (same zero-alloc discipline as the fast
|
|
332
|
+
// path): the per-request frame is packed into this buffer instead of paying
|
|
333
|
+
// the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
|
|
334
|
+
const inputPacker = new IngressInputPacker()
|
|
335
|
+
|
|
336
|
+
// Per-call state: the output handle backing the current `run()`'s result,
|
|
337
|
+
// and whether a zero-copy Response claimed it (so `run()` must not release
|
|
338
|
+
// it back to the pool before the body is consumed).
|
|
339
|
+
let currentHandle: PooledBuffer | null = null
|
|
340
|
+
let responseBorrowsBuffer = false
|
|
341
|
+
|
|
342
|
+
// FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
|
|
343
|
+
// non-panic UB (a segfault still crashes the process), but a RECURRING
|
|
344
|
+
// observable failure — a panic that `panic_guard` turned into 0 → throw, or
|
|
345
|
+
// a bun:ffi regression that throws — is caught here and permanently
|
|
346
|
+
// re-routed to the napi fallback (crash-safe via napi-rs catch_unwind), so a
|
|
347
|
+
// flaky FFI path degrades to a correct response instead of a failure storm.
|
|
348
|
+
let ffiFailures = 0
|
|
349
|
+
let ffiDisabled = false
|
|
350
|
+
|
|
351
|
+
// Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
|
|
352
|
+
// steady-state CORS case: the pre-baked template + one
|
|
353
|
+
// `access-control-allow-origin` row). Callers treat the returned array as
|
|
354
|
+
// read-only (the `Headers`/`Response` constructors copy entries), so every
|
|
355
|
+
// distinct (variant, origin) is reused after its first hit — removing the
|
|
356
|
+
// per-request array alloc + copy when an Origin header is present. Only
|
|
357
|
+
// ALLOWED origins reach the CORS-allowed path (the native pipeline gates the
|
|
358
|
+
// variant on CORS approval), so the map stays bounded by the configured
|
|
359
|
+
// allow-origin list (+ a couple of preflight variants).
|
|
360
|
+
const originHeaderCache = new Map<string, [string, string][]>()
|
|
400
361
|
|
|
401
362
|
// ── Variant-indexed header templates (precomputed once) ──
|
|
402
363
|
const cors = options.cors as
|
|
403
364
|
| {
|
|
404
|
-
allowOrigin?: string[]
|
|
405
|
-
allowMethods?: string[]
|
|
406
|
-
allowHeaders?: string[]
|
|
407
|
-
exposeHeaders?: string[]
|
|
408
|
-
allowCredentials?: boolean
|
|
409
|
-
maxAge?: number
|
|
365
|
+
allowOrigin?: string[]
|
|
366
|
+
allowMethods?: string[]
|
|
367
|
+
allowHeaders?: string[]
|
|
368
|
+
exposeHeaders?: string[]
|
|
369
|
+
allowCredentials?: boolean
|
|
370
|
+
maxAge?: number
|
|
410
371
|
}
|
|
411
|
-
| undefined
|
|
412
|
-
|
|
372
|
+
| undefined
|
|
373
|
+
|
|
374
|
+
// Security headers: honor the STRUCTURED `options.security`
|
|
375
|
+
// (SecurityHeadersOptions — the same shape `createIngress` honors) merged
|
|
376
|
+
// with raw `runtime.securityHeaders` pairs (raw wins on name conflicts).
|
|
377
|
+
// This closes the historic gotcha where `options.security` was silently
|
|
378
|
+
// ignored on the pre-baked path. When neither is provided, no security
|
|
379
|
+
// headers are emitted (the baked path's long-standing default).
|
|
413
380
|
const securityEntries: ReadonlyArray<[string, string]> =
|
|
414
381
|
runtime.enableSecurityHeaders === false
|
|
415
382
|
? Object.freeze([] as [string, string][])
|
|
416
383
|
: Object.freeze(
|
|
417
|
-
(runtime.securityHeaders
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if ((variant & HV_CORS_SIMPLE) !== 0) {
|
|
438
|
-
entries.push(["vary", "Origin"]);
|
|
439
|
-
if (cors?.allowCredentials) {
|
|
440
|
-
entries.push(["access-control-allow-credentials", "true"]);
|
|
441
|
-
}
|
|
442
|
-
if (corsExposeHeaders.length > 0) {
|
|
443
|
-
entries.push(["access-control-expose-headers", corsExposeHeaders]);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if ((variant & HV_CORS_PREFLIGHT) !== 0) {
|
|
448
|
-
entries.push([
|
|
449
|
-
"vary",
|
|
450
|
-
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
|
451
|
-
]);
|
|
452
|
-
if (cors?.allowCredentials) {
|
|
453
|
-
entries.push(["access-control-allow-credentials", "true"]);
|
|
454
|
-
}
|
|
455
|
-
entries.push(["access-control-allow-methods", corsAllowMethods]);
|
|
456
|
-
entries.push(["access-control-allow-headers", corsAllowHeaders]);
|
|
457
|
-
entries.push(["access-control-max-age", corsMaxAge]);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if ((variant & HV_RATE_ACTIVE) !== 0) {
|
|
461
|
-
entries.push(["ratelimit-limit", rateLimitStr]);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
return Object.freeze(entries);
|
|
465
|
-
}),
|
|
466
|
-
);
|
|
384
|
+
buildBakedSecurityEntries(options.security, options.https, runtime.securityHeaders),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
const corsAllowMethods = cors?.allowMethods?.join(', ') ?? ''
|
|
388
|
+
const corsAllowHeaders = cors?.allowHeaders?.join(', ') ?? ''
|
|
389
|
+
const corsExposeHeaders = cors?.exposeHeaders?.join(', ') ?? ''
|
|
390
|
+
const corsMaxAge = cors?.maxAge != null ? String(cors.maxAge) : ''
|
|
391
|
+
const rateLimitStr = rateEnabled ? String(limit) : ''
|
|
392
|
+
|
|
393
|
+
const { regular: headerTemplates, terminal: terminalTemplates } = buildBakedHeaderTemplates({
|
|
394
|
+
securityEntries,
|
|
395
|
+
cors,
|
|
396
|
+
corsAllowMethods,
|
|
397
|
+
corsAllowHeaders,
|
|
398
|
+
corsExposeHeaders,
|
|
399
|
+
corsMaxAge,
|
|
400
|
+
rateLimitStr,
|
|
401
|
+
})
|
|
467
402
|
|
|
468
403
|
function responseHeaders(
|
|
469
404
|
variant: number,
|
|
@@ -474,51 +409,72 @@ export function createIngressHandler(
|
|
|
474
409
|
retryAfterSecs?: number,
|
|
475
410
|
): [string, string][] {
|
|
476
411
|
const template: ReadonlyArray<[string, string]> =
|
|
477
|
-
headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
412
|
+
headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
478
413
|
|
|
479
|
-
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
414
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
480
415
|
const needsOrigin =
|
|
481
|
-
((variant & HV_CORS_SIMPLE) !== 0 ||
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const needsRate = (variant & HV_RATE_ACTIVE) !== 0;
|
|
485
|
-
const needsRetry = (variant & HV_RATE_LIMITED) !== 0;
|
|
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
|
|
486
419
|
|
|
487
420
|
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
488
|
-
|
|
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
|
|
489
445
|
}
|
|
490
446
|
|
|
491
|
-
let extra = 0
|
|
492
|
-
if (needsRequestId) extra
|
|
493
|
-
if (needsOrigin) extra
|
|
494
|
-
if (needsRate) extra += 2
|
|
495
|
-
if (needsRetry) extra
|
|
447
|
+
let extra = 0
|
|
448
|
+
if (needsRequestId) extra++
|
|
449
|
+
if (needsOrigin) extra++
|
|
450
|
+
if (needsRate) extra += 2
|
|
451
|
+
if (needsRetry) extra++
|
|
496
452
|
|
|
497
|
-
const entries = new Array<[string, string]>(template.length + extra)
|
|
498
|
-
let i = 0
|
|
453
|
+
const entries = new Array<[string, string]>(template.length + extra)
|
|
454
|
+
let i = 0
|
|
499
455
|
|
|
500
|
-
for (
|
|
501
|
-
entries[i] =
|
|
456
|
+
for (const pair of template) {
|
|
457
|
+
entries[i++] = pair
|
|
502
458
|
}
|
|
503
459
|
|
|
504
460
|
if (needsRequestId) {
|
|
505
|
-
entries[i++] = [
|
|
461
|
+
entries[i++] = ['x-request-id', requestIdHeader as string]
|
|
506
462
|
}
|
|
507
463
|
|
|
508
464
|
if (needsOrigin) {
|
|
509
|
-
entries[i++] = [
|
|
465
|
+
entries[i++] = ['access-control-allow-origin', origin as string]
|
|
510
466
|
}
|
|
511
467
|
|
|
512
468
|
if (needsRate) {
|
|
513
|
-
entries[i++] = [
|
|
514
|
-
entries[i++] = [
|
|
469
|
+
entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
|
|
470
|
+
entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
|
|
515
471
|
}
|
|
516
472
|
|
|
517
473
|
if (needsRetry) {
|
|
518
|
-
entries[i++] = [
|
|
474
|
+
entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
|
|
519
475
|
}
|
|
520
476
|
|
|
521
|
-
return entries
|
|
477
|
+
return entries
|
|
522
478
|
}
|
|
523
479
|
|
|
524
480
|
function terminalHeaders(
|
|
@@ -526,37 +482,57 @@ export function createIngressHandler(
|
|
|
526
482
|
ctx: BakedContext,
|
|
527
483
|
result: BakedIngressResult | null,
|
|
528
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`.
|
|
529
508
|
const base = responseHeaders(
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
509
|
+
v,
|
|
510
|
+
requestIdHeader,
|
|
511
|
+
origin,
|
|
533
512
|
result?.rateRemaining,
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
)
|
|
541
|
-
|
|
542
|
-
const out = new Array<[string, string]>(base.length + 1);
|
|
543
|
-
for (let i = 0; i < base.length; i++) {
|
|
544
|
-
out[i] = base[i]!;
|
|
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
|
|
545
521
|
}
|
|
546
|
-
out[base.length] = [
|
|
547
|
-
return out
|
|
522
|
+
out[base.length] = ['cache-control', 'no-store']
|
|
523
|
+
return out
|
|
548
524
|
}
|
|
549
525
|
|
|
550
526
|
function terminalResponse(
|
|
551
|
-
_req: Request,
|
|
527
|
+
_req: Request | undefined,
|
|
552
528
|
result: BakedIngressResult,
|
|
553
529
|
ctx: BakedContext,
|
|
554
530
|
): Response | null {
|
|
555
531
|
if (!result.terminal) {
|
|
556
|
-
return null
|
|
532
|
+
return null
|
|
557
533
|
}
|
|
558
534
|
|
|
559
|
-
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
535
|
+
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
560
536
|
|
|
561
537
|
if (preflightAllowed) {
|
|
562
538
|
return new Response(null, {
|
|
@@ -566,31 +542,27 @@ export function createIngressHandler(
|
|
|
566
542
|
ctx.requestIdHeader,
|
|
567
543
|
ctx.origin,
|
|
568
544
|
result.rateRemaining,
|
|
569
|
-
result.rateResetMs > 0
|
|
570
|
-
|
|
571
|
-
: undefined,
|
|
572
|
-
result.retryAfterMs > 0
|
|
573
|
-
? Math.ceil(result.retryAfterMs / 1000)
|
|
574
|
-
: undefined,
|
|
545
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
546
|
+
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
575
547
|
),
|
|
576
|
-
})
|
|
548
|
+
})
|
|
577
549
|
}
|
|
578
550
|
|
|
579
|
-
const status = safeTerminalStatus(result)
|
|
551
|
+
const status = safeTerminalStatus(result)
|
|
580
552
|
|
|
581
553
|
const body: Uint8Array =
|
|
582
554
|
result.errorCode === ERROR_CODE_RATE_LIMITED
|
|
583
555
|
? rateLimitedBody(result.retryAfterMs)
|
|
584
|
-
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal
|
|
556
|
+
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
|
|
585
557
|
|
|
586
558
|
return new Response(body, {
|
|
587
559
|
status,
|
|
588
560
|
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
589
|
-
})
|
|
561
|
+
})
|
|
590
562
|
}
|
|
591
563
|
|
|
592
564
|
function errorResponse(
|
|
593
|
-
_req: Request,
|
|
565
|
+
_req: Request | undefined,
|
|
594
566
|
result: BakedIngressResult | null,
|
|
595
567
|
status: number,
|
|
596
568
|
code: string,
|
|
@@ -598,46 +570,58 @@ export function createIngressHandler(
|
|
|
598
570
|
ctx: BakedContext,
|
|
599
571
|
): Response {
|
|
600
572
|
const body =
|
|
601
|
-
ERROR_BODIES[code] ??
|
|
602
|
-
encoder.encode(JSON.stringify({ ok: false, error: { code, message } }));
|
|
573
|
+
ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
|
|
603
574
|
|
|
604
575
|
return new Response(body, {
|
|
605
576
|
status,
|
|
606
577
|
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
607
|
-
})
|
|
578
|
+
})
|
|
608
579
|
}
|
|
609
580
|
|
|
610
|
-
function internalErrorResponse(
|
|
611
|
-
ctx: BakedContext,
|
|
612
|
-
result?: BakedIngressResult,
|
|
613
|
-
): Response {
|
|
581
|
+
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
614
582
|
return new Response(ERROR_BODIES.internal, {
|
|
615
583
|
status: 500,
|
|
616
|
-
headers: terminalHeaders(
|
|
617
|
-
|
|
618
|
-
ctx,
|
|
619
|
-
result ?? null,
|
|
620
|
-
),
|
|
621
|
-
});
|
|
584
|
+
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
585
|
+
})
|
|
622
586
|
}
|
|
623
587
|
|
|
624
588
|
function withContentType(
|
|
625
589
|
headers: ReadonlyArray<[string, string]>,
|
|
626
590
|
contentType: string,
|
|
627
591
|
): [string, string][] {
|
|
628
|
-
const out = new Array<[string, string]>(headers.length + 1)
|
|
629
|
-
|
|
630
|
-
|
|
592
|
+
const out = new Array<[string, string]>(headers.length + 1)
|
|
593
|
+
let i = 0
|
|
594
|
+
for (const pair of headers) {
|
|
595
|
+
out[i++] = pair
|
|
631
596
|
}
|
|
632
|
-
out[headers.length] = [
|
|
633
|
-
return out
|
|
597
|
+
out[headers.length] = ['content-type', contentType]
|
|
598
|
+
return out
|
|
634
599
|
}
|
|
635
600
|
|
|
636
|
-
|
|
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
|
+
}
|
|
619
|
+
|
|
620
|
+
const result = new BakedIngressResult()
|
|
637
621
|
const ctx: BakedContext = {
|
|
638
622
|
requestIdHeader: null,
|
|
639
623
|
origin: null,
|
|
640
|
-
}
|
|
624
|
+
}
|
|
641
625
|
|
|
642
626
|
function run<T>(
|
|
643
627
|
req: Request,
|
|
@@ -645,98 +629,201 @@ export function createIngressHandler(
|
|
|
645
629
|
body: Uint8Array | null,
|
|
646
630
|
fn: (result: BakedIngressResult, ctx: BakedContext) => T,
|
|
647
631
|
): T {
|
|
648
|
-
const
|
|
649
|
-
const
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null;
|
|
653
|
-
ctx.origin = headerPlan.cors ? req.headers.get("origin") : null;
|
|
654
|
-
|
|
655
|
-
// Gather raw headers as [name, value][] — no binary packing in JS.
|
|
656
|
-
// handleRequestFullSync packs them internally in Rust synchronously,
|
|
657
|
-
// eliminating both JS-side encoding and async overhead.
|
|
658
|
-
const headers: [string, string][] = [];
|
|
659
|
-
const h = req.headers;
|
|
660
|
-
|
|
661
|
-
if (headerPlan.cookie) {
|
|
662
|
-
const v = h.get("cookie");
|
|
663
|
-
if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
|
|
664
|
-
headers.push(["cookie", v]);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
632
|
+
const startedAt = logger ? performance.now() : 0
|
|
633
|
+
const methodKind = METHOD_KIND[req.method] ?? METHOD_KIND_UNKNOWN
|
|
634
|
+
const ridBytes = generateRequestId()
|
|
635
|
+
const requestIdStr = needRequestIdString ? decoder.decode(ridBytes) : ''
|
|
667
636
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (originV !== null && originV.length <= MAX_SMALL_HEADER_BYTES) {
|
|
671
|
-
headers.push(["origin", originV]);
|
|
672
|
-
}
|
|
637
|
+
ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
|
|
638
|
+
ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
|
|
673
639
|
|
|
674
|
-
|
|
675
|
-
const acrm = h.get("access-control-request-method");
|
|
676
|
-
if (acrm !== null && acrm.length <= MAX_SMALL_HEADER_BYTES) {
|
|
677
|
-
headers.push(["access-control-request-method", acrm]);
|
|
678
|
-
}
|
|
640
|
+
runtime.onRequest?.(req, requestIdStr, ip)
|
|
679
641
|
|
|
680
|
-
|
|
681
|
-
if (acrh !== null && acrh.length <= MAX_SMALL_HEADER_BYTES) {
|
|
682
|
-
headers.push(["access-control-request-headers", acrh]);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
642
|
+
const ipStr = ip ?? EMPTY_IP
|
|
686
643
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
644
|
+
// Pack the request frame in JS (reusing the fast path's zero-alloc packer
|
|
645
|
+
// discipline) and drive the same native core as `handleRequestPacked`.
|
|
646
|
+
// This removes the per-request `[name, value][]` header marshaling + napi
|
|
647
|
+
// `Vec<Vec<String>>` and the request-id string re-encode that the
|
|
648
|
+
// full_sync family paid on every request. The response wire format is
|
|
649
|
+
// unchanged because both entries run the identical `handle_packed` core.
|
|
650
|
+
let handle: PooledBuffer | null = null
|
|
651
|
+
currentHandle = null
|
|
652
|
+
responseBorrowsBuffer = false
|
|
692
653
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
654
|
+
try {
|
|
655
|
+
// Pack the request frame (inside the try: an unexpected packing failure
|
|
656
|
+
// is reported by the escaping-error catch below — it must never leak the
|
|
657
|
+
// request without accounting). `ctx.origin` was already fetched once
|
|
658
|
+
// above, so pass it in to avoid a second `req.headers.get('origin')`
|
|
659
|
+
// native→JS string conversion on the hot path.
|
|
660
|
+
const packedHeaders = gatherRawHeadersPacked(req, headerPlan, methodKind, ctx.origin)
|
|
661
|
+
// Minimal processing: when the native pipeline cannot read the URL/IP
|
|
662
|
+
// (see `urlNeeded`/`ipNeeded`), pass an empty URL / omit the IP so we
|
|
663
|
+
// skip the per-request JS UTF-8 encode entirely (the frame still carries
|
|
664
|
+
// a valid 0-length section the native bounds-checks).
|
|
665
|
+
const packUrl = urlNeeded ? req.url : ''
|
|
666
|
+
const packIp = ipNeeded ? ip : undefined
|
|
698
667
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
668
|
+
try {
|
|
669
|
+
// Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
|
|
670
|
+
// via setInternalError — never an uncaught exception out of run().
|
|
671
|
+
handle = outputPool.acquire(outputBufferSize)
|
|
672
|
+
currentHandle = handle
|
|
673
|
+
|
|
674
|
+
// Write into the pooled buffer — no per-request allocation. Bun
|
|
675
|
+
// PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
|
|
676
|
+
// passes `url`/`ip` as `bun:ffi` `cstring` args — the engine transcodes
|
|
677
|
+
// the JS strings to call-scoped UTF-8 in-engine, so we skip the JS
|
|
678
|
+
// frame assembly + `Buffer.write` encode for the URL/IP (same wire
|
|
679
|
+
// format: both paths run the shared `handle_components` core). Falls
|
|
680
|
+
// back to the packed frame (FFI or napi) on any failure / stale addon.
|
|
681
|
+
// If an ffi call throws (native reported 0 = error, or a panic
|
|
682
|
+
// `panic_guard` contained), re-dispatch through napi once and, after
|
|
683
|
+
// repeated failures, permanently disable ffi for this handler.
|
|
684
|
+
let written: number
|
|
685
|
+
if (
|
|
686
|
+
bunFFI !== null &&
|
|
687
|
+
ingressPtr !== 0 &&
|
|
688
|
+
!ffiDisabled &&
|
|
689
|
+
typeof bunFFI.ingressHandleComponents === 'function'
|
|
690
|
+
) {
|
|
691
|
+
try {
|
|
692
|
+
written = bunFFI.ingressHandleComponents(
|
|
693
|
+
ingressPtr,
|
|
694
|
+
methodKind,
|
|
695
|
+
packUrl,
|
|
696
|
+
packIp ?? '',
|
|
697
|
+
ridBytes,
|
|
698
|
+
packedHeaders,
|
|
699
|
+
body,
|
|
700
|
+
handle.buffer,
|
|
701
|
+
)
|
|
702
|
+
ffiFailures = 0
|
|
703
|
+
} catch {
|
|
704
|
+
ffiFailures++
|
|
705
|
+
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
706
|
+
ffiDisabled = true
|
|
707
|
+
}
|
|
708
|
+
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
709
|
+
// identical — a transient ffi panic still serves the request. The
|
|
710
|
+
// packed frame respects the same url/ip minimal-processing.
|
|
711
|
+
const input = inputPacker.packParts(
|
|
712
|
+
methodKind,
|
|
713
|
+
packUrl,
|
|
714
|
+
packIp,
|
|
715
|
+
ridBytes,
|
|
716
|
+
packedHeaders,
|
|
717
|
+
)
|
|
718
|
+
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
719
|
+
}
|
|
720
|
+
} else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
|
|
721
|
+
const input = inputPacker.packParts(
|
|
722
|
+
methodKind,
|
|
723
|
+
packUrl,
|
|
724
|
+
packIp,
|
|
725
|
+
ridBytes,
|
|
726
|
+
packedHeaders,
|
|
727
|
+
)
|
|
728
|
+
try {
|
|
729
|
+
written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
|
|
730
|
+
ffiFailures = 0
|
|
731
|
+
} catch {
|
|
732
|
+
ffiFailures++
|
|
733
|
+
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
734
|
+
ffiDisabled = true
|
|
735
|
+
}
|
|
736
|
+
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
737
|
+
// identical — a transient ffi panic still serves the request.
|
|
738
|
+
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
const input = inputPacker.packParts(
|
|
742
|
+
methodKind,
|
|
743
|
+
packUrl,
|
|
744
|
+
packIp,
|
|
745
|
+
ridBytes,
|
|
746
|
+
packedHeaders,
|
|
747
|
+
)
|
|
748
|
+
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
749
|
+
}
|
|
705
750
|
|
|
706
|
-
|
|
751
|
+
const used = handle.buffer.subarray(0, written)
|
|
752
|
+
// Cached per-ArrayBuffer DataView: no per-request view allocation.
|
|
753
|
+
result.refresh(used, body ?? EMPTY_BODY, viewForArrayBuffer(used.buffer, used.byteOffset))
|
|
754
|
+
} catch (err) {
|
|
755
|
+
result.setInternalError()
|
|
756
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
757
|
+
runtime.onError?.(req, requestIdStr, error)
|
|
758
|
+
logger?.error({
|
|
759
|
+
requestId: requestIdStr,
|
|
760
|
+
method: req.method,
|
|
761
|
+
path: pathForLog(req),
|
|
762
|
+
code: 'internal',
|
|
763
|
+
message: error.message,
|
|
764
|
+
})
|
|
765
|
+
}
|
|
707
766
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
767
|
+
try {
|
|
768
|
+
const out = fn(result, ctx)
|
|
769
|
+
// The ingress result is invalidated when run() returns, so an async
|
|
770
|
+
// callback would observe a stale/zeroed result — reject it up front
|
|
771
|
+
// (parity with the fast path / createIngressSync).
|
|
772
|
+
assertSyncCallback(out, 'createIngressHandler().run()')
|
|
773
|
+
// Report observability for every outcome. Non-Response callbacks
|
|
774
|
+
// (e.g. echoHandler's object return) fall back to the decoded terminal
|
|
775
|
+
// status so they are not silently invisible to onResponse/logging.
|
|
776
|
+
const status = out instanceof Response ? out.status : safeTerminalStatus(result)
|
|
777
|
+
runtime.onResponse?.(req, result, status, requestIdStr)
|
|
778
|
+
if (logger) {
|
|
779
|
+
logger.request({
|
|
780
|
+
requestId: requestIdStr,
|
|
781
|
+
method: req.method,
|
|
782
|
+
path: pathForLog(req),
|
|
783
|
+
status,
|
|
784
|
+
durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
|
|
785
|
+
ip: ipStr,
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
return out
|
|
789
|
+
} finally {
|
|
790
|
+
result.invalidate()
|
|
791
|
+
ctx.requestIdHeader = null
|
|
792
|
+
ctx.origin = null
|
|
793
|
+
}
|
|
726
794
|
} catch (err) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
|
|
795
|
+
// An unexpected error escaped run() after onRequest (a packing failure
|
|
796
|
+
// before the native call, or a throwing route callback). Report it so
|
|
797
|
+
// request accounting completes (metrics in_flight must not leak), then
|
|
798
|
+
// rethrow for the caller. The native-failure path above does NOT rethrow,
|
|
799
|
+
// so this fires at most once per request.
|
|
800
|
+
if (responseBorrowsBuffer && handle !== null) {
|
|
801
|
+
// `zeroCopyResponse` was called but the Response never escaped (the
|
|
802
|
+
// callback threw): the stream that would release the pooled buffer was
|
|
803
|
+
// never delivered. Release it here so the buffer isn't stuck in flight
|
|
804
|
+
// forever.
|
|
805
|
+
handle.release()
|
|
806
|
+
handle = null
|
|
807
|
+
responseBorrowsBuffer = false
|
|
734
808
|
}
|
|
735
|
-
|
|
809
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
810
|
+
runtime.onError?.(req, requestIdStr, error)
|
|
811
|
+
logger?.error({
|
|
812
|
+
requestId: requestIdStr,
|
|
813
|
+
method: req.method,
|
|
814
|
+
path: pathForLog(req),
|
|
815
|
+
code: 'internal',
|
|
816
|
+
message: error.message,
|
|
817
|
+
})
|
|
818
|
+
throw error
|
|
736
819
|
} finally {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
820
|
+
// A zero-copy Response owns the handle until its body is consumed; in
|
|
821
|
+
// every other case (copy mode, terminal/error responses) the buffer is
|
|
822
|
+
// safe to return to the pool immediately.
|
|
823
|
+
if (handle !== null && !responseBorrowsBuffer) {
|
|
824
|
+
handle.release()
|
|
825
|
+
}
|
|
826
|
+
currentHandle = null
|
|
740
827
|
}
|
|
741
828
|
}
|
|
742
829
|
|
|
@@ -748,455 +835,6 @@ export function createIngressHandler(
|
|
|
748
835
|
errorResponse,
|
|
749
836
|
internalErrorResponse,
|
|
750
837
|
withContentType,
|
|
751
|
-
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// ── Route-handler factories ───────────────────────────────────────
|
|
755
|
-
export interface BakedHandlerOptions {
|
|
756
|
-
/** Resolve the client IP from the server object (e.g. `srv.requestIP`). */
|
|
757
|
-
getIp?: (req: Request, srv: unknown) => string | undefined;
|
|
758
|
-
/** Copy body slices instead of sharing the native buffer. Default: true (safe). */
|
|
759
|
-
copyBody?: boolean;
|
|
760
|
-
/** Maximum request body bytes for write/echo handlers. Default: 1 MiB. */
|
|
761
|
-
maxBodyBytes?: number;
|
|
762
|
-
/** Fallback handler used for write error paths. Defaults to the write ingress. */
|
|
763
|
-
fallback?: OptimizedIngressHandler;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function resolveIp(
|
|
767
|
-
req: Request,
|
|
768
|
-
srv: unknown,
|
|
769
|
-
opts: BakedHandlerOptions,
|
|
770
|
-
): string | undefined {
|
|
771
|
-
return opts.getIp ? opts.getIp(req, srv) : undefined;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/** Pre-baked GET read handler: returns the ingress body JSON on success. */
|
|
775
|
-
export function readHandler(
|
|
776
|
-
ingress: OptimizedIngressHandler,
|
|
777
|
-
opts: BakedHandlerOptions = {},
|
|
778
|
-
): (req: Request, srv?: unknown) => Response {
|
|
779
|
-
const copyBody = opts.copyBody !== false;
|
|
780
|
-
|
|
781
|
-
return (req, srv) =>
|
|
782
|
-
ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
|
|
783
|
-
const terminal = ingress.terminalResponse(req, result, ctx);
|
|
784
|
-
if (terminal) return terminal;
|
|
785
|
-
|
|
786
|
-
if (result.bodyTruncated) {
|
|
787
|
-
return ingress.internalErrorResponse(ctx, result);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
return new Response(result.bodyJson(copyBody), {
|
|
791
|
-
status: 200,
|
|
792
|
-
headers: ingress.responseHeaders(
|
|
793
|
-
result.headerVariant,
|
|
794
|
-
ctx.requestIdHeader,
|
|
795
|
-
ctx.origin,
|
|
796
|
-
result.rateRemaining,
|
|
797
|
-
result.rateResetMs > 0
|
|
798
|
-
? Math.ceil(result.rateResetMs / 1000)
|
|
799
|
-
: undefined,
|
|
800
|
-
),
|
|
801
|
-
});
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/** Pre-baked HEAD read handler: headers only, no body. */
|
|
806
|
-
export function headHandler(
|
|
807
|
-
ingress: OptimizedIngressHandler,
|
|
808
|
-
opts: BakedHandlerOptions = {},
|
|
809
|
-
): (req: Request, srv?: unknown) => Response {
|
|
810
|
-
return (req, srv) =>
|
|
811
|
-
ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
|
|
812
|
-
const terminal = ingress.terminalResponse(req, result, ctx);
|
|
813
|
-
if (terminal) return terminal;
|
|
814
|
-
|
|
815
|
-
if (result.bodyTruncated) {
|
|
816
|
-
return ingress.internalErrorResponse(ctx, result);
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
return new Response(null, {
|
|
820
|
-
status: 200,
|
|
821
|
-
headers: ingress.responseHeaders(
|
|
822
|
-
result.headerVariant,
|
|
823
|
-
ctx.requestIdHeader,
|
|
824
|
-
ctx.origin,
|
|
825
|
-
result.rateRemaining,
|
|
826
|
-
result.rateResetMs > 0
|
|
827
|
-
? Math.ceil(result.rateResetMs / 1000)
|
|
828
|
-
: undefined,
|
|
829
|
-
),
|
|
830
|
-
});
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Pre-baked JSON-write handler (POST/PUT/PATCH): enforces Content-Type,
|
|
836
|
-
* content-length/body-size limits, JSON validity and (optionally) schema
|
|
837
|
-
* validation, then returns the ingress body JSON on success.
|
|
838
|
-
*/
|
|
839
|
-
export function jsonWriteHandler(
|
|
840
|
-
ingress: OptimizedIngressHandler,
|
|
841
|
-
opts: BakedHandlerOptions = {},
|
|
842
|
-
): (req: Request, srv?: unknown) => Promise<Response> {
|
|
843
|
-
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
844
|
-
const fallback = opts.fallback ?? ingress;
|
|
845
|
-
const copyBody = opts.copyBody !== false;
|
|
846
|
-
|
|
847
|
-
return async (req, srv) => {
|
|
848
|
-
const ip = resolveIp(req, srv, opts);
|
|
849
|
-
|
|
850
|
-
const contentType = req.headers.get("content-type") ?? "";
|
|
851
|
-
if (!contentType.includes("application/json")) {
|
|
852
|
-
return fallback.run(req, ip, null, (result, ctx) => {
|
|
853
|
-
const terminal = fallback.terminalResponse(req, result, ctx);
|
|
854
|
-
if (terminal) return terminal;
|
|
855
|
-
|
|
856
|
-
return fallback.errorResponse(
|
|
857
|
-
req,
|
|
858
|
-
result,
|
|
859
|
-
415,
|
|
860
|
-
"unsupported_media_type",
|
|
861
|
-
"Content-Type must be application/json",
|
|
862
|
-
ctx,
|
|
863
|
-
);
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
const contentLengthHeader = req.headers.get("content-length");
|
|
868
|
-
const contentLength =
|
|
869
|
-
contentLengthHeader === null ? NaN : Number(contentLengthHeader);
|
|
870
|
-
|
|
871
|
-
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
872
|
-
return fallback.run(req, ip, null, (result, ctx) => {
|
|
873
|
-
const terminal = fallback.terminalResponse(req, result, ctx);
|
|
874
|
-
if (terminal) return terminal;
|
|
875
|
-
|
|
876
|
-
return fallback.errorResponse(
|
|
877
|
-
req,
|
|
878
|
-
result,
|
|
879
|
-
413,
|
|
880
|
-
"body_too_large",
|
|
881
|
-
"Request body is too large",
|
|
882
|
-
ctx,
|
|
883
|
-
);
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
let bodyBytes: Uint8Array;
|
|
888
|
-
try {
|
|
889
|
-
bodyBytes = new Uint8Array(await req.arrayBuffer());
|
|
890
|
-
} catch {
|
|
891
|
-
return fallback.run(req, ip, null, (result, ctx) => {
|
|
892
|
-
const terminal = fallback.terminalResponse(req, result, ctx);
|
|
893
|
-
if (terminal) return terminal;
|
|
894
|
-
|
|
895
|
-
return fallback.errorResponse(
|
|
896
|
-
req,
|
|
897
|
-
result,
|
|
898
|
-
400,
|
|
899
|
-
"bad_request",
|
|
900
|
-
"Unable to read request body",
|
|
901
|
-
ctx,
|
|
902
|
-
);
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
if (bodyBytes.byteLength > maxBodyBytes) {
|
|
907
|
-
return fallback.run(req, ip, null, (result, ctx) => {
|
|
908
|
-
const terminal = fallback.terminalResponse(req, result, ctx);
|
|
909
|
-
if (terminal) return terminal;
|
|
910
|
-
|
|
911
|
-
return fallback.errorResponse(
|
|
912
|
-
req,
|
|
913
|
-
result,
|
|
914
|
-
413,
|
|
915
|
-
"body_too_large",
|
|
916
|
-
"Request body is too large",
|
|
917
|
-
ctx,
|
|
918
|
-
);
|
|
919
|
-
});
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
return ingress.run(req, ip, bodyBytes, (result, ctx) => {
|
|
923
|
-
const terminal = ingress.terminalResponse(req, result, ctx);
|
|
924
|
-
if (terminal) return terminal;
|
|
925
|
-
|
|
926
|
-
if (result.bodyTruncated) {
|
|
927
|
-
return ingress.internalErrorResponse(ctx, result);
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (!result.bodyValidJson) {
|
|
931
|
-
return ingress.errorResponse(
|
|
932
|
-
req,
|
|
933
|
-
result,
|
|
934
|
-
400,
|
|
935
|
-
"invalid_json",
|
|
936
|
-
"Invalid JSON body",
|
|
937
|
-
ctx,
|
|
938
|
-
);
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
if (!result.schemaValid) {
|
|
942
|
-
return ingress.errorResponse(
|
|
943
|
-
req,
|
|
944
|
-
result,
|
|
945
|
-
422,
|
|
946
|
-
"schema_validation_failed",
|
|
947
|
-
"Request body failed schema validation",
|
|
948
|
-
ctx,
|
|
949
|
-
);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
return new Response(result.bodyJson(copyBody), {
|
|
953
|
-
status: 200,
|
|
954
|
-
headers: ingress.responseHeaders(
|
|
955
|
-
result.headerVariant,
|
|
956
|
-
ctx.requestIdHeader,
|
|
957
|
-
ctx.origin,
|
|
958
|
-
result.rateRemaining,
|
|
959
|
-
result.rateResetMs > 0
|
|
960
|
-
? Math.ceil(result.rateResetMs / 1000)
|
|
961
|
-
: undefined,
|
|
962
|
-
),
|
|
963
|
-
});
|
|
964
|
-
});
|
|
965
|
-
};
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/**
|
|
969
|
-
* Pre-baked echo handler: streams the request body back with the client's
|
|
970
|
-
* Content-Type, bounded by `maxBodyBytes`.
|
|
971
|
-
*/
|
|
972
|
-
export function echoHandler(
|
|
973
|
-
ingress: OptimizedIngressHandler,
|
|
974
|
-
opts: BakedHandlerOptions = {},
|
|
975
|
-
): (req: Request, srv?: unknown) => Promise<Response> {
|
|
976
|
-
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
977
|
-
|
|
978
|
-
return async (req, srv) => {
|
|
979
|
-
const ip = resolveIp(req, srv, opts);
|
|
980
|
-
|
|
981
|
-
const prep = ingress.run<{
|
|
982
|
-
terminal?: Response;
|
|
983
|
-
headers?: ReadonlyArray<[string, string]>;
|
|
984
|
-
}>(req, ip, null, (result, ctx) => {
|
|
985
|
-
const terminal = ingress.terminalResponse(req, result, ctx);
|
|
986
|
-
if (terminal) return { terminal };
|
|
987
|
-
|
|
988
|
-
if (result.bodyTruncated) {
|
|
989
|
-
return { terminal: ingress.internalErrorResponse(ctx, result) };
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
const hv = result.headerVariant & ~HV_JSON;
|
|
993
|
-
|
|
994
|
-
return {
|
|
995
|
-
headers: ingress.responseHeaders(
|
|
996
|
-
hv,
|
|
997
|
-
ctx.requestIdHeader,
|
|
998
|
-
ctx.origin,
|
|
999
|
-
result.rateRemaining,
|
|
1000
|
-
result.rateResetMs > 0
|
|
1001
|
-
? Math.ceil(result.rateResetMs / 1000)
|
|
1002
|
-
: undefined,
|
|
1003
|
-
),
|
|
1004
|
-
};
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
if (prep.terminal) return prep.terminal;
|
|
1008
|
-
|
|
1009
|
-
const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? [];
|
|
1010
|
-
|
|
1011
|
-
const requestedContentType =
|
|
1012
|
-
req.headers.get("content-type") ?? "application/octet-stream";
|
|
1013
|
-
|
|
1014
|
-
const contentLengthHeader = req.headers.get("content-length");
|
|
1015
|
-
const contentLength =
|
|
1016
|
-
contentLengthHeader === null ? NaN : Number(contentLengthHeader);
|
|
1017
|
-
|
|
1018
|
-
if (Number.isFinite(contentLength)) {
|
|
1019
|
-
if (contentLength > maxBodyBytes) {
|
|
1020
|
-
return new Response(ERROR_BODIES.body_too_large, {
|
|
1021
|
-
status: 413,
|
|
1022
|
-
headers: ingress.withContentType(baseHeaders, "application/json"),
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
if (contentLength <= 0 || req.body === null) {
|
|
1027
|
-
return new Response(null, {
|
|
1028
|
-
status: 200,
|
|
1029
|
-
headers: ingress.withContentType(baseHeaders, requestedContentType),
|
|
1030
|
-
});
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
return new Response(req.body, {
|
|
1034
|
-
status: 200,
|
|
1035
|
-
headers: ingress.withContentType(baseHeaders, requestedContentType),
|
|
1036
|
-
});
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
try {
|
|
1040
|
-
const bodyBytes = new Uint8Array(await req.arrayBuffer());
|
|
1041
|
-
|
|
1042
|
-
if (bodyBytes.byteLength > maxBodyBytes) {
|
|
1043
|
-
return new Response(ERROR_BODIES.body_too_large, {
|
|
1044
|
-
status: 413,
|
|
1045
|
-
headers: ingress.withContentType(baseHeaders, "application/json"),
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
|
|
1050
|
-
status: 200,
|
|
1051
|
-
headers: ingress.withContentType(baseHeaders, requestedContentType),
|
|
1052
|
-
});
|
|
1053
|
-
} catch {
|
|
1054
|
-
return new Response(ERROR_BODIES.bad_request, {
|
|
1055
|
-
status: 400,
|
|
1056
|
-
headers: ingress.withContentType(baseHeaders, "application/json"),
|
|
1057
|
-
});
|
|
1058
|
-
}
|
|
1059
|
-
};
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/** Pre-baked fallback handler: 404 for unmatched routes / OPTIONS. */
|
|
1063
|
-
export function fallbackHandler(
|
|
1064
|
-
ingress: OptimizedIngressHandler,
|
|
1065
|
-
opts: BakedHandlerOptions = {},
|
|
1066
|
-
): (req: Request, srv?: unknown) => Response {
|
|
1067
|
-
return (req, srv) =>
|
|
1068
|
-
ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
|
|
1069
|
-
const terminal = ingress.terminalResponse(req, result, ctx);
|
|
1070
|
-
if (terminal) return terminal;
|
|
1071
|
-
|
|
1072
|
-
return ingress.errorResponse(
|
|
1073
|
-
req,
|
|
1074
|
-
result,
|
|
1075
|
-
404,
|
|
1076
|
-
"not_found",
|
|
1077
|
-
"Not found",
|
|
1078
|
-
ctx,
|
|
1079
|
-
);
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// ── Bun.serve builder ─────────────────────────────────────────────
|
|
1084
|
-
export interface BakedRoute {
|
|
1085
|
-
/** Wires GET + HEAD read handlers. */
|
|
1086
|
-
read?: OptimizedIngressHandler;
|
|
1087
|
-
/** Wires POST/PUT/PATCH JSON-write handlers (and OPTIONS fallback). */
|
|
1088
|
-
write?: OptimizedIngressHandler;
|
|
1089
|
-
/** Wires a POST echo handler. */
|
|
1090
|
-
echo?: OptimizedIngressHandler;
|
|
1091
|
-
/** Wires a GET read handler (cookies-style route). */
|
|
1092
|
-
cookies?: OptimizedIngressHandler;
|
|
1093
|
-
/** Overrides `maxBodyBytes` for this route's write/echo handlers. */
|
|
1094
|
-
maxBodyBytes?: number;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
export interface CreateIngressServerOptions {
|
|
1098
|
-
port: number;
|
|
1099
|
-
hostname?: string;
|
|
1100
|
-
idleTimeout?: number;
|
|
1101
|
-
maxRequestBodySize?: number;
|
|
1102
|
-
reusePort?: boolean;
|
|
1103
|
-
copyBody?: boolean;
|
|
1104
|
-
getIp?: (req: Request, srv: unknown) => string | undefined;
|
|
1105
|
-
routes: Record<string, BakedRoute>;
|
|
1106
|
-
fallback?: OptimizedIngressHandler;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
export interface BakedServer {
|
|
1110
|
-
server: ReturnType<typeof Bun.serve>;
|
|
1111
|
-
stop(): void;
|
|
1112
|
-
port: number;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/** Build a Bun.serve config from pre-baked route handlers. */
|
|
1116
|
-
export function createIngressServer(
|
|
1117
|
-
options: CreateIngressServerOptions,
|
|
1118
|
-
): BakedServer {
|
|
1119
|
-
const baseOpts: BakedHandlerOptions = {
|
|
1120
|
-
getIp: options.getIp,
|
|
1121
|
-
copyBody: options.copyBody,
|
|
1122
|
-
};
|
|
1123
|
-
|
|
1124
|
-
const serverRoutes: Record<string, Record<string, unknown>> = {};
|
|
1125
|
-
|
|
1126
|
-
for (const [path, spec] of Object.entries(options.routes)) {
|
|
1127
|
-
const routeOpts: BakedHandlerOptions = {
|
|
1128
|
-
...baseOpts,
|
|
1129
|
-
maxBodyBytes: spec.maxBodyBytes,
|
|
1130
|
-
};
|
|
1131
|
-
const methods: Record<string, unknown> = {};
|
|
1132
|
-
|
|
1133
|
-
if (spec.read) {
|
|
1134
|
-
methods.GET = readHandler(spec.read, routeOpts);
|
|
1135
|
-
methods.HEAD = headHandler(spec.read, routeOpts);
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
if (spec.write) {
|
|
1139
|
-
const writeOpts: BakedHandlerOptions = {
|
|
1140
|
-
...routeOpts,
|
|
1141
|
-
fallback: options.fallback ?? spec.write,
|
|
1142
|
-
};
|
|
1143
|
-
methods.POST = jsonWriteHandler(spec.write, writeOpts);
|
|
1144
|
-
methods.PUT = jsonWriteHandler(spec.write, writeOpts);
|
|
1145
|
-
methods.PATCH = jsonWriteHandler(spec.write, writeOpts);
|
|
1146
|
-
if (options.fallback) {
|
|
1147
|
-
methods.OPTIONS = fallbackHandler(options.fallback, routeOpts);
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
if (spec.echo) {
|
|
1152
|
-
methods.POST = echoHandler(spec.echo, routeOpts);
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
if (spec.cookies) {
|
|
1156
|
-
methods.GET = readHandler(spec.cookies, routeOpts);
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
serverRoutes[path] = methods;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
const serverOptions: Record<string, unknown> = {
|
|
1163
|
-
hostname: options.hostname ?? "0.0.0.0",
|
|
1164
|
-
port: options.port,
|
|
1165
|
-
idleTimeout: options.idleTimeout ?? 30,
|
|
1166
|
-
routes: serverRoutes,
|
|
1167
|
-
};
|
|
1168
|
-
|
|
1169
|
-
if (options.maxRequestBodySize !== undefined) {
|
|
1170
|
-
serverOptions.maxRequestBodySize = options.maxRequestBodySize;
|
|
1171
|
-
}
|
|
1172
|
-
if (options.reusePort) {
|
|
1173
|
-
serverOptions.reusePort = true;
|
|
838
|
+
zeroCopyResponse,
|
|
1174
839
|
}
|
|
1175
|
-
if (options.fallback) {
|
|
1176
|
-
serverOptions.fetch = fallbackHandler(options.fallback, baseOpts);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
let server: ReturnType<typeof Bun.serve>;
|
|
1180
|
-
try {
|
|
1181
|
-
server = Bun.serve(serverOptions as any);
|
|
1182
|
-
} catch (err) {
|
|
1183
|
-
if (options.reusePort) {
|
|
1184
|
-
delete serverOptions.reusePort;
|
|
1185
|
-
server = Bun.serve(serverOptions as any);
|
|
1186
|
-
} else {
|
|
1187
|
-
throw err;
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
return {
|
|
1192
|
-
server,
|
|
1193
|
-
stop: () => {
|
|
1194
|
-
try {
|
|
1195
|
-
server.stop(true);
|
|
1196
|
-
} catch {
|
|
1197
|
-
// already stopped
|
|
1198
|
-
}
|
|
1199
|
-
},
|
|
1200
|
-
port: options.port,
|
|
1201
|
-
};
|
|
1202
840
|
}
|