castrum 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -59
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +3161 -0
- package/dist/index.js +7306 -0
- package/docs/ARCHITECTURE.md +415 -0
- package/docs/BENCHMARKS.md +307 -0
- package/docs/ENVIRONMENT.md +79 -0
- package/docs/GETTING_STARTED.md +209 -0
- package/docs/INGRESS.md +745 -0
- package/docs/REPO_MAP.md +259 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +42 -0
- package/docs/bun-builtins-decision-matrix.md +108 -0
- package/index.ts +51 -8
- package/package.json +73 -20
- package/src/data/json-rows.ts +13 -13
- package/src/ingress/body.ts +149 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +563 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +67 -0
- package/src/ingress/index.ts +130 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +75 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +150 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +96 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/routes/common.ts +38 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +37 -0
- package/src/ingress/routes/index.ts +10 -0
- package/src/ingress/routes/json-write.ts +153 -0
- package/src/ingress/routes/options.ts +36 -0
- package/src/ingress/routes/read.ts +45 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +443 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +157 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +217 -0
- package/src/integration/streaming.ts +79 -0
- package/src/integration/websocket.ts +92 -0
- package/src/loader/batch.ts +133 -0
- package/src/loader/cost.ts +104 -0
- package/src/loader/index.ts +930 -0
- package/src/loader/ops.ts +657 -0
- package/src/native/ffi.ts +1541 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +194 -0
- package/src/native/types.ts +598 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch.ts +431 -0
- package/src/rust-ffi/client.ts +88 -0
- package/src/rust-ffi/context.ts +164 -0
- package/src/rust-ffi/index.ts +40 -541
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +42 -0
- package/src/rust-ffi/scalar/crypto.ts +265 -0
- package/src/rust-ffi/scalar/factories.ts +233 -0
- package/src/rust-ffi/scalar/hashing.ts +72 -0
- package/src/rust-ffi/scalar/http.ts +206 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +450 -0
- package/src/rust-ffi/scalar/json.ts +45 -0
- package/src/rust-ffi/scalar/payload.ts +124 -0
- package/src/rust-ffi/text.ts +95 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +77 -0
- package/src/shared/adaptive.ts +68 -0
- package/src/shared/bench-classify.ts +209 -0
- package/src/shared/buffer-pool.ts +209 -0
- package/src/shared/bytes.ts +35 -5
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +74 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed.ts +350 -154
- package/src/shared/proven.ts +596 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +34 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +31 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
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,360 @@
|
|
|
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, 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
|
-
ERR_CODE_SCHEMA_VALIDATION as ERROR_CODE_SCHEMA_VALIDATION,
|
|
61
|
-
ERR_CODE_BAD_REQUEST as ERROR_CODE_BAD_REQUEST,
|
|
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'
|
|
65
57
|
import {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
assertIngressOptionValues,
|
|
59
|
+
assertKnownIngressOptions,
|
|
60
|
+
type IngressHandlerOptions,
|
|
61
|
+
warnTrustProxyDeprecated,
|
|
62
|
+
} from './options'
|
|
63
|
+
import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
|
|
64
|
+
import { IngressInputPacker } from './packing/input-packer'
|
|
65
|
+
import { buildSecurityPairs } from './headers/hsts'
|
|
66
|
+
import type { SecurityHeadersOptions } from './headers/hsts'
|
|
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
|
+
echoHandler,
|
|
88
|
+
fallbackHandler,
|
|
89
|
+
headHandler,
|
|
90
|
+
jsonWriteHandler,
|
|
91
|
+
readHandler,
|
|
92
|
+
} from './routes'
|
|
93
|
+
export type {
|
|
94
|
+
BakedRoute,
|
|
95
|
+
BakedServer,
|
|
96
|
+
CreateIngressServerOptions,
|
|
97
|
+
GracefulShutdownOptions,
|
|
98
|
+
ServerHandle,
|
|
99
|
+
} from './server'
|
|
100
|
+
export { createIngressServer, gracefulShutdown } from './server'
|
|
101
|
+
export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
|
|
102
|
+
// Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
|
|
103
|
+
export { createIngressServerNode } from './server-node'
|
|
104
|
+
|
|
105
|
+
const encoder = new TextEncoder()
|
|
106
|
+
const EMPTY_BODY = new Uint8Array(0)
|
|
107
|
+
const EMPTY_IP = '0.0.0.0'
|
|
77
108
|
|
|
78
109
|
/** 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;
|
|
110
|
+
export const RATE_LIMIT_U32_MAX = 4_294_967_295
|
|
86
111
|
|
|
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
|
-
}
|
|
112
|
+
/**
|
|
113
|
+
* Consecutive `bun:ffi` ingress failures before this handler permanently
|
|
114
|
+
* falls back to the napi addon (a panic that `panic_guard` contained, or a
|
|
115
|
+
* bun:ffi regression that throws). Low enough to recover fast from a flaky
|
|
116
|
+
* transport, high enough that a single transient failure never flips it.
|
|
117
|
+
*/
|
|
118
|
+
const MAX_FFI_FAILURES = 3
|
|
266
119
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Best-effort URL pathname extraction for log/error lines.
|
|
122
|
+
*
|
|
123
|
+
* This runs inside catch/finally paths, so a malformed `req.url` must never
|
|
124
|
+
* throw out of the handler (an uncaught error here would bypass `onError`).
|
|
125
|
+
* Falls back to the raw URL string when parsing fails.
|
|
126
|
+
*/
|
|
127
|
+
function pathForLog(req: Request): string {
|
|
128
|
+
try {
|
|
129
|
+
return new URL(req.url).pathname
|
|
130
|
+
} catch {
|
|
131
|
+
return req.url
|
|
275
132
|
}
|
|
133
|
+
}
|
|
276
134
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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()]
|
|
290
159
|
}
|
|
291
160
|
|
|
292
161
|
// ── Types ────────────────────────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
162
|
+
// `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
|
|
163
|
+
// (shared with the route factories) and re-exported here for back-compat.
|
|
164
|
+
export type { BakedContext, OptimizedIngressHandler } from './types'
|
|
297
165
|
|
|
298
166
|
export interface BakedIngressRuntime {
|
|
299
167
|
/** Emit an `x-request-id` header on responses. Default: false. */
|
|
300
|
-
emitRequestIdHeader?: boolean
|
|
168
|
+
emitRequestIdHeader?: boolean
|
|
301
169
|
/** Enable security headers on responses. Default: true. */
|
|
302
|
-
enableSecurityHeaders?: boolean
|
|
303
|
-
/**
|
|
304
|
-
|
|
170
|
+
enableSecurityHeaders?: boolean
|
|
171
|
+
/**
|
|
172
|
+
* Ordered `[name, value]` security headers (names are lowercased).
|
|
173
|
+
* NOTE: this PRE-BAKED path takes RAW `[name, value]` pairs here; the
|
|
174
|
+
* fast/async paths (`createIngressFast`/`createIngress`) instead take a
|
|
175
|
+
* structured `security: SecurityHeadersOptions` object (CSP/HSTS builders).
|
|
176
|
+
* The two paths intentionally use different option shapes (see
|
|
177
|
+
* docs/INGRESS.md).
|
|
178
|
+
*/
|
|
179
|
+
securityHeaders?: ReadonlyArray<[string, string]>
|
|
305
180
|
/** Native output buffer size in bytes. Default: 131072. */
|
|
306
|
-
outputBufferSize?: number
|
|
181
|
+
outputBufferSize?: number
|
|
182
|
+
/** Invoked before a request is processed (for tracing/context hooks). */
|
|
183
|
+
onRequest?: (req: Request, requestId: string, ip: string | undefined) => void
|
|
307
184
|
/** Invoked after a Response is produced (for metrics/logging hooks). */
|
|
308
|
-
onResponse?: (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
) => void
|
|
185
|
+
onResponse?: (req: Request, result: BakedIngressResult, status: number, requestId: string) => void
|
|
186
|
+
/**
|
|
187
|
+
* Invoked when the native pipeline fails (the request becomes a 500).
|
|
188
|
+
* Native failures are otherwise silent — wire this to your error tracker.
|
|
189
|
+
*/
|
|
190
|
+
onError?: (req: Request, requestId: string, error: Error) => void
|
|
191
|
+
/**
|
|
192
|
+
* Emit one structured JSON line per request/error via the built-in logger
|
|
193
|
+
* (gated by `CASTRUM_LOG_LEVEL`). Default: false.
|
|
194
|
+
*/
|
|
195
|
+
structuredLog?: boolean
|
|
196
|
+
/**
|
|
197
|
+
* Custom structured logger (e.g. with a custom stream or extra fields).
|
|
198
|
+
* When provided, request/error lines go here instead of the built-in stderr
|
|
199
|
+
* logger and `structuredLog` is implicitly enabled.
|
|
200
|
+
*/
|
|
201
|
+
logger?: ReturnType<typeof createStructuredLogger>
|
|
202
|
+
/**
|
|
203
|
+
* Hard cap on concurrently borrowed output buffers (zero-copy responses not
|
|
204
|
+
* yet consumed). Bounds memory when zero-copy responses are consumed
|
|
205
|
+
* slowly; when exceeded, requests fail with a 500 (pool exhaustion) instead
|
|
206
|
+
* of allocating unbounded temporaries. 0 (default) is unlimited — see
|
|
207
|
+
* `BufferPool.maxInFlight`.
|
|
208
|
+
*/
|
|
209
|
+
maxInFlight?: number
|
|
210
|
+
/**
|
|
211
|
+
* Abandonment guard (ms) for zero-copy responses: if a response body is
|
|
212
|
+
* neither pulled nor cancelled within this window, its pooled buffer is
|
|
213
|
+
* released and the stream closed. Bounds memory under abandoned (unread)
|
|
214
|
+
* zero-copy responses. 0 (default) disables it — see `pooledBodyResponse`.
|
|
215
|
+
*/
|
|
216
|
+
zeroCopyTimeoutMs?: number
|
|
314
217
|
}
|
|
315
218
|
|
|
219
|
+
// ── Optimized ingress factory ─────────────────────────────────────
|
|
220
|
+
|
|
316
221
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
222
|
+
* Create a pre-baked ingress handler (path 2).
|
|
223
|
+
*
|
|
224
|
+
* The request frame (url/ip/rid/headers) is packed in JS via `IngressInputPacker`
|
|
225
|
+
* + `gatherRawHeadersPacked` and driven through the same native core as the
|
|
226
|
+
* fast path (`Ingress.handleRequestPacked`), writing into a pooled output
|
|
227
|
+
* buffer (no per-request allocation). Responses use the benchmark wire format
|
|
228
|
+
* (`{"ok":false,"error":{...}}` + `ratelimit-*` headers) — see AGENTS.md for
|
|
229
|
+
* why this differs from `createIngressFast`.
|
|
230
|
+
*
|
|
231
|
+
* Returns an `OptimizedIngressHandler` whose response-builder methods are
|
|
232
|
+
* bound to this handler's configuration. Pair it with the route factories
|
|
233
|
+
* (`readHandler`, `jsonWriteHandler`, ...) or `createIngressServer`.
|
|
321
234
|
*/
|
|
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
235
|
export function createIngressHandler(
|
|
370
|
-
options:
|
|
236
|
+
options: IngressHandlerOptions = {},
|
|
371
237
|
runtime: BakedIngressRuntime = {},
|
|
372
238
|
): OptimizedIngressHandler {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
239
|
+
// Fail fast on a misspelled option key — same guard as the fast path.
|
|
240
|
+
assertKnownIngressOptions(options, 'createIngressHandler')
|
|
241
|
+
assertIngressOptionValues(options, 'createIngressHandler')
|
|
242
|
+
if (options.trustProxy === true) {
|
|
243
|
+
warnTrustProxyDeprecated()
|
|
376
244
|
}
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
);
|
|
245
|
+
// Lazy: the native addon is only needed once a handler is created.
|
|
246
|
+
const addon = getAddon()
|
|
247
|
+
const NativeIngress = addon.Ingress
|
|
248
|
+
if (typeof NativeIngress !== 'function') {
|
|
249
|
+
throw new Error('Native Ingress class missing. Rebuild the Rust addon.')
|
|
383
250
|
}
|
|
384
251
|
|
|
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
|
-
};
|
|
252
|
+
const handler = new NativeIngress(options)
|
|
253
|
+
if (typeof handler.handleRequestPacked !== 'function') {
|
|
254
|
+
throw new Error('Native Ingress.handleRequestPacked missing. Rebuild the Rust addon.')
|
|
255
|
+
}
|
|
397
256
|
|
|
398
|
-
|
|
399
|
-
|
|
257
|
+
// Bun fast path: drive the SAME native pipeline through `bun:ffi` (opaque
|
|
258
|
+
// inner handle from `ingressInnerPtr`), cutting the per-request N-API
|
|
259
|
+
// crossing. The handle is valid only while `handler` is alive — this closure
|
|
260
|
+
// holds `handler` for the handler's lifetime, so it can never dangle. Falls
|
|
261
|
+
// back to napi when ffi is unavailable, the addon lacks the pointer method,
|
|
262
|
+
// or the handle is 0 (i.e. the state was dropped).
|
|
263
|
+
const bunFFI = getBunFFI()
|
|
264
|
+
// The opaque handle is a u64 under the 2^53 number range, so convert it to a
|
|
265
|
+
// plain JS number ONCE here — bun:ffi converts a BigInt argument to a number
|
|
266
|
+
// on every call anyway (and docs note BigInt is slower), so passing a number
|
|
267
|
+
// removes that per-request conversion on the hottest path.
|
|
268
|
+
const ingressPtr =
|
|
269
|
+
bunFFI !== null && typeof handler.ingressInnerPtr === 'function'
|
|
270
|
+
? Number(handler.ingressInnerPtr())
|
|
271
|
+
: 0
|
|
272
|
+
|
|
273
|
+
const rateLimit = options.rateLimit as { limit?: number } | undefined
|
|
274
|
+
const limit = rateLimit?.limit
|
|
275
|
+
const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
|
|
276
|
+
|
|
277
|
+
// Shared with the fast path — proxy extraction is driven by trust config
|
|
278
|
+
// (trustProxy / trustedProxies), not by whether rate limiting is enabled.
|
|
279
|
+
const headerPlan: HeaderPlan = buildHeaderPlan(options)
|
|
280
|
+
|
|
281
|
+
const emitRequestIdHeader = runtime.emitRequestIdHeader === true
|
|
282
|
+
const zeroCopyTimeoutMs = runtime.zeroCopyTimeoutMs ?? 0
|
|
283
|
+
const outputBufferSize = clampIngressBufferSize(
|
|
284
|
+
runtime.outputBufferSize ?? DEFAULT_BAKED_OUTPUT_BUFFER_SIZE,
|
|
285
|
+
OUT_DATA_START,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
// Built-in structured logger (opt-in; gated by CASTRUM_LOG_LEVEL). A custom
|
|
289
|
+
// `runtime.logger` overrides it and implies structuredLog:true.
|
|
290
|
+
const logger =
|
|
291
|
+
runtime.logger ?? (runtime.structuredLog === true ? createStructuredLogger() : null)
|
|
292
|
+
|
|
293
|
+
// Only materialize the request-id string when a consumer needs it, avoiding
|
|
294
|
+
// one string allocation + UTF-8 decode per request in deployments with no
|
|
295
|
+
// hooks/logging. The bench wires `onResponse`, so the decode stays hot there;
|
|
296
|
+
// lean servers with no hooks skip it entirely.
|
|
297
|
+
const needRequestIdString = !!(
|
|
298
|
+
emitRequestIdHeader ||
|
|
299
|
+
runtime.onRequest ||
|
|
300
|
+
runtime.onError ||
|
|
301
|
+
runtime.onResponse ||
|
|
302
|
+
logger
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
// Reusable output-buffer pool: eliminates the per-request output-buffer
|
|
306
|
+
// allocation by reusing buffers across requests.
|
|
307
|
+
// `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
|
|
308
|
+
const outputPool = new BufferPool({
|
|
309
|
+
initialSize: outputBufferSize,
|
|
310
|
+
maxInFlight: runtime.maxInFlight,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// Reusable packed-input builder (same zero-alloc discipline as the fast
|
|
314
|
+
// path): the per-request frame is packed into this buffer instead of paying
|
|
315
|
+
// the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
|
|
316
|
+
const inputPacker = new IngressInputPacker()
|
|
317
|
+
|
|
318
|
+
// Per-call state: the output handle backing the current `run()`'s result,
|
|
319
|
+
// and whether a zero-copy Response claimed it (so `run()` must not release
|
|
320
|
+
// it back to the pool before the body is consumed).
|
|
321
|
+
let currentHandle: PooledBuffer | null = null
|
|
322
|
+
let responseBorrowsBuffer = false
|
|
323
|
+
|
|
324
|
+
// FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
|
|
325
|
+
// non-panic UB (a segfault still crashes the process), but a RECURRING
|
|
326
|
+
// observable failure — a panic that `panic_guard` turned into 0 → throw, or
|
|
327
|
+
// a bun:ffi regression that throws — is caught here and permanently
|
|
328
|
+
// re-routed to the napi fallback (crash-safe via napi-rs catch_unwind), so a
|
|
329
|
+
// flaky FFI path degrades to a correct response instead of a failure storm.
|
|
330
|
+
let ffiFailures = 0
|
|
331
|
+
let ffiDisabled = false
|
|
332
|
+
|
|
333
|
+
// Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
|
|
334
|
+
// steady-state CORS case: the pre-baked template + one
|
|
335
|
+
// `access-control-allow-origin` row). Callers treat the returned array as
|
|
336
|
+
// read-only (the `Headers`/`Response` constructors copy entries), so every
|
|
337
|
+
// distinct (variant, origin) is reused after its first hit — removing the
|
|
338
|
+
// per-request array alloc + copy when an Origin header is present. Only
|
|
339
|
+
// ALLOWED origins reach the CORS-allowed path (the native pipeline gates the
|
|
340
|
+
// variant on CORS approval), so the map stays bounded by the configured
|
|
341
|
+
// allow-origin list (+ a couple of preflight variants).
|
|
342
|
+
const originHeaderCache = new Map<string, [string, string][]>()
|
|
400
343
|
|
|
401
344
|
// ── Variant-indexed header templates (precomputed once) ──
|
|
402
345
|
const cors = options.cors as
|
|
403
346
|
| {
|
|
404
|
-
allowOrigin?: string[]
|
|
405
|
-
allowMethods?: string[]
|
|
406
|
-
allowHeaders?: string[]
|
|
407
|
-
exposeHeaders?: string[]
|
|
408
|
-
allowCredentials?: boolean
|
|
409
|
-
maxAge?: number
|
|
347
|
+
allowOrigin?: string[]
|
|
348
|
+
allowMethods?: string[]
|
|
349
|
+
allowHeaders?: string[]
|
|
350
|
+
exposeHeaders?: string[]
|
|
351
|
+
allowCredentials?: boolean
|
|
352
|
+
maxAge?: number
|
|
410
353
|
}
|
|
411
|
-
| undefined
|
|
412
|
-
|
|
354
|
+
| undefined
|
|
355
|
+
|
|
356
|
+
// Security headers: honor the STRUCTURED `options.security`
|
|
357
|
+
// (SecurityHeadersOptions — the same shape `createIngress` honors) merged
|
|
358
|
+
// with raw `runtime.securityHeaders` pairs (raw wins on name conflicts).
|
|
359
|
+
// This closes the historic gotcha where `options.security` was silently
|
|
360
|
+
// ignored on the pre-baked path. When neither is provided, no security
|
|
361
|
+
// headers are emitted (the baked path's long-standing default).
|
|
413
362
|
const securityEntries: ReadonlyArray<[string, string]> =
|
|
414
363
|
runtime.enableSecurityHeaders === false
|
|
415
364
|
? Object.freeze([] as [string, string][])
|
|
416
365
|
: 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
|
-
);
|
|
366
|
+
buildBakedSecurityEntries(options.security, options.https, runtime.securityHeaders),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
const corsAllowMethods = cors?.allowMethods?.join(', ') ?? ''
|
|
370
|
+
const corsAllowHeaders = cors?.allowHeaders?.join(', ') ?? ''
|
|
371
|
+
const corsExposeHeaders = cors?.exposeHeaders?.join(', ') ?? ''
|
|
372
|
+
const corsMaxAge = cors?.maxAge != null ? String(cors.maxAge) : ''
|
|
373
|
+
const rateLimitStr = rateEnabled ? String(limit) : ''
|
|
374
|
+
|
|
375
|
+
const { regular: headerTemplates, terminal: terminalTemplates } = buildBakedHeaderTemplates({
|
|
376
|
+
securityEntries,
|
|
377
|
+
cors,
|
|
378
|
+
corsAllowMethods,
|
|
379
|
+
corsAllowHeaders,
|
|
380
|
+
corsExposeHeaders,
|
|
381
|
+
corsMaxAge,
|
|
382
|
+
rateLimitStr,
|
|
383
|
+
})
|
|
467
384
|
|
|
468
385
|
function responseHeaders(
|
|
469
386
|
variant: number,
|
|
@@ -474,51 +391,70 @@ export function createIngressHandler(
|
|
|
474
391
|
retryAfterSecs?: number,
|
|
475
392
|
): [string, string][] {
|
|
476
393
|
const template: ReadonlyArray<[string, string]> =
|
|
477
|
-
headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
394
|
+
headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
478
395
|
|
|
479
|
-
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
396
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
480
397
|
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;
|
|
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
|
|
486
401
|
|
|
487
402
|
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
488
|
-
return template as unknown as [string, string][]
|
|
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
|
|
489
425
|
}
|
|
490
426
|
|
|
491
|
-
let extra = 0
|
|
492
|
-
if (needsRequestId) extra
|
|
493
|
-
if (needsOrigin) extra
|
|
494
|
-
if (needsRate) extra += 2
|
|
495
|
-
if (needsRetry) extra
|
|
427
|
+
let extra = 0
|
|
428
|
+
if (needsRequestId) extra++
|
|
429
|
+
if (needsOrigin) extra++
|
|
430
|
+
if (needsRate) extra += 2
|
|
431
|
+
if (needsRetry) extra++
|
|
496
432
|
|
|
497
|
-
const entries = new Array<[string, string]>(template.length + extra)
|
|
498
|
-
let i = 0
|
|
433
|
+
const entries = new Array<[string, string]>(template.length + extra)
|
|
434
|
+
let i = 0
|
|
499
435
|
|
|
500
|
-
for (
|
|
501
|
-
entries[i] =
|
|
436
|
+
for (const pair of template) {
|
|
437
|
+
entries[i++] = pair
|
|
502
438
|
}
|
|
503
439
|
|
|
504
440
|
if (needsRequestId) {
|
|
505
|
-
entries[i++] = [
|
|
441
|
+
entries[i++] = ['x-request-id', requestIdHeader as string]
|
|
506
442
|
}
|
|
507
443
|
|
|
508
444
|
if (needsOrigin) {
|
|
509
|
-
entries[i++] = [
|
|
445
|
+
entries[i++] = ['access-control-allow-origin', origin as string]
|
|
510
446
|
}
|
|
511
447
|
|
|
512
448
|
if (needsRate) {
|
|
513
|
-
entries[i++] = [
|
|
514
|
-
entries[i++] = [
|
|
449
|
+
entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
|
|
450
|
+
entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
|
|
515
451
|
}
|
|
516
452
|
|
|
517
453
|
if (needsRetry) {
|
|
518
|
-
entries[i++] = [
|
|
454
|
+
entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
|
|
519
455
|
}
|
|
520
456
|
|
|
521
|
-
return entries
|
|
457
|
+
return entries
|
|
522
458
|
}
|
|
523
459
|
|
|
524
460
|
function terminalHeaders(
|
|
@@ -526,37 +462,56 @@ export function createIngressHandler(
|
|
|
526
462
|
ctx: BakedContext,
|
|
527
463
|
result: BakedIngressResult | null,
|
|
528
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`.
|
|
529
487
|
const base = responseHeaders(
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
488
|
+
v,
|
|
489
|
+
requestIdHeader,
|
|
490
|
+
origin,
|
|
533
491
|
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]!;
|
|
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
|
|
545
500
|
}
|
|
546
|
-
out[base.length] = [
|
|
547
|
-
return out
|
|
501
|
+
out[base.length] = ['cache-control', 'no-store']
|
|
502
|
+
return out
|
|
548
503
|
}
|
|
549
504
|
|
|
550
505
|
function terminalResponse(
|
|
551
|
-
_req: Request,
|
|
506
|
+
_req: Request | undefined,
|
|
552
507
|
result: BakedIngressResult,
|
|
553
508
|
ctx: BakedContext,
|
|
554
509
|
): Response | null {
|
|
555
510
|
if (!result.terminal) {
|
|
556
|
-
return null
|
|
511
|
+
return null
|
|
557
512
|
}
|
|
558
513
|
|
|
559
|
-
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
514
|
+
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
560
515
|
|
|
561
516
|
if (preflightAllowed) {
|
|
562
517
|
return new Response(null, {
|
|
@@ -566,31 +521,27 @@ export function createIngressHandler(
|
|
|
566
521
|
ctx.requestIdHeader,
|
|
567
522
|
ctx.origin,
|
|
568
523
|
result.rateRemaining,
|
|
569
|
-
result.rateResetMs > 0
|
|
570
|
-
|
|
571
|
-
: undefined,
|
|
572
|
-
result.retryAfterMs > 0
|
|
573
|
-
? Math.ceil(result.retryAfterMs / 1000)
|
|
574
|
-
: undefined,
|
|
524
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
525
|
+
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
575
526
|
),
|
|
576
|
-
})
|
|
527
|
+
})
|
|
577
528
|
}
|
|
578
529
|
|
|
579
|
-
const status = safeTerminalStatus(result)
|
|
530
|
+
const status = safeTerminalStatus(result)
|
|
580
531
|
|
|
581
532
|
const body: Uint8Array =
|
|
582
533
|
result.errorCode === ERROR_CODE_RATE_LIMITED
|
|
583
534
|
? rateLimitedBody(result.retryAfterMs)
|
|
584
|
-
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal
|
|
535
|
+
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
|
|
585
536
|
|
|
586
537
|
return new Response(body, {
|
|
587
538
|
status,
|
|
588
539
|
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
589
|
-
})
|
|
540
|
+
})
|
|
590
541
|
}
|
|
591
542
|
|
|
592
543
|
function errorResponse(
|
|
593
|
-
_req: Request,
|
|
544
|
+
_req: Request | undefined,
|
|
594
545
|
result: BakedIngressResult | null,
|
|
595
546
|
status: number,
|
|
596
547
|
code: string,
|
|
@@ -598,46 +549,58 @@ export function createIngressHandler(
|
|
|
598
549
|
ctx: BakedContext,
|
|
599
550
|
): Response {
|
|
600
551
|
const body =
|
|
601
|
-
ERROR_BODIES[code] ??
|
|
602
|
-
encoder.encode(JSON.stringify({ ok: false, error: { code, message } }));
|
|
552
|
+
ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
|
|
603
553
|
|
|
604
554
|
return new Response(body, {
|
|
605
555
|
status,
|
|
606
556
|
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
607
|
-
})
|
|
557
|
+
})
|
|
608
558
|
}
|
|
609
559
|
|
|
610
|
-
function internalErrorResponse(
|
|
611
|
-
ctx: BakedContext,
|
|
612
|
-
result?: BakedIngressResult,
|
|
613
|
-
): Response {
|
|
560
|
+
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
614
561
|
return new Response(ERROR_BODIES.internal, {
|
|
615
562
|
status: 500,
|
|
616
|
-
headers: terminalHeaders(
|
|
617
|
-
|
|
618
|
-
ctx,
|
|
619
|
-
result ?? null,
|
|
620
|
-
),
|
|
621
|
-
});
|
|
563
|
+
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
564
|
+
})
|
|
622
565
|
}
|
|
623
566
|
|
|
624
567
|
function withContentType(
|
|
625
568
|
headers: ReadonlyArray<[string, string]>,
|
|
626
569
|
contentType: string,
|
|
627
570
|
): [string, string][] {
|
|
628
|
-
const out = new Array<[string, string]>(headers.length + 1)
|
|
629
|
-
|
|
630
|
-
|
|
571
|
+
const out = new Array<[string, string]>(headers.length + 1)
|
|
572
|
+
let i = 0
|
|
573
|
+
for (const pair of headers) {
|
|
574
|
+
out[i++] = pair
|
|
631
575
|
}
|
|
632
|
-
out[headers.length] = [
|
|
633
|
-
return out
|
|
576
|
+
out[headers.length] = ['content-type', contentType]
|
|
577
|
+
return out
|
|
634
578
|
}
|
|
635
579
|
|
|
636
|
-
|
|
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
|
+
}
|
|
598
|
+
|
|
599
|
+
const result = new BakedIngressResult()
|
|
637
600
|
const ctx: BakedContext = {
|
|
638
601
|
requestIdHeader: null,
|
|
639
602
|
origin: null,
|
|
640
|
-
}
|
|
603
|
+
}
|
|
641
604
|
|
|
642
605
|
function run<T>(
|
|
643
606
|
req: Request,
|
|
@@ -645,98 +608,146 @@ export function createIngressHandler(
|
|
|
645
608
|
body: Uint8Array | null,
|
|
646
609
|
fn: (result: BakedIngressResult, ctx: BakedContext) => T,
|
|
647
610
|
): 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
|
-
}
|
|
611
|
+
const startedAt = logger ? performance.now() : 0
|
|
612
|
+
const methodKind = METHOD_KIND[req.method] ?? METHOD_KIND_UNKNOWN
|
|
613
|
+
const ridBytes = generateRequestId()
|
|
614
|
+
const requestIdStr = needRequestIdString ? decoder.decode(ridBytes) : ''
|
|
667
615
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (originV !== null && originV.length <= MAX_SMALL_HEADER_BYTES) {
|
|
671
|
-
headers.push(["origin", originV]);
|
|
672
|
-
}
|
|
616
|
+
ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
|
|
617
|
+
ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
|
|
673
618
|
|
|
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
|
-
}
|
|
619
|
+
runtime.onRequest?.(req, requestIdStr, ip)
|
|
679
620
|
|
|
680
|
-
|
|
681
|
-
if (acrh !== null && acrh.length <= MAX_SMALL_HEADER_BYTES) {
|
|
682
|
-
headers.push(["access-control-request-headers", acrh]);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
621
|
+
const ipStr = ip ?? EMPTY_IP
|
|
686
622
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
623
|
+
// Pack the request frame in JS (reusing the fast path's zero-alloc packer
|
|
624
|
+
// discipline) and drive the same native core as `handleRequestPacked`.
|
|
625
|
+
// This removes the per-request `[name, value][]` header marshaling + napi
|
|
626
|
+
// `Vec<Vec<String>>` and the request-id string re-encode that the
|
|
627
|
+
// full_sync family paid on every request. The response wire format is
|
|
628
|
+
// unchanged because both entries run the identical `handle_packed` core.
|
|
629
|
+
let handle: PooledBuffer | null = null
|
|
630
|
+
currentHandle = null
|
|
631
|
+
responseBorrowsBuffer = false
|
|
692
632
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
633
|
+
try {
|
|
634
|
+
// Pack the request frame (inside the try: an unexpected packing failure
|
|
635
|
+
// is reported by the escaping-error catch below — it must never leak the
|
|
636
|
+
// request without accounting). `ctx.origin` was already fetched once
|
|
637
|
+
// above, so pass it in to avoid a second `req.headers.get('origin')`
|
|
638
|
+
// native→JS string conversion on the hot path.
|
|
639
|
+
const packedHeaders = gatherRawHeadersPacked(req, headerPlan, methodKind, ctx.origin)
|
|
640
|
+
// url/ip are encoded directly into the packer buffer (no intermediate
|
|
641
|
+
// `encoder.encode` Uint8Array + copy); requestId/headers are byte slices
|
|
642
|
+
// copied verbatim (no decode→re-encode of the pre-encoded request id).
|
|
643
|
+
const input = inputPacker.packParts(methodKind, req.url, ip, ridBytes, packedHeaders)
|
|
698
644
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
645
|
+
try {
|
|
646
|
+
// Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
|
|
647
|
+
// via setInternalError — never an uncaught exception out of run().
|
|
648
|
+
handle = outputPool.acquire(outputBufferSize)
|
|
649
|
+
currentHandle = handle
|
|
650
|
+
|
|
651
|
+
// Write into the pooled buffer — no per-request allocation. Under Bun
|
|
652
|
+
// with a live ffi handle this runs the pipeline through `bun:ffi`
|
|
653
|
+
// (identical packed wire format); otherwise the napi call. If the ffi
|
|
654
|
+
// call throws (native reported 0 = error, or a panic `panic_guard`
|
|
655
|
+
// contained), re-dispatch through napi once and, after repeated
|
|
656
|
+
// failures, permanently disable ffi for this handler.
|
|
657
|
+
let written: number
|
|
658
|
+
if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
|
|
659
|
+
try {
|
|
660
|
+
written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
|
|
661
|
+
ffiFailures = 0
|
|
662
|
+
} catch {
|
|
663
|
+
ffiFailures++
|
|
664
|
+
if (ffiFailures >= MAX_FFI_FAILURES) {
|
|
665
|
+
ffiDisabled = true
|
|
666
|
+
}
|
|
667
|
+
// napi re-run: crash-safe (napi-rs catch_unwind) and semantically
|
|
668
|
+
// identical — a transient ffi panic still serves the request.
|
|
669
|
+
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
670
|
+
}
|
|
671
|
+
} else {
|
|
672
|
+
written = handler.handleRequestPacked(input, body, handle.buffer)
|
|
673
|
+
}
|
|
705
674
|
|
|
706
|
-
|
|
675
|
+
const used = handle.buffer.subarray(0, written)
|
|
676
|
+
// Cached per-ArrayBuffer DataView: no per-request view allocation.
|
|
677
|
+
result.refresh(used, body ?? EMPTY_BODY, viewForArrayBuffer(used.buffer, used.byteOffset))
|
|
678
|
+
} catch (err) {
|
|
679
|
+
result.setInternalError()
|
|
680
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
681
|
+
runtime.onError?.(req, requestIdStr, error)
|
|
682
|
+
logger?.error({
|
|
683
|
+
requestId: requestIdStr,
|
|
684
|
+
method: req.method,
|
|
685
|
+
path: pathForLog(req),
|
|
686
|
+
code: 'internal',
|
|
687
|
+
message: error.message,
|
|
688
|
+
})
|
|
689
|
+
}
|
|
707
690
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
691
|
+
try {
|
|
692
|
+
const out = fn(result, ctx)
|
|
693
|
+
// The ingress result is invalidated when run() returns, so an async
|
|
694
|
+
// callback would observe a stale/zeroed result — reject it up front
|
|
695
|
+
// (parity with the fast path / createIngressSync).
|
|
696
|
+
assertSyncCallback(out, 'createIngressHandler().run()')
|
|
697
|
+
// Report observability for every outcome. Non-Response callbacks
|
|
698
|
+
// (e.g. echoHandler's object return) fall back to the decoded terminal
|
|
699
|
+
// status so they are not silently invisible to onResponse/logging.
|
|
700
|
+
const status = out instanceof Response ? out.status : safeTerminalStatus(result)
|
|
701
|
+
runtime.onResponse?.(req, result, status, requestIdStr)
|
|
702
|
+
if (logger) {
|
|
703
|
+
logger.request({
|
|
704
|
+
requestId: requestIdStr,
|
|
705
|
+
method: req.method,
|
|
706
|
+
path: pathForLog(req),
|
|
707
|
+
status,
|
|
708
|
+
durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
|
|
709
|
+
ip: ipStr,
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
return out
|
|
713
|
+
} finally {
|
|
714
|
+
result.invalidate()
|
|
715
|
+
ctx.requestIdHeader = null
|
|
716
|
+
ctx.origin = null
|
|
717
|
+
}
|
|
726
718
|
} catch (err) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
|
|
719
|
+
// An unexpected error escaped run() after onRequest (a packing failure
|
|
720
|
+
// before the native call, or a throwing route callback). Report it so
|
|
721
|
+
// request accounting completes (metrics in_flight must not leak), then
|
|
722
|
+
// rethrow for the caller. The native-failure path above does NOT rethrow,
|
|
723
|
+
// so this fires at most once per request.
|
|
724
|
+
if (responseBorrowsBuffer && handle !== null) {
|
|
725
|
+
// `zeroCopyResponse` was called but the Response never escaped (the
|
|
726
|
+
// callback threw): the stream that would release the pooled buffer was
|
|
727
|
+
// never delivered. Release it here so the buffer isn't stuck in flight
|
|
728
|
+
// forever.
|
|
729
|
+
handle.release()
|
|
730
|
+
handle = null
|
|
731
|
+
responseBorrowsBuffer = false
|
|
734
732
|
}
|
|
735
|
-
|
|
733
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
734
|
+
runtime.onError?.(req, requestIdStr, error)
|
|
735
|
+
logger?.error({
|
|
736
|
+
requestId: requestIdStr,
|
|
737
|
+
method: req.method,
|
|
738
|
+
path: pathForLog(req),
|
|
739
|
+
code: 'internal',
|
|
740
|
+
message: error.message,
|
|
741
|
+
})
|
|
742
|
+
throw error
|
|
736
743
|
} finally {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
744
|
+
// A zero-copy Response owns the handle until its body is consumed; in
|
|
745
|
+
// every other case (copy mode, terminal/error responses) the buffer is
|
|
746
|
+
// safe to return to the pool immediately.
|
|
747
|
+
if (handle !== null && !responseBorrowsBuffer) {
|
|
748
|
+
handle.release()
|
|
749
|
+
}
|
|
750
|
+
currentHandle = null
|
|
740
751
|
}
|
|
741
752
|
}
|
|
742
753
|
|
|
@@ -748,455 +759,6 @@ export function createIngressHandler(
|
|
|
748
759
|
errorResponse,
|
|
749
760
|
internalErrorResponse,
|
|
750
761
|
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;
|
|
762
|
+
zeroCopyResponse,
|
|
1174
763
|
}
|
|
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
764
|
}
|