castrum 0.9.2 → 0.9.4

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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,324 @@
1
+ // rust/http/url_join.rs — URL building: RFC 3986 reference resolution + query builder.
2
+ //
3
+ // `url_resolve(base, reference)` resolves a relative/absolute reference against
4
+ // a base URI (merge paths + remove dot segments). `url_encode_query` builds a
5
+ // percent-encoded query string (deterministic sorted-key order). The
6
+ // `UrlBuilder` higher-order instance parses the base once and reuses it.
7
+
8
+ use std::collections::BTreeMap;
9
+
10
+ use napi::bindgen_prelude::*;
11
+ use napi_derive::napi;
12
+
13
+ /// A parsed URI reference (RFC 3986 §4.2 components).
14
+ #[derive(Debug, Clone)]
15
+ pub(crate) struct Ref {
16
+ scheme: Option<String>,
17
+ authority: Option<String>,
18
+ path: String,
19
+ query: Option<String>,
20
+ fragment: Option<String>,
21
+ }
22
+
23
+ pub(crate) fn parse_ref(input: &str) -> Ref {
24
+ let (rest, fragment) = match input.find('#') {
25
+ Some(i) => (&input[..i], Some(input[i + 1..].to_string())),
26
+ None => (input, None),
27
+ };
28
+
29
+ let (rest, scheme) = if let Some(i) = rest.find(':') {
30
+ let candidate = &rest[..i];
31
+ if !candidate.is_empty()
32
+ && candidate
33
+ .chars()
34
+ .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
35
+ {
36
+ (&rest[i + 1..], Some(candidate.to_string()))
37
+ } else {
38
+ (rest, None)
39
+ }
40
+ } else {
41
+ (rest, None)
42
+ };
43
+
44
+ let (rest, authority) = if let Some(rest2) = rest.strip_prefix("//") {
45
+ let end = rest2.find('/').unwrap_or(rest2.len());
46
+ (&rest2[end..], Some(rest2[..end].to_string()))
47
+ } else {
48
+ (rest, None)
49
+ };
50
+
51
+ let (path, query) = match rest.find('?') {
52
+ Some(i) => (rest[..i].to_string(), Some(rest[i + 1..].to_string())),
53
+ None => (rest.to_string(), None),
54
+ };
55
+
56
+ Ref {
57
+ scheme,
58
+ authority,
59
+ path,
60
+ query,
61
+ fragment,
62
+ }
63
+ }
64
+
65
+ /// RFC 3986 §5.2.4 remove_dot_segments (normalize `.` / `..`).
66
+ fn remove_dot_segments(path: &str) -> String {
67
+ let leading = path.starts_with('/');
68
+ let parts: Vec<&str> = path.split('/').collect();
69
+ let mut segments: Vec<&str> = Vec::new();
70
+ let mut i = 0usize;
71
+ if parts.first() == Some(&"") {
72
+ i = 1;
73
+ }
74
+ while i < parts.len() {
75
+ match parts[i] {
76
+ "." => {}
77
+ ".." => {
78
+ segments.pop();
79
+ }
80
+ seg => segments.push(seg),
81
+ }
82
+ i += 1;
83
+ }
84
+ let body = segments.join("/");
85
+ if leading {
86
+ format!("/{body}")
87
+ } else {
88
+ body
89
+ }
90
+ }
91
+
92
+ /// RFC 3986 §5.3.3 merge paths.
93
+ fn merge_paths(base_path: &str, ref_path: &str) -> String {
94
+ match base_path.rfind('/') {
95
+ Some(i) => format!("{}{}", &base_path[..=i], ref_path),
96
+ None => ref_path.to_string(),
97
+ }
98
+ }
99
+
100
+ pub(crate) fn resolve_target(base: &Ref, r: &Ref) -> Ref {
101
+ if r.scheme.is_some() {
102
+ let mut t = r.clone();
103
+ t.path = remove_dot_segments(&r.path);
104
+ return t;
105
+ }
106
+ let mut t = Ref {
107
+ scheme: base.scheme.clone(),
108
+ authority: base.authority.clone(),
109
+ path: String::new(),
110
+ query: None,
111
+ fragment: r.fragment.clone(),
112
+ };
113
+ if r.authority.is_some() {
114
+ t.authority = r.authority.clone();
115
+ t.path = remove_dot_segments(&r.path);
116
+ t.query = r.query.clone();
117
+ return t;
118
+ }
119
+ if r.path.is_empty() {
120
+ t.path = base.path.clone();
121
+ t.query = r.query.clone().or_else(|| base.query.clone());
122
+ return t;
123
+ }
124
+ if r.path.starts_with('/') {
125
+ t.path = remove_dot_segments(&r.path);
126
+ } else {
127
+ t.path = remove_dot_segments(&merge_paths(&base.path, &r.path));
128
+ }
129
+ t.query = r.query.clone();
130
+ t
131
+ }
132
+
133
+ pub(crate) fn recompose(r: &Ref) -> String {
134
+ let mut s = String::new();
135
+ if let Some(sch) = &r.scheme {
136
+ s.push_str(sch);
137
+ s.push(':');
138
+ }
139
+ if let Some(auth) = &r.authority {
140
+ s.push_str("//");
141
+ s.push_str(auth);
142
+ }
143
+ s.push_str(&r.path);
144
+ if let Some(q) = &r.query {
145
+ s.push('?');
146
+ s.push_str(q);
147
+ }
148
+ if let Some(f) = &r.fragment {
149
+ s.push('#');
150
+ s.push_str(f);
151
+ }
152
+ s
153
+ }
154
+
155
+ #[napi]
156
+ pub fn url_resolve(base: Uint8Array, reference: Uint8Array) -> Result<Buffer> {
157
+ let b =
158
+ std::str::from_utf8(base.as_ref()).map_err(|_| Error::from_reason("base must be UTF-8"))?;
159
+ let r = std::str::from_utf8(reference.as_ref())
160
+ .map_err(|_| Error::from_reason("reference must be UTF-8"))?;
161
+ let target = resolve_target(&parse_ref(b), &parse_ref(r));
162
+ Ok(Buffer::from(recompose(&target).into_bytes()))
163
+ }
164
+
165
+ /// Packed URL-resolve batch (two packed lists: bases + references, zipped) →
166
+ /// `[u32 count]{[u32 len][resolved]}` out. Items that fail UTF-8 parsing yield
167
+ /// an empty result — skip-on-error.
168
+ #[napi]
169
+ pub fn url_resolve_batch_packed(bases: Uint8Array, references: Uint8Array) -> Result<Buffer> {
170
+ use crate::util::packed::PackedIter;
171
+ let b = bases.as_ref();
172
+ let r = references.as_ref();
173
+ let count = PackedIter::new(b)?.len().min(PackedIter::new(r)?.len());
174
+ let mut out = Vec::with_capacity(4 + count * 64);
175
+ out.extend_from_slice(&(count as u32).to_le_bytes());
176
+ for (base, reference) in PackedIter::new(b)?.zip(PackedIter::new(r)?) {
177
+ let resolved = match (std::str::from_utf8(base), std::str::from_utf8(reference)) {
178
+ (Ok(bs), Ok(rs)) => {
179
+ recompose(&resolve_target(&parse_ref(bs), &parse_ref(rs))).into_bytes()
180
+ }
181
+ _ => Vec::new(),
182
+ };
183
+ out.extend_from_slice(&(resolved.len() as u32).to_le_bytes());
184
+ out.extend_from_slice(&resolved);
185
+ }
186
+ Ok(Buffer::from(out))
187
+ }
188
+
189
+ pub(crate) fn encode_query_component(
190
+ input: &[u8],
191
+ scratch: &mut Vec<u8>,
192
+ out: &mut Vec<u8>,
193
+ ) -> Result<()> {
194
+ // Reuse one caller-provided scratch buffer across all keys/values instead of
195
+ // allocating a fresh `vec![0u8; len*3]` per component. Grows only when the
196
+ // largest component needs more room.
197
+ if scratch.len() < input.len().saturating_mul(3) {
198
+ scratch.resize(input.len().saturating_mul(3), 0);
199
+ }
200
+ let written = crate::http::url_codec::url_encode_into_slice(input, scratch)?;
201
+ out.extend_from_slice(&scratch[..written]);
202
+ Ok(())
203
+ }
204
+
205
+ #[napi]
206
+ pub fn url_encode_query(params: BTreeMap<String, String>) -> Result<Buffer> {
207
+ let mut out = Vec::new();
208
+ let mut scratch = Vec::new();
209
+ for (i, (k, v)) in params.iter().enumerate() {
210
+ if i > 0 {
211
+ out.push(b'&');
212
+ }
213
+ encode_query_component(k.as_bytes(), &mut scratch, &mut out)?;
214
+ out.push(b'=');
215
+ encode_query_component(v.as_bytes(), &mut scratch, &mut out)?;
216
+ }
217
+ Ok(Buffer::from(out))
218
+ }
219
+
220
+ /// Higher-order instance: base parsed once, reused across resolves.
221
+ #[napi]
222
+ pub struct UrlBuilder {
223
+ base: Ref,
224
+ }
225
+
226
+ /// C-ABI support: resolve `reference` against the precompiled base, returning
227
+ /// the UTF-8 bytes. `Err(())` on non-UTF-8 reference (napi parity: throws).
228
+ ///
229
+ /// # Safety
230
+ /// `p` must be a valid `*const UrlBuilder` obtained from `inner_ptr` and must
231
+ /// stay alive for the call (the JS wrapper holds the napi instance).
232
+ pub(crate) unsafe fn url_builder_resolve_core(
233
+ p: *const UrlBuilder,
234
+ reference: &[u8],
235
+ ) -> std::result::Result<Vec<u8>, ()> {
236
+ let r = std::str::from_utf8(reference).map_err(|_| ())?;
237
+ let this = &*p;
238
+ Ok(recompose(&resolve_target(&this.base, &parse_ref(r))).into_bytes())
239
+ }
240
+
241
+ #[napi]
242
+ impl UrlBuilder {
243
+ #[napi(constructor)]
244
+ pub fn new(base: Uint8Array) -> Result<Self> {
245
+ let b = std::str::from_utf8(base.as_ref())
246
+ .map_err(|_| Error::from_reason("base must be UTF-8"))?;
247
+ Ok(Self { base: parse_ref(b) })
248
+ }
249
+
250
+ /// Opaque handle to the precompiled base, for the `bun:ffi` C-ABI fast path
251
+ /// (`castrum_url_builder_resolve` in rust/ffi.rs). Only valid while THIS
252
+ /// instance is alive; the JS wrapper holds the instance for the handle
253
+ /// lifetime (same contract as `Ingress::ingress_inner_ptr`).
254
+ #[napi]
255
+ pub fn inner_ptr(&self) -> u64 {
256
+ self as *const UrlBuilder as u64
257
+ }
258
+
259
+ #[napi]
260
+ pub fn resolve(&self, reference: Uint8Array) -> Result<Buffer> {
261
+ let r = std::str::from_utf8(reference.as_ref())
262
+ .map_err(|_| Error::from_reason("reference must be UTF-8"))?;
263
+ let target = resolve_target(&self.base, &parse_ref(r));
264
+ Ok(Buffer::from(recompose(&target).into_bytes()))
265
+ }
266
+ }
267
+
268
+ #[cfg(test)]
269
+ mod tests {
270
+ use super::*;
271
+
272
+ fn resolve(base: &str, reference: &str) -> String {
273
+ recompose(&resolve_target(&parse_ref(base), &parse_ref(reference)))
274
+ }
275
+
276
+ #[test]
277
+ fn absolute_reference() {
278
+ assert_eq!(
279
+ resolve("http://a/b/c/d;p?q", "http://x/y/z"),
280
+ "http://x/y/z"
281
+ );
282
+ }
283
+
284
+ #[test]
285
+ fn authority_reference() {
286
+ assert_eq!(
287
+ resolve("http://a/b/c/d;p?q", "//other/path"),
288
+ "http://other/path"
289
+ );
290
+ }
291
+
292
+ #[test]
293
+ fn dot_segment_removal() {
294
+ assert_eq!(resolve("http://a/b/c/d;p?q", "../../../g"), "http://a/g");
295
+ assert_eq!(resolve("http://a/b/c/d;p?q", "../../../../g"), "http://a/g");
296
+ assert_eq!(resolve("http://a/b/c/d;p?q", "./g"), "http://a/b/c/g");
297
+ assert_eq!(resolve("http://a/b/c/d;p?q", "g/./h"), "http://a/b/c/g/h");
298
+ assert_eq!(resolve("http://a/b/c/d;p?q", "g/../h"), "http://a/b/c/h");
299
+ }
300
+
301
+ #[test]
302
+ fn query_and_fragment() {
303
+ assert_eq!(resolve("http://a/b/c/d;p?q", "g?y#f"), "http://a/b/c/g?y#f");
304
+ assert_eq!(
305
+ resolve("http://a/b/c/d;p?q", "#frag"),
306
+ "http://a/b/c/d;p?q#frag"
307
+ );
308
+ assert_eq!(resolve("http://a/b/c/d;p?q", "?y"), "http://a/b/c/d;p?y");
309
+ }
310
+
311
+ #[test]
312
+ fn empty_path_reference_keeps_base_query() {
313
+ assert_eq!(resolve("http://a/b/c/d;p?q", ""), "http://a/b/c/d;p?q");
314
+ }
315
+
316
+ #[test]
317
+ fn query_builder_sorted_and_encoded() {
318
+ let mut params = BTreeMap::new();
319
+ params.insert("b".to_string(), "x y".to_string());
320
+ params.insert("a".to_string(), "1".to_string());
321
+ let out = url_encode_query(params).unwrap();
322
+ assert_eq!(out.as_ref(), b"a=1&b=x%20y");
323
+ }
324
+ }
@@ -0,0 +1,355 @@
1
+ // rust/ingress/api.rs — the `Ingress` napi class + `IngressInner` state.
2
+ //
3
+ // The napi boundary of the ingress pipeline. `IngressInner` holds the compiled
4
+ // per-instance configuration (options, CORS, rate limiter, schema, limits); the
5
+ // `Ingress` class exposes the packed and full-sync request entry points. The
6
+ // core 8-stage pipeline logic lives in `pipeline.rs` (`IngressInner`'s
7
+ // `handle_packed`); this module only marshals options into state and routes
8
+ // requests into it. Kept as a separate file so `ingress/mod.rs` stays a pure
9
+ // declaration hub.
10
+
11
+ use napi::bindgen_prelude::*;
12
+ use napi::Status;
13
+ use napi_derive::napi;
14
+
15
+ use std::sync::Arc;
16
+
17
+ use super::{IngressOptions, IngressSchema, Limits};
18
+ use crate::ingress::output::{MAX_OUTPUT_BUFFER_SIZE, OUT_DATA_START};
19
+ use crate::ingress::rate_limit::RateLimiterState;
20
+
21
+ /// Clamp an ingress output-buffer size into `[OUT_DATA_START, MAX_OUTPUT_BUFFER_SIZE]`
22
+ /// so a misconfigured (huge/negative) `outputBufferSize` can never trigger a
23
+ /// multi-GB native allocation (OOM) in the allocating full_sync entry.
24
+ pub(crate) fn clamp_output_size(output_buffer_size: Option<u32>) -> usize {
25
+ output_buffer_size
26
+ .unwrap_or(262_144)
27
+ .clamp(OUT_DATA_START as u32, MAX_OUTPUT_BUFFER_SIZE) as usize
28
+ }
29
+
30
+ /// Compiled per-instance ingress state. Constructed once by [`Ingress::new`]
31
+ /// from the JS-facing options; `handle_packed` (in `pipeline.rs`) runs the
32
+ /// 8-stage pipeline over this immutable configuration.
33
+ #[derive(Clone)]
34
+ pub(crate) struct IngressInner {
35
+ pub https_fixed: Option<bool>,
36
+ pub max_body_bytes: usize,
37
+ pub proxy_trust: crate::ingress::ip_trust::ProxyTrustMode,
38
+ pub parse_cookies: bool,
39
+ pub parse_query: bool,
40
+ pub require_json_body: bool,
41
+ pub guard_enabled: bool,
42
+ pub emit_metadata_json: bool,
43
+ pub cors_enabled: bool,
44
+ pub cors: crate::ingress::cors::CorsEngine,
45
+ pub rate: RateLimiterState,
46
+ pub schema: Option<Arc<IngressSchema>>,
47
+ pub limits: Limits,
48
+ }
49
+
50
+ #[napi]
51
+ pub struct Ingress {
52
+ inner: Arc<IngressInner>,
53
+ }
54
+
55
+ #[napi]
56
+ impl Ingress {
57
+ #[napi(constructor)]
58
+ pub fn new(options: IngressOptions) -> Result<Self> {
59
+ let parse_cookies = options.parse_cookies.unwrap_or(false);
60
+ let parse_query = options.parse_query.unwrap_or(false);
61
+ let require_json_body = options.require_json_body.unwrap_or(false);
62
+ let max_body_bytes = options
63
+ .max_body_bytes
64
+ .unwrap_or(crate::ingress::options::DEFAULT_MAX_BODY_BYTES as u32)
65
+ as usize;
66
+ let guard_enabled = options.enable_body_size_guard.unwrap_or(true);
67
+ let emit_metadata_json = options.emit_metadata_json.unwrap_or(false);
68
+
69
+ let proxy_trust = if let Some(tp) = options.trusted_proxies {
70
+ crate::ingress::ip_trust::ProxyTrustMode::from_config(
71
+ tp.enabled.unwrap_or(false),
72
+ tp.networks,
73
+ )
74
+ .map_err(|e| Error::new(Status::InvalidArg, e))?
75
+ } else if options.trust_proxy.unwrap_or(false) {
76
+ crate::ingress::ip_trust::ProxyTrustMode::All
77
+ } else {
78
+ crate::ingress::ip_trust::ProxyTrustMode::None
79
+ };
80
+
81
+ let schema = if let Some(schema_bytes) = options.schema {
82
+ let schema_str = std::str::from_utf8(&schema_bytes)
83
+ .map_err(|_| Error::new(Status::InvalidArg, "Invalid UTF-8 in schema"))?;
84
+ let schema_value: serde_json::Value = sonic_rs::from_str(schema_str)
85
+ .map_err(|e| Error::new(Status::InvalidArg, format!("Schema JSON error: {}", e)))?;
86
+ // Compile BOTH the authoritative jsonschema validator and the
87
+ // zero-DOM fast path once at construction — no per-request schema
88
+ // work (see IngressSchema in pipeline.rs).
89
+ let compiled = IngressSchema::compile(&schema_value).map_err(|e| {
90
+ Error::new(Status::InvalidArg, format!("Schema compile error: {}", e))
91
+ })?;
92
+ Some(Arc::new(compiled))
93
+ } else {
94
+ None
95
+ };
96
+
97
+ let cors_enabled = options.cors.is_some();
98
+ let cors = crate::ingress::cors::CorsEngine::from_options(options.cors)?;
99
+
100
+ let rate = if let Some(rl_opts) = options.rate_limit {
101
+ let limit = rl_opts.limit.unwrap_or(0);
102
+ let window_ms = rl_opts.window_ms.unwrap_or(1000);
103
+ if limit == 0 || limit == u32::MAX {
104
+ RateLimiterState::Disabled
105
+ } else {
106
+ let max_entries = rl_opts.max_entries.map(|v| v as usize);
107
+ // Shared per-configuration across the process: prevents a client
108
+ // from bypassing a per-IP limit by spreading requests across routes.
109
+ RateLimiterState::Enabled(
110
+ crate::ingress::rate_limit::shared_limiter(limit, window_ms, max_entries)
111
+ .map_err(|e| Error::new(Status::InvalidArg, e))?,
112
+ )
113
+ }
114
+ } else {
115
+ RateLimiterState::Disabled
116
+ };
117
+
118
+ let limits = Limits::from_options(options.limits);
119
+
120
+ Ok(Self {
121
+ inner: Arc::new(IngressInner {
122
+ https_fixed: options.https,
123
+ max_body_bytes,
124
+ proxy_trust,
125
+ parse_cookies,
126
+ parse_query,
127
+ require_json_body,
128
+ guard_enabled,
129
+ emit_metadata_json,
130
+ cors_enabled,
131
+ cors,
132
+ rate,
133
+ schema,
134
+ limits,
135
+ }),
136
+ })
137
+ }
138
+
139
+ /// Opaque handle to the inner pipeline state, for the `bun:ffi` C-ABI fast
140
+ /// path (`castrum_ingress_handle_packed` in rust/ffi.rs).
141
+ ///
142
+ /// # Safety / lifetime
143
+ /// The returned value is a raw pointer into the `Arc<IngressInner>` and is
144
+ /// ONLY valid while THIS `Ingress` instance is alive (napi keeps the native
145
+ /// state alive while the JS object is referenced; it is dropped on GC). It
146
+ /// must be treated as an opaque handle — JS must never dereference it, and
147
+ /// it must be discarded if the instance is dropped. The internal wrapper
148
+ /// (`src/native/ffi.ts`) holds the instance for the lifetime of the ingress
149
+ /// handler, so in practice the handle never outlives the state it points
150
+ /// to. Exposed for the internal fast path only; not part of the public API.
151
+ #[napi]
152
+ pub fn ingress_inner_ptr(&self) -> u64 {
153
+ self.inner.as_ref() as *const IngressInner as u64
154
+ }
155
+
156
+ /// Production API:
157
+ /// input = packed metadata frame
158
+ /// body = separate zero-copy body
159
+ /// output = response decision buffer
160
+ #[napi(ts_args_type = "input: Uint8Array, body: Uint8Array | null, output: Uint8Array")]
161
+ pub fn handle_request_packed(
162
+ &self,
163
+ input: Uint8Array,
164
+ body: Option<Uint8Array>,
165
+ mut output: Uint8Array,
166
+ ) -> Result<u32> {
167
+ let inner: &IngressInner = self.inner.as_ref();
168
+
169
+ crate::util::run_packed_into(&input, &mut output, move |inp, out| match body {
170
+ Some(b) => {
171
+ let b_bytes = b.as_ref();
172
+ if crate::util::slices_overlap(b_bytes, out) {
173
+ let owned = b_bytes.to_vec();
174
+ inner.handle_packed(inp, &owned, out)
175
+ } else {
176
+ inner.handle_packed(inp, b_bytes, out)
177
+ }
178
+ }
179
+ None => inner.handle_packed(inp, &[], out),
180
+ })
181
+ }
182
+
183
+ /// Optimized sync API:
184
+ /// Accepts raw JS values (no binary packing in JS), packs them in Rust,
185
+ /// and processes them synchronously.
186
+ /// This is the fastest path for simple requests.
187
+ ///
188
+ /// Allocating variant: allocates a fresh output buffer per call. Hot paths
189
+ /// should prefer [`Self::handle_request_full_sync_into`], which reuses a
190
+ /// caller-provided (pooled) output buffer.
191
+ #[allow(clippy::too_many_arguments)]
192
+ #[napi(
193
+ ts_args_type = "methodKind: number, url: string, ip: string, requestId: string, headers: Array<[string, string]>, body: Uint8Array | null, outputBufferSize?: number"
194
+ )]
195
+ pub fn handle_request_full_sync(
196
+ &self,
197
+ method_kind: u32,
198
+ url: String,
199
+ ip: String,
200
+ request_id: String,
201
+ headers: Vec<Vec<String>>,
202
+ body: Option<Uint8Array>,
203
+ output_buffer_size: Option<u32>,
204
+ ) -> Result<Uint8Array> {
205
+ // The fresh `out` vec cannot alias `body` (a distinct JS buffer), so it
206
+ // is safe to borrow the body without copying.
207
+ let body_bytes: &[u8] = match &body {
208
+ Some(b) => b.as_ref(),
209
+ None => &[],
210
+ };
211
+
212
+ // Default SMALL (the packed decision + metadata JSON is typically a few
213
+ // hundred bytes — a flat 256 KiB zero-fill per call is wasteful) and
214
+ // grow on the pipeline's "output buffer too small" signal, capped at
215
+ // `MAX_OUTPUT_BUFFER_SIZE`. Hot paths use the pooled `_into` variant.
216
+ let mut output_size = match output_buffer_size {
217
+ Some(s) => clamp_output_size(Some(s)),
218
+ // `OUT_DATA_START + 8192` is within `[OUT_DATA_START, MAX]` by
219
+ // construction, so no clamp is needed for the default.
220
+ None => OUT_DATA_START + 8192,
221
+ };
222
+ let mut out = vec![0u8; output_size];
223
+ let mut attempt_headers = headers.clone();
224
+ loop {
225
+ match self.full_sync_into(
226
+ method_kind,
227
+ &url,
228
+ &ip,
229
+ &request_id,
230
+ attempt_headers,
231
+ body_bytes,
232
+ &mut out,
233
+ ) {
234
+ Ok(written) => {
235
+ out.truncate(written);
236
+ return Ok(Uint8Array::new(out));
237
+ }
238
+ Err(e)
239
+ if e.reason == "output buffer too small"
240
+ && output_size < MAX_OUTPUT_BUFFER_SIZE as usize =>
241
+ {
242
+ // Grow (bounded) and retry with a fresh header marshal.
243
+ output_size = (output_size * 2).min(MAX_OUTPUT_BUFFER_SIZE as usize);
244
+ out.resize(output_size, 0);
245
+ attempt_headers = headers.clone();
246
+ }
247
+ Err(e) => return Err(e),
248
+ }
249
+ }
250
+ }
251
+
252
+ /// Reusable-output variant of [`Self::handle_request_full_sync`]: packs raw
253
+ /// request components in Rust, runs the pipeline, and writes the packed
254
+ /// decision into the caller-provided `output` buffer, returning the number
255
+ /// of bytes written.
256
+ ///
257
+ /// The caller is expected to pool the output buffer across requests (see
258
+ /// `src/shared/buffer-pool.ts`) to eliminate per-request allocation. The
259
+ /// body is zero-copied unless it aliases `output` (guarded by
260
+ /// `crate::util::slices_overlap`).
261
+ #[allow(clippy::too_many_arguments)]
262
+ #[napi(
263
+ ts_args_type = "methodKind: number, url: string, ip: string, requestId: string, headers: Array<[string, string]>, body: Uint8Array | null, output: Uint8Array"
264
+ )]
265
+ pub fn handle_request_full_sync_into(
266
+ &self,
267
+ method_kind: u32,
268
+ url: String,
269
+ ip: String,
270
+ request_id: String,
271
+ headers: Vec<Vec<String>>,
272
+ body: Option<Uint8Array>,
273
+ mut output: Uint8Array,
274
+ ) -> Result<u32> {
275
+ let body_bytes: &[u8] = match &body {
276
+ Some(b) => b.as_ref(),
277
+ None => &[],
278
+ };
279
+
280
+ // Check overlap before taking the mutable slice (can't alias output
281
+ // mutably and immutably at the same time).
282
+ let overlaps = crate::util::slices_overlap(body_bytes, output.as_ref());
283
+
284
+ if overlaps {
285
+ // The caller aliased body with output: fall back to an owned copy.
286
+ let owned = body_bytes.to_vec();
287
+ // SAFETY: `output` is a caller-provided buffer we write into and
288
+ // `full_sync_into` does not retain the slice beyond the call. This
289
+ // mirrors the existing `crate::util::run_packed_into` pattern.
290
+ let out: &mut [u8] = unsafe { output.as_mut() };
291
+ let written =
292
+ self.full_sync_into(method_kind, &url, &ip, &request_id, headers, &owned, out)?;
293
+ return Ok(written as u32);
294
+ }
295
+
296
+ // SAFETY: see above — the caller-provided buffer is only borrowed for
297
+ // the duration of the call.
298
+ let out: &mut [u8] = unsafe { output.as_mut() };
299
+ let written = self.full_sync_into(
300
+ method_kind,
301
+ &url,
302
+ &ip,
303
+ &request_id,
304
+ headers,
305
+ body_bytes,
306
+ out,
307
+ )?;
308
+ Ok(written as u32)
309
+ }
310
+
311
+ /// Shared core for the full_sync family: packs raw request components in
312
+ /// Rust and runs the ingress pipeline, writing into `out` (which must be at
313
+ /// least `OUT_DATA_START` bytes). Returns the number of bytes written.
314
+ ///
315
+ /// `body` is borrowed; callers must guarantee it does not alias `out` (the
316
+ /// into-variant checks overlap before calling).
317
+ #[allow(clippy::too_many_arguments)]
318
+ pub(crate) fn full_sync_into(
319
+ &self,
320
+ method_kind: u32,
321
+ url: &str,
322
+ ip: &str,
323
+ request_id: &str,
324
+ headers: Vec<Vec<String>>,
325
+ body: &[u8],
326
+ out: &mut [u8],
327
+ ) -> Result<usize> {
328
+ let inner: &IngressInner = self.inner.as_ref();
329
+
330
+ // Consume the napi-marshaled header strings by moving the last two
331
+ // elements out of each Vec — avoids a second allocation per header
332
+ // name/value (napi already allocated the owned Strings).
333
+ let header_pairs: Vec<(String, String)> = headers
334
+ .into_iter()
335
+ .filter_map(|mut h| {
336
+ if h.len() < 2 {
337
+ return None;
338
+ }
339
+ let value = h.pop().unwrap_or_default();
340
+ let name = h.pop().unwrap_or_default();
341
+ Some((name, value))
342
+ })
343
+ .collect();
344
+
345
+ let packed = crate::ingress::packed::build_packed_input_sync(
346
+ method_kind as u8,
347
+ url.as_bytes(),
348
+ ip.as_bytes(),
349
+ request_id.as_bytes(),
350
+ &header_pairs,
351
+ );
352
+
353
+ inner.handle_packed(&packed, body, out)
354
+ }
355
+ }