castrum 0.9.3 → 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,606 @@
1
+ // rust/http/accept.rs — Accept-Encoding parsing + content-coding negotiation.
2
+ //
3
+ // RFC 7231 §5.3.4: q-value parsing (0–1, up to 3 decimals), wildcard `*`,
4
+ // order preservation, and the `AcceptNegotiator` higher-order instance that
5
+ // precompiles the server's supported list once and answers "which encoding
6
+ // should I use for this Accept-Encoding header?"
7
+
8
+ use napi::bindgen_prelude::*;
9
+ use napi_derive::napi;
10
+
11
+ use crate::util::bytes::trim_ascii_whitespace;
12
+
13
+ /// One parsed Accept-Encoding entry (pure-Rust core type).
14
+ #[derive(Debug, Clone)]
15
+ pub struct EncodingPref {
16
+ pub encoding: String,
17
+ pub q: f32,
18
+ pub order: u32,
19
+ }
20
+
21
+ /// Parse a q-value like `0.8`, `1`, `0.123`.
22
+ fn parse_q(input: &[u8]) -> Option<f32> {
23
+ let s = std::str::from_utf8(input).ok()?.trim();
24
+ if s.is_empty() {
25
+ return None;
26
+ }
27
+ s.parse::<f32>().ok().map(|v| v.clamp(0.0, 1.0))
28
+ }
29
+
30
+ /// Parse an Accept-Encoding header into ordered preferences (lowercased).
31
+ pub fn parse_accept_encoding_core(header: &[u8]) -> Vec<EncodingPref> {
32
+ let mut out = Vec::new();
33
+ let mut order = 0u32;
34
+ for part in header.split(|&b| b == b',') {
35
+ let part = trim_ascii_whitespace(part);
36
+ if part.is_empty() {
37
+ continue;
38
+ }
39
+ let (name, q) = match part.iter().position(|&b| b == b';') {
40
+ Some(i) => {
41
+ let qpart = trim_ascii_whitespace(&part[i + 1..]);
42
+ let q = if let Some(eq) = qpart.iter().position(|&b| b == b'=') {
43
+ parse_q(&qpart[eq + 1..])
44
+ } else {
45
+ None
46
+ };
47
+ (trim_ascii_whitespace(&part[..i]), q.unwrap_or(1.0))
48
+ }
49
+ None => (part, 1.0),
50
+ };
51
+ if name.is_empty() {
52
+ continue;
53
+ }
54
+ out.push(EncodingPref {
55
+ encoding: String::from_utf8_lossy(name).to_ascii_lowercase(),
56
+ q,
57
+ order,
58
+ });
59
+ order += 1;
60
+ }
61
+ out
62
+ }
63
+
64
+ /// Borrowed form of `EncodingPref` — no per-entry String allocation.
65
+ #[derive(Clone, Copy)]
66
+ struct EncodingPrefRef<'a> {
67
+ name: &'a [u8],
68
+ q: f32,
69
+ order: u32,
70
+ }
71
+
72
+ /// Maximum encoding entries handled on the stack (covers every real-world
73
+ /// Accept-Encoding header). Headers with more entries fall back to the exact
74
+ /// heap path.
75
+ const MAX_STACK_PREFS: usize = 32;
76
+
77
+ /// Parse an Accept-Encoding header into borrowed prefs (no heap allocation).
78
+ /// Returns `usize::MAX` when the header has more than `out.len()` entries —
79
+ /// callers must fall back to the exact heap path in that case.
80
+ fn parse_accept_encoding_refs<'a>(header: &'a [u8], out: &mut [EncodingPrefRef<'a>]) -> usize {
81
+ let mut count = 0usize;
82
+ let mut order = 0u32;
83
+ for part in header.split(|&b| b == b',') {
84
+ let part = trim_ascii_whitespace(part);
85
+ if part.is_empty() {
86
+ continue;
87
+ }
88
+ let (name, q) = match part.iter().position(|&b| b == b';') {
89
+ Some(i) => {
90
+ let qpart = trim_ascii_whitespace(&part[i + 1..]);
91
+ let q = if let Some(eq) = qpart.iter().position(|&b| b == b'=') {
92
+ parse_q(&qpart[eq + 1..])
93
+ } else {
94
+ None
95
+ };
96
+ (trim_ascii_whitespace(&part[..i]), q.unwrap_or(1.0))
97
+ }
98
+ None => (part, 1.0),
99
+ };
100
+ if name.is_empty() {
101
+ continue;
102
+ }
103
+ if count >= out.len() {
104
+ return usize::MAX;
105
+ }
106
+ out[count] = EncodingPrefRef { name, q, order };
107
+ count += 1;
108
+ order += 1;
109
+ }
110
+ count
111
+ }
112
+
113
+ /// Equivalent to `pref_name.to_ascii_lowercase() == supported` WITHOUT
114
+ /// allocating — exactly mirrors the heap path, where the pref is lowercased
115
+ /// and the supported string is compared as-is (case-sensitive). For the usual
116
+ /// pre-lowercased supported lists this is a plain case-insensitive compare.
117
+ #[inline]
118
+ fn name_eq_supported(pref_name: &[u8], supported: &[u8]) -> bool {
119
+ if pref_name.len() != supported.len() {
120
+ return false;
121
+ }
122
+ for (a, b) in pref_name.iter().zip(supported) {
123
+ if a.to_ascii_lowercase() != *b {
124
+ return false;
125
+ }
126
+ }
127
+ true
128
+ }
129
+
130
+ /// Pick the best supported encoding for a header, or `None` for identity.
131
+ /// Priority: most specific match, then highest q, then earliest client order.
132
+ ///
133
+ /// The common path parses the header into a stack buffer (no heap allocations
134
+ /// per call); a header with more than `MAX_STACK_PREFS` entries falls back to
135
+ /// the exact heap path.
136
+ pub fn negotiate_encoding(supported: &[String], header: &[u8]) -> Option<String> {
137
+ let mut stack = [EncodingPrefRef {
138
+ name: &[],
139
+ q: 0.0,
140
+ order: 0,
141
+ }; MAX_STACK_PREFS];
142
+ let count = parse_accept_encoding_refs(header, &mut stack);
143
+ if count == usize::MAX {
144
+ return negotiate_encoding_heap(supported, header);
145
+ }
146
+ if count == 0 {
147
+ return supported.first().cloned();
148
+ }
149
+ negotiate_refs(supported, &stack[..count])
150
+ }
151
+
152
+ /// Heap fallback with identical semantics to `negotiate_refs`, used only for
153
+ /// headers too large for the stack buffer.
154
+ fn negotiate_encoding_heap(supported: &[String], header: &[u8]) -> Option<String> {
155
+ let prefs = parse_accept_encoding_core(header);
156
+ if prefs.is_empty() {
157
+ return supported.first().cloned();
158
+ }
159
+
160
+ let mut best: Option<(&String, f32, u32, u32)> = None; // (enc, q, spec, order)
161
+ for sup in supported {
162
+ let mut matched: Option<(f32, u32, u32)> = None; // (q, spec, order)
163
+ for pref in &prefs {
164
+ let spec = if pref.encoding == sup.as_str() {
165
+ 2
166
+ } else if pref.encoding == "*" {
167
+ 1
168
+ } else {
169
+ continue;
170
+ };
171
+ let replace = match matched {
172
+ None => true,
173
+ Some((_, s, o)) => spec > s || (spec == s && pref.order < o),
174
+ };
175
+ if replace {
176
+ matched = Some((pref.q, spec, pref.order));
177
+ }
178
+ }
179
+ let Some((q, spec, order)) = matched else {
180
+ continue;
181
+ };
182
+ if q <= 0.0 {
183
+ continue;
184
+ }
185
+ let cand = (sup, q, spec, order);
186
+ let better = match best {
187
+ None => true,
188
+ Some((_, bq, bspec, border)) => {
189
+ spec > bspec
190
+ || (spec == bspec && (q - bq).abs() > 1e-4 && q > bq)
191
+ || (spec == bspec && (q - bq).abs() <= 1e-4 && order < border)
192
+ }
193
+ };
194
+ if better {
195
+ best = Some(cand);
196
+ }
197
+ }
198
+ best.map(|(e, _, _, _)| (*e).clone())
199
+ }
200
+
201
+ /// Negotiation core over borrowed prefs (stack path).
202
+ fn negotiate_refs(supported: &[String], prefs: &[EncodingPrefRef<'_>]) -> Option<String> {
203
+ let mut best: Option<(&String, f32, u32, u32)> = None; // (enc, q, spec, order)
204
+ for sup in supported {
205
+ let mut matched: Option<(f32, u32, u32)> = None; // (q, spec, order)
206
+ for pref in prefs {
207
+ let spec = if name_eq_supported(pref.name, sup.as_bytes()) {
208
+ 2
209
+ } else if pref.name == b"*" {
210
+ 1
211
+ } else {
212
+ continue;
213
+ };
214
+ let replace = match matched {
215
+ None => true,
216
+ Some((_, s, o)) => spec > s || (spec == s && pref.order < o),
217
+ };
218
+ if replace {
219
+ matched = Some((pref.q, spec, pref.order));
220
+ }
221
+ }
222
+ let Some((q, spec, order)) = matched else {
223
+ continue;
224
+ };
225
+ if q <= 0.0 {
226
+ continue;
227
+ }
228
+ let cand = (sup, q, spec, order);
229
+ let better = match best {
230
+ None => true,
231
+ Some((_, bq, bspec, border)) => {
232
+ spec > bspec
233
+ || (spec == bspec && (q - bq).abs() > 1e-4 && q > bq)
234
+ || (spec == bspec && (q - bq).abs() <= 1e-4 && order < border)
235
+ }
236
+ };
237
+ if better {
238
+ best = Some(cand);
239
+ }
240
+ }
241
+ best.map(|(e, _, _, _)| (*e).clone())
242
+ }
243
+
244
+ /// ignex-compatible SERVER-preference negotiation (q-only, server order breaks
245
+ /// ties). This is the semantic of ignex's `negotiateEncoding` (compression
246
+ /// plugin): parse the header, apply explicit q-values with a wildcard
247
+ /// fallback for unlisted encodings, exclude `q <= 0`, and on a tie keep the
248
+ /// EARLIER supported entry (server preference) — NOT the client's order and
249
+ /// NOT the RFC-specificity rule. Empty/absent header → `None` (identity),
250
+ /// unlike the specificity negotiator which returns the first supported.
251
+ pub fn negotiate_encoding_server_preference(supported: &[String], header: &[u8]) -> Option<String> {
252
+ let mut stack = [EncodingPrefRef {
253
+ name: &[],
254
+ q: 0.0,
255
+ order: 0,
256
+ }; MAX_STACK_PREFS];
257
+ let count = parse_accept_encoding_refs(header, &mut stack);
258
+ if count == usize::MAX {
259
+ return negotiate_server_preference_heap(supported, header);
260
+ }
261
+ if count == 0 {
262
+ return None; // empty header → identity (no encoding preferred)
263
+ }
264
+ negotiate_server_preference_refs(supported, &stack[..count])
265
+ }
266
+
267
+ /// Server-preference negotiation core over borrowed prefs (stack path).
268
+ fn negotiate_server_preference_refs(
269
+ supported: &[String],
270
+ prefs: &[EncodingPrefRef<'_>],
271
+ ) -> Option<String> {
272
+ // Wildcard q applies to every encoding not listed explicitly.
273
+ let mut wildcard_q = -1.0f32;
274
+ for p in prefs {
275
+ if p.name == b"*" {
276
+ wildcard_q = wildcard_q.max(p.q);
277
+ }
278
+ }
279
+ let mut best: Option<(&String, f32)> = None; // (enc, q); ties keep the FIRST
280
+ for sup in supported {
281
+ // Explicit q for `sup` (first occurrence wins — RFC 7231 §5.3.4
282
+ // duplicates are undefined; ignex keeps the first).
283
+ let mut q = if wildcard_q >= 0.0 { wildcard_q } else { -1.0 };
284
+ for p in prefs {
285
+ if name_eq_supported(p.name, sup.as_bytes()) {
286
+ q = p.q;
287
+ break;
288
+ }
289
+ }
290
+ if q <= 0.0 {
291
+ continue;
292
+ }
293
+ if best.is_none() || q > best.unwrap().1 {
294
+ best = Some((sup, q));
295
+ }
296
+ // Tie (q == best q) → keep the earlier supported entry (server pref).
297
+ }
298
+ best.map(|(e, _)| (*e).clone())
299
+ }
300
+
301
+ /// Heap fallback for the server-preference negotiator (identical semantics).
302
+ fn negotiate_server_preference_heap(supported: &[String], header: &[u8]) -> Option<String> {
303
+ let prefs = parse_accept_encoding_core(header);
304
+ if prefs.is_empty() {
305
+ return None;
306
+ }
307
+ let mut wildcard_q = -1.0f32;
308
+ for p in &prefs {
309
+ if p.encoding == "*" {
310
+ wildcard_q = wildcard_q.max(p.q);
311
+ }
312
+ }
313
+ let mut best: Option<(&String, f32)> = None;
314
+ for sup in supported {
315
+ let mut q = if wildcard_q >= 0.0 { wildcard_q } else { -1.0 };
316
+ for p in &prefs {
317
+ if p.encoding == sup.as_str() {
318
+ q = p.q;
319
+ break;
320
+ }
321
+ }
322
+ if q <= 0.0 {
323
+ continue;
324
+ }
325
+ if best.is_none() || q > best.unwrap().1 {
326
+ best = Some((sup, q));
327
+ }
328
+ }
329
+ best.map(|(e, _)| (*e).clone())
330
+ }
331
+
332
+ /// napi-projected parse result.
333
+ #[napi(object)]
334
+ pub struct EncodingPrefResult {
335
+ pub encoding: String,
336
+ pub q: f64,
337
+ pub order: u32,
338
+ }
339
+
340
+ #[napi]
341
+ pub fn parse_accept_encoding(input: Uint8Array) -> Vec<EncodingPrefResult> {
342
+ parse_accept_encoding_core(input.as_ref())
343
+ .into_iter()
344
+ .map(|p| EncodingPrefResult {
345
+ encoding: p.encoding,
346
+ q: f64::from(p.q),
347
+ order: p.order,
348
+ })
349
+ .collect()
350
+ }
351
+
352
+ /// Higher-order instance: precompiles the supported encodings once and
353
+ /// negotiates any number of headers against that list.
354
+ #[napi]
355
+ pub struct AcceptNegotiator {
356
+ supported: Vec<String>,
357
+ }
358
+
359
+ #[napi]
360
+ impl AcceptNegotiator {
361
+ #[napi(constructor)]
362
+ pub fn new(supported: Vec<String>) -> Self {
363
+ let supported = supported
364
+ .into_iter()
365
+ .map(|s| s.to_ascii_lowercase())
366
+ .collect();
367
+ Self { supported }
368
+ }
369
+
370
+ /// Best supported encoding for `header`, or null for identity.
371
+ #[napi]
372
+ pub fn negotiate(&self, header: Uint8Array) -> Option<String> {
373
+ negotiate_encoding(&self.supported, header.as_ref())
374
+ }
375
+
376
+ /// Best supported encoding for `header` with SERVER-preference
377
+ /// tie-breaking (q-only; the supported list's order decides ties). Matches
378
+ /// ignex's `negotiateEncoding` semantics; empty header → None (identity).
379
+ #[napi]
380
+ pub fn negotiate_server_preference(&self, header: Uint8Array) -> Option<String> {
381
+ negotiate_encoding_server_preference(&self.supported, header.as_ref())
382
+ }
383
+
384
+ /// Opaque handle to the precompiled supported list, for the `bun:ffi`
385
+ /// C-ABI fast path (`castrum_accept_negotiator_negotiate` in rust/ffi.rs).
386
+ /// Only valid while THIS instance is alive; the JS wrapper holds it.
387
+ #[napi]
388
+ pub fn inner_ptr(&self) -> u64 {
389
+ self as *const AcceptNegotiator as u64
390
+ }
391
+ }
392
+
393
+ /// C-ABI support: best supported encoding for `header`, or None = identity.
394
+ ///
395
+ /// # Safety
396
+ /// `p` must be a valid `*const AcceptNegotiator` from `inner_ptr`, alive for
397
+ /// the call (the JS wrapper holds the napi instance).
398
+ pub(crate) unsafe fn accept_negotiator_negotiate_core(
399
+ p: *const AcceptNegotiator,
400
+ header: &[u8],
401
+ ) -> Option<Vec<u8>> {
402
+ let this = &*p;
403
+ negotiate_encoding(&this.supported, header).map(String::into_bytes)
404
+ }
405
+
406
+ /// C-ABI support: server-preference negotiation (ignex `negotiateEncoding`
407
+ /// semantics). `None` = identity (no supported encoding acceptable / empty).
408
+ ///
409
+ /// # Safety
410
+ /// `p` must be a valid `*const AcceptNegotiator` from `inner_ptr`, alive for
411
+ /// the call.
412
+ pub(crate) unsafe fn accept_negotiator_negotiate_server_core(
413
+ p: *const AcceptNegotiator,
414
+ header: &[u8],
415
+ ) -> Option<Vec<u8>> {
416
+ let this = &*p;
417
+ negotiate_encoding_server_preference(&this.supported, header).map(String::into_bytes)
418
+ }
419
+
420
+ #[cfg(test)]
421
+ mod tests {
422
+ use super::*;
423
+
424
+ fn negotiate(supported: &[&str], header: &str) -> Option<String> {
425
+ let sup: Vec<String> = supported.iter().map(|s| s.to_string()).collect();
426
+ negotiate_encoding(&sup, header.as_bytes())
427
+ }
428
+
429
+ #[test]
430
+ fn parses_q_and_order() {
431
+ let prefs = parse_accept_encoding_core(b"gzip;q=0.8, br, *;q=0.1");
432
+ assert_eq!(prefs.len(), 3);
433
+ assert_eq!(prefs[0].encoding, "gzip");
434
+ assert!((prefs[0].q - 0.8).abs() < 1e-4);
435
+ assert_eq!(prefs[0].order, 0);
436
+ assert_eq!(prefs[1].encoding, "br");
437
+ assert!((prefs[1].q - 1.0).abs() < 1e-4);
438
+ assert_eq!(prefs[2].encoding, "*");
439
+ assert!((prefs[2].q - 0.1).abs() < 1e-4);
440
+ }
441
+
442
+ #[test]
443
+ fn lowercases_encoding() {
444
+ let prefs = parse_accept_encoding_core(b"GZip");
445
+ assert_eq!(prefs[0].encoding, "gzip");
446
+ }
447
+
448
+ #[test]
449
+ fn empty_header_returns_first_supported() {
450
+ assert_eq!(negotiate(&["gzip", "br"], ""), Some("gzip".to_string()));
451
+ }
452
+
453
+ #[test]
454
+ fn picks_highest_q_among_explicit() {
455
+ assert_eq!(
456
+ negotiate(&["gzip", "br"], "gzip;q=0.8, br;q=1.0"),
457
+ Some("br".to_string())
458
+ );
459
+ }
460
+
461
+ #[test]
462
+ fn specific_beats_wildcard_even_at_lower_q() {
463
+ assert_eq!(
464
+ negotiate(&["gzip", "br"], "gzip;q=0.5, *;q=1"),
465
+ Some("gzip".to_string())
466
+ );
467
+ }
468
+
469
+ #[test]
470
+ fn q_zero_disables_even_with_wildcard() {
471
+ assert_eq!(negotiate(&["gzip"], "gzip;q=0, *;q=1"), None);
472
+ }
473
+
474
+ #[test]
475
+ fn unlisted_without_wildcard_not_acceptable() {
476
+ assert_eq!(negotiate(&["gzip", "br"], "br;q=1"), Some("br".to_string()));
477
+ assert_eq!(negotiate(&["gzip"], "br;q=1"), None);
478
+ }
479
+
480
+ #[test]
481
+ fn wildcard_matches_unlisted() {
482
+ assert_eq!(
483
+ negotiate(&["gzip"], "br;q=0.1, *;q=0.9"),
484
+ Some("gzip".to_string())
485
+ );
486
+ }
487
+
488
+ #[test]
489
+ fn negotiator_instance_lowercases_supported() {
490
+ let n = AcceptNegotiator::new(vec!["GZip".to_string(), "Br".to_string()]);
491
+ assert_eq!(
492
+ n.negotiate(Uint8Array::new(b"br".to_vec())),
493
+ Some("br".to_string())
494
+ );
495
+ assert_eq!(
496
+ n.negotiate(Uint8Array::new(b"gzip".to_vec())),
497
+ Some("gzip".to_string())
498
+ );
499
+ }
500
+
501
+ #[test]
502
+ fn negotiate_stack_heap_parity() {
503
+ // The allocation-free stack path must produce IDENTICAL results to the
504
+ // exact heap path for every input, including case/whitespace/q-edge
505
+ // cases where the two implementations could drift.
506
+ let supported: Vec<String> = ["gzip", "br", "deflate"]
507
+ .iter()
508
+ .map(|s| s.to_string())
509
+ .collect();
510
+ let cases = [
511
+ "gzip, br, deflate",
512
+ "GZip;q=0.8, br;q=1.0",
513
+ "*;q=0.5",
514
+ "gzip;q=0, br;q=1",
515
+ "",
516
+ "zstd;q=1, *;q=0.1",
517
+ "gzip;q=0.2, gzip;q=0.9",
518
+ " br ;q = 0.5 ",
519
+ "gzip;q=0.9999, br;q=0.9998",
520
+ ];
521
+ for c in cases {
522
+ assert_eq!(
523
+ negotiate_encoding(&supported, c.as_bytes()),
524
+ negotiate_encoding_heap(&supported, c.as_bytes()),
525
+ "stack vs heap parity for {c:?}"
526
+ );
527
+ }
528
+ }
529
+
530
+ #[test]
531
+ fn negotiate_large_header_falls_back_to_heap() {
532
+ // A header with more than MAX_STACK_PREFS entries must exercise the
533
+ // heap fallback and still produce a correct result.
534
+ let mut header = String::new();
535
+ for i in 0..40 {
536
+ if i > 0 {
537
+ header.push(',');
538
+ }
539
+ header.push_str(&format!("enc{i};q=0.1"));
540
+ }
541
+ header.push_str(", gzip;q=1.0");
542
+ assert_eq!(
543
+ negotiate(&["gzip", "br"], &header),
544
+ Some("gzip".to_string())
545
+ );
546
+ }
547
+
548
+ #[test]
549
+ fn server_preference_matches_ignex_negotiate_encoding() {
550
+ let sup: Vec<String> = ["br", "gzip", "deflate"]
551
+ .iter()
552
+ .map(|s| s.to_string())
553
+ .collect();
554
+ let neg = |h: &str| negotiate_encoding_server_preference(&sup, h.as_bytes());
555
+ // The exact vectors from ignex content-encoding.ts `negotiateEncoding`.
556
+ assert_eq!(neg("gzip, br"), Some("br".to_string())); // tie → server pref (br first)
557
+ assert_eq!(neg("br;q=0.8, gzip;q=0.9"), Some("gzip".to_string()));
558
+ assert_eq!(neg("*"), Some("br".to_string()));
559
+ assert_eq!(neg("gzip;q=0, deflate"), Some("deflate".to_string()));
560
+ assert_eq!(neg("identity"), None);
561
+ assert_eq!(neg(""), None); // empty → identity
562
+ assert_eq!(
563
+ neg("deflate, gzip;q=0.5, br;q=0.3"),
564
+ Some("deflate".to_string())
565
+ );
566
+ }
567
+
568
+ #[test]
569
+ fn server_preference_wildcard_vs_explicit() {
570
+ let sup: Vec<String> = ["gzip", "br"].iter().map(|s| s.to_string()).collect();
571
+ let neg = |h: &str| negotiate_encoding_server_preference(&sup, h.as_bytes());
572
+ // Explicit q=0.5 < wildcard q=1 → br (unlisted) wins (q-only, no specificity).
573
+ assert_eq!(neg("gzip;q=0.5, *;q=1"), Some("br".to_string()));
574
+ // Explicit q=1 beats wildcard q=0.5.
575
+ assert_eq!(neg("gzip;q=1, *;q=0.5"), Some("gzip".to_string()));
576
+ // Tie (both q=1) → FIRST supported entry wins (server preference):
577
+ // supported is ["gzip", "br"], so gzip wins regardless of client order.
578
+ assert_eq!(neg("br;q=1, gzip;q=1"), Some("gzip".to_string()));
579
+ }
580
+
581
+ #[test]
582
+ fn server_preference_stack_heap_parity() {
583
+ let sup: Vec<String> = ["br", "gzip", "deflate"]
584
+ .iter()
585
+ .map(|s| s.to_string())
586
+ .collect();
587
+ let cases = [
588
+ "gzip, br, deflate",
589
+ "GZip;q=0.8, br;q=1.0",
590
+ "*;q=0.5",
591
+ "gzip;q=0, br;q=1",
592
+ "",
593
+ "zstd;q=1, *;q=0.1",
594
+ "gzip;q=0.2, gzip;q=0.9",
595
+ " br ;q = 0.5 ",
596
+ "gzip;q=0.9999, br;q=0.9998",
597
+ ];
598
+ for c in cases {
599
+ assert_eq!(
600
+ negotiate_encoding_server_preference(&sup, c.as_bytes()),
601
+ negotiate_server_preference_heap(&sup, c.as_bytes()),
602
+ "server-pref stack vs heap parity for {c:?}"
603
+ );
604
+ }
605
+ }
606
+ }