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/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castrum",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"main": "index.
|
|
8
|
-
"module": "index.
|
|
9
|
-
"types": "index.ts",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"module": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"keywords": [
|
|
12
12
|
"bun",
|
|
@@ -25,70 +25,135 @@
|
|
|
25
25
|
"type": "git",
|
|
26
26
|
"url": "git+https://github.com/Axylabs/flux-rs.git"
|
|
27
27
|
},
|
|
28
|
+
"homepage": "https://github.com/Axylabs/flux-rs#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/Axylabs/flux-rs/issues"
|
|
31
|
+
},
|
|
28
32
|
"publishConfig": {
|
|
29
33
|
"access": "public"
|
|
30
34
|
},
|
|
31
35
|
"exports": {
|
|
32
|
-
".":
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"bun": "./index.ts",
|
|
39
|
+
"node": "./dist/index.js",
|
|
40
|
+
"default": "./dist/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
33
43
|
},
|
|
34
44
|
"files": [
|
|
35
45
|
"index.ts",
|
|
36
|
-
"
|
|
46
|
+
"dist",
|
|
37
47
|
"src",
|
|
38
|
-
"
|
|
39
|
-
"Cargo.toml",
|
|
48
|
+
"docs",
|
|
40
49
|
"README.md",
|
|
41
|
-
"*.node"
|
|
50
|
+
"*.node",
|
|
51
|
+
"!src/bench",
|
|
52
|
+
"!src/baseline",
|
|
53
|
+
"!src/data"
|
|
42
54
|
],
|
|
43
55
|
"scripts": {
|
|
44
56
|
"build": "napi build --release --platform",
|
|
57
|
+
"build:perf": "bash scripts/build-perf.sh",
|
|
58
|
+
"build:v3": "bash scripts/build-v3.sh",
|
|
59
|
+
"build:all": "bun run build && bun run build:v3",
|
|
60
|
+
"build:js": "bun run build:js:bundle && bun run build:js:types",
|
|
61
|
+
"build:js:bundle": "bun build ./index.ts --target=node --format=esm --outdir=dist",
|
|
62
|
+
"build:js:types": "dts-bundle-generator -o dist/index.d.ts index.ts",
|
|
45
63
|
"bench": "bun run build && bun bench.ts",
|
|
46
64
|
"check": "bun bench.ts",
|
|
47
65
|
"test": "bun test",
|
|
66
|
+
"test:all": "bun test && bun run typecheck && bun run typecheck:test && bun run lint:ci && bun run check:clean && cargo test --lib",
|
|
67
|
+
"test:coverage": "node scripts/check-coverage.mjs",
|
|
68
|
+
"setup": "bun install && bun run build",
|
|
69
|
+
"test:node": "node --test test/integration/node-smoke.test.mjs test/integration/node-enterprise.test.mjs",
|
|
70
|
+
"verify:install": "node scripts/verify-install.mjs",
|
|
48
71
|
"build:debug": "napi build --platform",
|
|
49
|
-
"
|
|
72
|
+
"lint": "bunx biome check .",
|
|
73
|
+
"lint:fix": "bunx biome check --write .",
|
|
74
|
+
"lint:ci": "bunx biome check . --formatter-enabled=false --assist-enabled=false --error-on-warnings",
|
|
75
|
+
"format": "bunx biome format --write .",
|
|
76
|
+
"typecheck": "bunx tsc --noEmit",
|
|
77
|
+
"typecheck:test": "bunx tsc --noEmit -p tsconfig.test.json",
|
|
78
|
+
"test:rust": "cargo test",
|
|
79
|
+
"deny": "cargo deny check",
|
|
80
|
+
"check:version": "bun scripts/check-version.mjs",
|
|
81
|
+
"check:jsdoc": "bun scripts/check-jsdoc.ts",
|
|
82
|
+
"check:selection": "bun scripts/select-native.ts --check",
|
|
83
|
+
"check:clean": "bun scripts/check-clean.ts",
|
|
84
|
+
"audit": "bun audit",
|
|
50
85
|
"publish:manual": "node scripts/publish-manual.mjs",
|
|
51
86
|
"publish:manual:dry": "node scripts/publish-manual.mjs --dry-run",
|
|
52
|
-
"prepublishOnly": "node scripts/prepublish.mjs",
|
|
53
|
-
"bench:http": "bun run bench/run-bench.ts",
|
|
54
|
-
"bench:http:smoke": "bun run bench/run-bench.ts smoke",
|
|
55
|
-
"bench:http:stress": "bun run bench/run-bench.ts stress",
|
|
56
|
-
"bench:http:heavy": "bun run bench/run-bench.ts heavy-json",
|
|
57
|
-
"bench:http:crud": "bun run bench/run-bench.ts crud",
|
|
58
|
-
"bench:http:spike": "bun run bench/run-bench.ts spike",
|
|
59
|
-
"bench:http:soak": "bun run bench/run-bench.ts soak",
|
|
60
|
-
"bench:http:storm": "bun run bench/run-bench.ts storm",
|
|
61
|
-
"bench:http:boundary": "bun run bench/run-bench.ts boundary",
|
|
62
|
-
"bench:http:elysia": "bun run bench/run-bench.ts '' elysia",
|
|
63
|
-
"bench:http:ingress": "bun run bench/run-bench.ts '' ingress",
|
|
64
|
-
"bench:http:bun-only": "bun run bench/run-bench.ts '' bun",
|
|
65
|
-
"bench:http:all-heavy": "bun run bench/run-bench.ts 1"
|
|
87
|
+
"prepublishOnly": "bun run build:js && node scripts/prepublish.mjs",
|
|
88
|
+
"bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts",
|
|
89
|
+
"bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts smoke",
|
|
90
|
+
"bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts stress",
|
|
91
|
+
"bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts heavy-json",
|
|
92
|
+
"bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts crud",
|
|
93
|
+
"bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts spike",
|
|
94
|
+
"bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts soak",
|
|
95
|
+
"bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts storm",
|
|
96
|
+
"bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts boundary",
|
|
97
|
+
"bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts '' elysia",
|
|
98
|
+
"bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts '' ingress",
|
|
99
|
+
"bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts '' bun",
|
|
100
|
+
"bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts 1",
|
|
101
|
+
"bench:http:ac": "bun bench/autocannon-stress.mjs",
|
|
102
|
+
"bench:http:stress:ac": "SCENARIO=03-stress bun bench/autocannon-stress.mjs",
|
|
103
|
+
"bench:startup": "bun run bench/startup.ts",
|
|
104
|
+
"bench:load": "bun run bench/load-phase.ts",
|
|
105
|
+
"bench:ffi": "bun run bench/ffi-all.ts",
|
|
106
|
+
"bench:ffi:load": "bun run bench/ffi-load.ts",
|
|
107
|
+
"bench:ffi:public": "bun run bench/ffi-public.ts",
|
|
108
|
+
"bench:ffi:workers": "bun run bench/ffi-workers.ts",
|
|
109
|
+
"bench:margin": "bun run bench/ffi-margin.ts",
|
|
110
|
+
"bench:router": "bun run bench/router-cost.ts",
|
|
111
|
+
"bench:ingress-cost": "bun run bench/ingress-cost.ts",
|
|
112
|
+
"bench:ingress-cost:post": "bun run bench/ingress-cost-post.ts",
|
|
113
|
+
"verify:native:batch": "bun run scripts/verify-native-batch.ts"
|
|
66
114
|
},
|
|
67
|
-
"dependencies": {
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
115
|
+
"dependencies": {},
|
|
116
|
+
"peerDependencies": {
|
|
117
|
+
"@types/node": "^26.1.2"
|
|
118
|
+
},
|
|
119
|
+
"peerDependenciesMeta": {
|
|
120
|
+
"@types/node": {
|
|
121
|
+
"optional": true
|
|
122
|
+
}
|
|
72
123
|
},
|
|
73
124
|
"devDependencies": {
|
|
74
125
|
"@elysia/cors": "^1.4.2",
|
|
75
|
-
"@napi-rs/cli": "^3.
|
|
126
|
+
"@napi-rs/cli": "^3.8.6",
|
|
76
127
|
"@types/bun": "^1.3.14",
|
|
77
128
|
"@types/mime-types": "^3.0.1",
|
|
129
|
+
"autocannon": "^8.0.0",
|
|
78
130
|
"@types/validator": "^13.15.10",
|
|
79
|
-
"
|
|
131
|
+
"ajv": "^8.20.0",
|
|
132
|
+
"crc-32": "^1.2.2",
|
|
133
|
+
"dts-bundle-generator": "^9.5.1",
|
|
134
|
+
"elysia": "^1.4.29",
|
|
135
|
+
"fast-json-patch": "^3.1.1",
|
|
136
|
+
"mime-types": "^3.0.2",
|
|
137
|
+
"typescript": "5.9.3",
|
|
138
|
+
"validator": "^13.15.35"
|
|
139
|
+
},
|
|
140
|
+
"overrides": {
|
|
141
|
+
"typescript": "5.9.3"
|
|
80
142
|
},
|
|
81
143
|
"engines": {
|
|
82
|
-
"bun": ">=1.1.0"
|
|
144
|
+
"bun": ">=1.1.0",
|
|
145
|
+
"node": ">=20.3"
|
|
83
146
|
},
|
|
84
147
|
"napi": {
|
|
85
148
|
"binaryName": "castrum",
|
|
86
149
|
"targets": [
|
|
87
150
|
"aarch64-apple-darwin",
|
|
88
151
|
"aarch64-unknown-linux-gnu",
|
|
152
|
+
"aarch64-unknown-linux-musl",
|
|
89
153
|
"x86_64-apple-darwin",
|
|
90
154
|
"x86_64-pc-windows-msvc",
|
|
91
|
-
"x86_64-unknown-linux-gnu"
|
|
155
|
+
"x86_64-unknown-linux-gnu",
|
|
156
|
+
"x86_64-unknown-linux-musl"
|
|
92
157
|
]
|
|
93
158
|
}
|
|
94
159
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// src/ingress/body.ts — Streaming request-body reading for the async ingress
|
|
2
|
+
// API.
|
|
3
|
+
|
|
4
|
+
import { isBun } from '../shared/runtime'
|
|
5
|
+
|
|
6
|
+
const EMPTY_BODY = new Uint8Array(0)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read the request body as bytes. Bun's `req.bytes()` returns a `Uint8Array`
|
|
10
|
+
* directly (no `ArrayBuffer` → view wrapper and no copy) — feature-detected so
|
|
11
|
+
* Node (which lacks `Response.bytes()` on some versions) falls back to
|
|
12
|
+
* `arrayBuffer()` + a zero-copy view. This is the hot-path body read for every
|
|
13
|
+
* write route.
|
|
14
|
+
*/
|
|
15
|
+
async function readBodyBytes(req: Request): Promise<Uint8Array> {
|
|
16
|
+
const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
|
|
17
|
+
if (typeof bytes === 'function') {
|
|
18
|
+
return bytes.call(req)
|
|
19
|
+
}
|
|
20
|
+
return new Uint8Array(await req.arrayBuffer())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bun-only synchronous peek of an already-buffered body.
|
|
25
|
+
*
|
|
26
|
+
* Under Bun a body fully received during HTTP parsing resolves `req.bytes()`
|
|
27
|
+
* IMMEDIATELY — `Bun.peek` hands back the `Uint8Array` without awaiting, so a
|
|
28
|
+
* declared-Content-Length write route can read the body with no deadline race,
|
|
29
|
+
* no watchdog entry and no microtask turn (the common small-body case).
|
|
30
|
+
*
|
|
31
|
+
* Returns:
|
|
32
|
+
* - `{ bytes }` — already buffered; the caller reads it synchronously and
|
|
33
|
+
* must NOT pay the deadline race (the body is already here).
|
|
34
|
+
* - `Promise<Uint8Array>` — the read is still pending (slow/trickling body);
|
|
35
|
+
* the caller MUST still enforce its deadline against this promise.
|
|
36
|
+
* - `false` — no `req.bytes()` on this runtime (Node); the caller falls back
|
|
37
|
+
* to the async `readBodyBytes` + race as before.
|
|
38
|
+
*/
|
|
39
|
+
function peekBufferedBody(req: Request): { bytes: Uint8Array } | Promise<Uint8Array> | false {
|
|
40
|
+
const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
|
|
41
|
+
if (typeof bytes !== 'function') return false
|
|
42
|
+
const pending = bytes.call(req)
|
|
43
|
+
if (!isBun()) return pending
|
|
44
|
+
const peeked = Bun.peek(pending)
|
|
45
|
+
return peeked instanceof Promise ? peeked : { bytes: peeked }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read the request body once, enforcing the size guard when enabled. */
|
|
49
|
+
export function readRequestBodyOnce(
|
|
50
|
+
req: Request,
|
|
51
|
+
maxBytes: number,
|
|
52
|
+
guard: boolean,
|
|
53
|
+
timeoutMs?: number,
|
|
54
|
+
): Promise<Uint8Array> {
|
|
55
|
+
if (req.body === null) {
|
|
56
|
+
return Promise.resolve(EMPTY_BODY)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return readBodyWithLimit(req, maxBytes, guard, timeoutMs)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bodyTooLargeError(): Error & { code?: string } {
|
|
63
|
+
const err = new Error('BODY_TOO_LARGE') as Error & { code?: string }
|
|
64
|
+
err.code = 'BODY_TOO_LARGE'
|
|
65
|
+
return err
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Shared body-read deadline watchdog ──────────────────────────────────────
|
|
69
|
+
// One interval timer sweeps ALL in-flight body reads that carry a deadline,
|
|
70
|
+
// replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
|
|
71
|
+
// every write-route body read (that per-request timer alloc is measurable in
|
|
72
|
+
// the POST micro-bench — ~800ns of the ~2.3µs body read). Abort semantics are
|
|
73
|
+
// unchanged: a read that outlives its deadline rejects with REQUEST_TIMEOUT.
|
|
74
|
+
// Entries are removed on completion or when the deadline fires; the interval
|
|
75
|
+
// only runs while at least one read is pending.
|
|
76
|
+
const bodyWatchdog = (() => {
|
|
77
|
+
const inflight = new Set<WatchdogEntry>()
|
|
78
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
79
|
+
const SWEEP_MS = 250
|
|
80
|
+
|
|
81
|
+
function sweep() {
|
|
82
|
+
const now = Date.now()
|
|
83
|
+
for (const entry of inflight) {
|
|
84
|
+
if (entry.settled) {
|
|
85
|
+
inflight.delete(entry)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (now >= entry.deadlineWall) {
|
|
89
|
+
entry.settled = true
|
|
90
|
+
inflight.delete(entry)
|
|
91
|
+
entry.reject(timeoutError())
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (inflight.size === 0 && timer !== null) {
|
|
95
|
+
clearInterval(timer)
|
|
96
|
+
timer = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function track(entry: WatchdogEntry) {
|
|
101
|
+
inflight.add(entry)
|
|
102
|
+
if (timer === null) {
|
|
103
|
+
timer = setInterval(sweep, SWEEP_MS)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function untrack(entry: WatchdogEntry) {
|
|
108
|
+
entry.settled = true
|
|
109
|
+
inflight.delete(entry)
|
|
110
|
+
if (inflight.size === 0 && timer !== null) {
|
|
111
|
+
clearInterval(timer)
|
|
112
|
+
timer = null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { track, untrack }
|
|
117
|
+
})()
|
|
118
|
+
|
|
119
|
+
interface WatchdogEntry {
|
|
120
|
+
deadlineWall: number
|
|
121
|
+
settled: boolean
|
|
122
|
+
reject: (err: Error) => void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Create a deadline tracked by the shared watchdog (no per-read setTimeout). */
|
|
126
|
+
function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel: () => void } {
|
|
127
|
+
const entry: WatchdogEntry = {
|
|
128
|
+
deadlineWall: Date.now() + deadlineMs,
|
|
129
|
+
settled: false,
|
|
130
|
+
reject: () => {},
|
|
131
|
+
}
|
|
132
|
+
const promise = new Promise<never>((_resolve, reject) => {
|
|
133
|
+
entry.reject = reject
|
|
134
|
+
})
|
|
135
|
+
bodyWatchdog.track(entry)
|
|
136
|
+
return { promise, cancel: () => bodyWatchdog.untrack(entry) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Stream-read the request body, enforcing the size guard when enabled and an
|
|
141
|
+
* optional overall deadline.
|
|
142
|
+
*
|
|
143
|
+
* Errors carry `err.code`:
|
|
144
|
+
* - `BODY_TOO_LARGE` when `guard` is on and the body exceeds `maxBytes`
|
|
145
|
+
* (thrown as soon as the limit is crossed — the body is never fully
|
|
146
|
+
* buffered first, which is the slowloris/large-body protection).
|
|
147
|
+
* - `REQUEST_TIMEOUT` when `timeoutMs` elapses before the body completes.
|
|
148
|
+
*/
|
|
149
|
+
export async function readBodyWithLimit(
|
|
150
|
+
req: Request,
|
|
151
|
+
maxBytes: number,
|
|
152
|
+
guard: boolean,
|
|
153
|
+
timeoutMs?: number,
|
|
154
|
+
): Promise<Uint8Array> {
|
|
155
|
+
const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
|
|
156
|
+
|
|
157
|
+
// Fast path: a declared Content-Length lets us PROVE the body fits the
|
|
158
|
+
// guard before reading, so one native `bytes()`/`arrayBuffer()` read
|
|
159
|
+
// replaces the reader + per-chunk `Promise.race` + `concatUint8Arrays`
|
|
160
|
+
// churn (the bench POSTs are single-shot bodies — this removes ~4-6
|
|
161
|
+
// allocations per write request). The deadline (when set) is still enforced
|
|
162
|
+
// with a single race, not one per chunk. The read is bounded by the
|
|
163
|
+
// server's `maxRequestBodySize`, and the post-read length re-check catches
|
|
164
|
+
// a client that lies about Content-Length.
|
|
165
|
+
const declared = req.headers.get('content-length')
|
|
166
|
+
if (declared !== null) {
|
|
167
|
+
const declaredLen = Number(declared)
|
|
168
|
+
if (Number.isFinite(declaredLen)) {
|
|
169
|
+
if (guard && declaredLen > maxBytes) {
|
|
170
|
+
throw bodyTooLargeError()
|
|
171
|
+
}
|
|
172
|
+
// `req.body` (the stream getter) is deliberately NOT touched on this
|
|
173
|
+
// path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
|
|
174
|
+
// body directly, avoiding a lazily-constructed stream per write request.
|
|
175
|
+
if (deadline <= 0) {
|
|
176
|
+
const bytes = await readBodyBytes(req)
|
|
177
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
178
|
+
throw bodyTooLargeError()
|
|
179
|
+
}
|
|
180
|
+
return bytes
|
|
181
|
+
}
|
|
182
|
+
// Deadline path. Under Bun, a small declared-length body is usually
|
|
183
|
+
// already buffered — `Bun.peek` returns the bytes synchronously, so we
|
|
184
|
+
// skip the deadline race + watchdog + microtask entirely for the common
|
|
185
|
+
// case. Only a genuinely pending read (slow/trickling body) pays the
|
|
186
|
+
// race; non-Bun runtimes keep the previous behavior.
|
|
187
|
+
const buffered = peekBufferedBody(req)
|
|
188
|
+
if (buffered === false) {
|
|
189
|
+
const dl = createDeadline(deadline)
|
|
190
|
+
try {
|
|
191
|
+
const bytes = await Promise.race([readBodyBytes(req), dl.promise])
|
|
192
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
193
|
+
throw bodyTooLargeError()
|
|
194
|
+
}
|
|
195
|
+
return bytes
|
|
196
|
+
} finally {
|
|
197
|
+
dl.cancel()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if ('bytes' in buffered) {
|
|
201
|
+
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
202
|
+
throw bodyTooLargeError()
|
|
203
|
+
}
|
|
204
|
+
return buffered.bytes
|
|
205
|
+
}
|
|
206
|
+
const dl = createDeadline(deadline)
|
|
207
|
+
try {
|
|
208
|
+
const bytes = await Promise.race([buffered, dl.promise])
|
|
209
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
210
|
+
throw bodyTooLargeError()
|
|
211
|
+
}
|
|
212
|
+
return bytes
|
|
213
|
+
} finally {
|
|
214
|
+
dl.cancel()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// No (usable) Content-Length: fall back to the streaming reader. `req.body`
|
|
220
|
+
// is only touched here — the declared-length fast path never needs it.
|
|
221
|
+
const body = req.body
|
|
222
|
+
if (!body) {
|
|
223
|
+
return EMPTY_BODY
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const reader = body.getReader()
|
|
227
|
+
const chunks: Uint8Array[] = []
|
|
228
|
+
let total = 0
|
|
229
|
+
// True when the stream was fully read to `done` — on that path the reader is
|
|
230
|
+
// already closed, so the `cancel()` in `finally` is a wasted async round-trip
|
|
231
|
+
// per request. It is still required when the loop exits early (timeout/error).
|
|
232
|
+
let completed = false
|
|
233
|
+
const dl = deadline > 0 ? createDeadline(deadline) : null
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
for (;;) {
|
|
237
|
+
const { done, value } =
|
|
238
|
+
dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
|
|
239
|
+
|
|
240
|
+
if (done) {
|
|
241
|
+
completed = true
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
if (!value) continue
|
|
245
|
+
|
|
246
|
+
total += value.byteLength
|
|
247
|
+
|
|
248
|
+
if (guard && total > maxBytes) {
|
|
249
|
+
throw bodyTooLargeError()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
chunks.push(value)
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
dl?.cancel()
|
|
256
|
+
// Release the underlying stream. Cancelling while a read() is still
|
|
257
|
+
// pending (the timeout case) can resolve that pending read() as `done`
|
|
258
|
+
// and RACE the timeout rejection — so reject first, cancel only AFTER
|
|
259
|
+
// the loop has settled. The `.catch` keeps the cancel from masking a
|
|
260
|
+
// thrown REQUEST_TIMEOUT / BODY_TOO_LARGE. On the normal `done` path the
|
|
261
|
+
// stream is already closed, so skip the cancel entirely.
|
|
262
|
+
if (!completed) {
|
|
263
|
+
await reader.cancel().catch(() => {})
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return concatUint8Arrays(chunks, total)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function timeoutError(): Error & { code?: string } {
|
|
271
|
+
const err = new Error('REQUEST_TIMEOUT') as Error & { code?: string }
|
|
272
|
+
err.code = 'REQUEST_TIMEOUT'
|
|
273
|
+
return err
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function concatUint8Arrays(chunks: Uint8Array[], total: number): Uint8Array {
|
|
277
|
+
if (chunks.length === 0) return EMPTY_BODY
|
|
278
|
+
if (chunks.length === 1) return chunks[0] ?? EMPTY_BODY
|
|
279
|
+
|
|
280
|
+
const out = new Uint8Array(total)
|
|
281
|
+
let offset = 0
|
|
282
|
+
|
|
283
|
+
for (const chunk of chunks) {
|
|
284
|
+
out.set(chunk, offset)
|
|
285
|
+
offset += chunk.byteLength
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return out
|
|
289
|
+
}
|