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,214 @@
1
+ // rust/payload/sse.rs — Server-Sent Events framing.
2
+ //
3
+ // Backend-framework feature: SSE response framing. Encodes a single SSE event
4
+ // per the WHATWG SSE spec: optional `id:`, `event:`, `retry:` lines followed by
5
+ // one `data:` line per input line and a terminating blank line. Pure byte work
6
+ // with no allocation beyond the output buffer.
7
+
8
+ use napi::bindgen_prelude::*;
9
+ use napi_derive::napi;
10
+
11
+ // ── Pure-Rust core ─────────────────────────────────────────────
12
+
13
+ /// Digits in the base-10 decimal form of a `u64` (for the `retry:` line —
14
+ /// avoids `retry.to_string()`'s heap allocation).
15
+ #[inline]
16
+ fn u64_digits(mut n: u64) -> usize {
17
+ let mut c = 1;
18
+ while n >= 10 {
19
+ n /= 10;
20
+ c += 1;
21
+ }
22
+ c
23
+ }
24
+
25
+ /// Exact encoded size of one SSE event (no allocation).
26
+ #[inline]
27
+ pub fn encode_event_size(
28
+ event: Option<&str>,
29
+ data: &[u8],
30
+ id: Option<&str>,
31
+ retry: Option<u64>,
32
+ ) -> usize {
33
+ let mut need = 0usize;
34
+ if let Some(id) = id {
35
+ need += 5 + id.len(); // "id: " + id + "\n"
36
+ }
37
+ if let Some(event) = event {
38
+ need += 8 + event.len(); // "event: " + event + "\n"
39
+ }
40
+ if let Some(retry) = retry {
41
+ need += 8 + u64_digits(retry); // "retry: " + digits + "\n"
42
+ }
43
+ // One `data: ` (6) + `\n` (1) per input line, the line bytes themselves,
44
+ // then the terminating blank `\n`.
45
+ let line_count = data.iter().filter(|&&b| b == b'\n').count() + 1;
46
+ need + line_count * 7 + data.len() + 1
47
+ }
48
+
49
+ /// Encode one SSE event into `out`, returning bytes written. Zero-alloc (no
50
+ /// `retry.to_string()`, no output `Vec`). Errors (`&'static str`) if `out` is
51
+ /// too small — nothing is written on error.
52
+ pub fn encode_event_into_slice(
53
+ event: Option<&str>,
54
+ data: &[u8],
55
+ id: Option<&str>,
56
+ retry: Option<u64>,
57
+ out: &mut [u8],
58
+ ) -> std::result::Result<usize, &'static str> {
59
+ let need = encode_event_size(event, data, id, retry);
60
+ if need > out.len() {
61
+ return Err("sse encode: output buffer too small");
62
+ }
63
+ let mut w = 0usize;
64
+ if let Some(id) = id {
65
+ out[w..w + 4].copy_from_slice(b"id: ");
66
+ w += 4;
67
+ out[w..w + id.len()].copy_from_slice(id.as_bytes());
68
+ w += id.len();
69
+ out[w] = b'\n';
70
+ w += 1;
71
+ }
72
+ if let Some(event) = event {
73
+ out[w..w + 7].copy_from_slice(b"event: ");
74
+ w += 7;
75
+ out[w..w + event.len()].copy_from_slice(event.as_bytes());
76
+ w += event.len();
77
+ out[w] = b'\n';
78
+ w += 1;
79
+ }
80
+ if let Some(retry) = retry {
81
+ out[w..w + 7].copy_from_slice(b"retry: ");
82
+ w += 7;
83
+ let mut digits = [0u8; 20];
84
+ let mut n = retry;
85
+ let mut i = 0;
86
+ loop {
87
+ digits[i] = b'0' + (n % 10) as u8;
88
+ n /= 10;
89
+ i += 1;
90
+ if n == 0 {
91
+ break;
92
+ }
93
+ }
94
+ for &d in digits[..i].iter().rev() {
95
+ out[w] = d;
96
+ w += 1;
97
+ }
98
+ out[w] = b'\n';
99
+ w += 1;
100
+ }
101
+ for line in data.split(|&b| b == b'\n') {
102
+ out[w..w + 6].copy_from_slice(b"data: ");
103
+ w += 6;
104
+ out[w..w + line.len()].copy_from_slice(line);
105
+ w += line.len();
106
+ out[w] = b'\n';
107
+ w += 1;
108
+ }
109
+ out[w] = b'\n';
110
+ w += 1;
111
+ Ok(w)
112
+ }
113
+
114
+ /// Encode one SSE event. Multi-line `data` is emitted as repeated `data:` lines.
115
+ pub fn encode_event(
116
+ event: Option<&str>,
117
+ data: &[u8],
118
+ id: Option<&str>,
119
+ retry: Option<u64>,
120
+ ) -> Vec<u8> {
121
+ let mut out = vec![0u8; encode_event_size(event, data, id, retry)];
122
+ let w = encode_event_into_slice(event, data, id, retry, &mut out)
123
+ .expect("output sized to the exact encoded length");
124
+ out.truncate(w);
125
+ out
126
+ }
127
+
128
+ // ── NAPI entry points ──────────────────────────────────────────
129
+
130
+ /// Encode a single SSE event → bytes.
131
+ #[napi]
132
+ pub fn sse_encode_event(
133
+ event: Option<String>,
134
+ data: Uint8Array,
135
+ id: Option<String>,
136
+ retry: Option<u32>,
137
+ ) -> Buffer {
138
+ Buffer::from(encode_event(
139
+ event.as_deref(),
140
+ data.as_ref(),
141
+ id.as_deref(),
142
+ retry.map(u64::from),
143
+ ))
144
+ }
145
+
146
+ /// Parallel SSE batch: packed `[u32 count]{[u32 len][data]}` in → packed
147
+ /// `[u32 count]{[u32 len][event]}` out (same event/id/retry for all items).
148
+ #[napi]
149
+ pub fn sse_encode_batch_packed(
150
+ data: Uint8Array,
151
+ event: Option<String>,
152
+ id: Option<String>,
153
+ retry: Option<u32>,
154
+ ) -> Result<Buffer> {
155
+ let encode_one =
156
+ |d: &[u8]| encode_event(event.as_deref(), d, id.as_deref(), retry.map(u64::from));
157
+ crate::util::run_packed_batch(data.as_ref(), encode_one).map(Buffer::from)
158
+ }
159
+
160
+ #[cfg(test)]
161
+ mod tests {
162
+ use super::*;
163
+
164
+ #[test]
165
+ fn encodes_basic_event() {
166
+ assert_eq!(encode_event(None, b"hello", None, None), b"data: hello\n\n");
167
+ }
168
+
169
+ #[test]
170
+ fn encodes_multiline_data() {
171
+ assert_eq!(
172
+ encode_event(None, b"line1\nline2", None, None),
173
+ b"data: line1\ndata: line2\n\n"
174
+ );
175
+ }
176
+
177
+ #[test]
178
+ fn encodes_named_event_with_id_and_retry() {
179
+ assert_eq!(
180
+ encode_event(Some("update"), b"payload", Some("42"), Some(3000)),
181
+ b"id: 42\nevent: update\nretry: 3000\ndata: payload\n\n"
182
+ );
183
+ }
184
+
185
+ #[test]
186
+ fn empty_data_still_terminates() {
187
+ assert_eq!(encode_event(None, b"", None, None), b"data: \n\n");
188
+ }
189
+
190
+ #[test]
191
+ fn into_slice_matches_allocating() {
192
+ type SseCase<'a> = (Option<&'a str>, &'a [u8], Option<&'a str>, Option<u64>);
193
+ let cases: &[SseCase] = &[
194
+ (None, b"hello", None, None),
195
+ (Some("update"), b"payload", Some("42"), Some(3000)),
196
+ (None, b"line1\nline2\nline3", None, None),
197
+ (Some("ev"), b"", Some("id"), Some(0)),
198
+ (Some(""), b"x", None, None),
199
+ ];
200
+ for &(event, data, id, retry) in cases {
201
+ let expected = encode_event(event, data, id, retry);
202
+ let mut out = vec![0u8; expected.len() + 8];
203
+ let w = encode_event_into_slice(event, data, id, retry, &mut out).unwrap();
204
+ assert_eq!(w, expected.len());
205
+ assert_eq!(&out[..w], &expected[..]);
206
+ }
207
+ }
208
+
209
+ #[test]
210
+ fn into_slice_rejects_too_small() {
211
+ let mut out = [0u8; 3];
212
+ assert!(encode_event_into_slice(Some("update"), b"payload", None, None, &mut out).is_err());
213
+ }
214
+ }
@@ -0,0 +1,253 @@
1
+ // rust/payload/template.rs — HTML/text templating via minijinja.
2
+ //
3
+ // Backend-framework feature: server-side templating. minijinja is a fast, safe,
4
+ // Jinja2-compatible engine (no eval, no unsafe). Templates are compiled once and
5
+ // cached inside the `Environment`. The napi JS-object `render` accepts a
6
+ // `serde_json::Value` (marshal from JS); the pre-serialized byte paths
7
+ // (`render_bytes` / batch / C-ABI) parse straight into `sonic_rs::Value`
8
+ // (compact, zero per-key heap `String`) — both render via the `Serialize`
9
+ // trait, so behavior is identical.
10
+ //
11
+ // The napi class keeps an owned `Environment<'static>` (templates added via
12
+ // `add_template_owned`), so it is `Send + Sync` and safe to hold across calls.
13
+
14
+ use napi::bindgen_prelude::*;
15
+ use napi_derive::napi;
16
+
17
+ /// A compiled template renderer. Construct once per template, render many times
18
+ /// with different contexts.
19
+ #[napi]
20
+ pub struct TemplateRenderer {
21
+ // Owns the compiled template state (templates added via add_template_owned).
22
+ env: minijinja::Environment<'static>,
23
+ }
24
+
25
+ #[napi]
26
+ impl TemplateRenderer {
27
+ #[napi(constructor)]
28
+ pub fn new(source: String) -> Result<Self> {
29
+ let mut env = minijinja::Environment::new();
30
+ env.add_template_owned("main", source)
31
+ .map_err(|e| Error::from_reason(format!("template compile failed: {e}")))?;
32
+ Ok(Self { env })
33
+ }
34
+
35
+ /// Opaque handle to the compiled template, for the `bun:ffi` C-ABI fast
36
+ /// path (`castrum_template_render` in rust/ffi.rs). Only valid while THIS
37
+ /// instance is alive; the JS wrapper holds the instance.
38
+ #[napi]
39
+ pub fn inner_ptr(&self) -> u64 {
40
+ self as *const TemplateRenderer as u64
41
+ }
42
+
43
+ /// Render the template with the given JSON context → UTF-8 bytes.
44
+ #[napi]
45
+ pub fn render(&self, context: serde_json::Value) -> Result<Buffer> {
46
+ let tpl = self
47
+ .env
48
+ .get_template("main")
49
+ .map_err(|e| Error::from_reason(format!("template compile failed: {e}")))?;
50
+ let out = tpl
51
+ .render(&context)
52
+ .map_err(|e| Error::from_reason(format!("template render failed: {e}")))?;
53
+ Ok(Buffer::from(out.into_bytes()))
54
+ }
55
+
56
+ /// Render the template with a PRE-SERIALIZED JSON context (bytes) → UTF-8
57
+ /// bytes. Avoids the napi `serde_json::Value` DOM marshal of `render` for
58
+ /// callers that already hold the context bytes (same core as the packed
59
+ /// batch path). The context is parsed straight into a `sonic_rs::Value`
60
+ /// (compact, no per-key heap `String`) instead of `serde_json::Value`.
61
+ #[napi]
62
+ pub fn render_bytes(&self, context_json: Uint8Array) -> Result<Buffer> {
63
+ let tpl = self
64
+ .env
65
+ .get_template("main")
66
+ .map_err(|e| Error::from_reason(format!("template compile failed: {e}")))?;
67
+ let context: sonic_rs::Value = sonic_rs::from_slice(context_json.as_ref())
68
+ .map_err(|e| Error::from_reason(format!("template context is not valid JSON: {e}")))?;
69
+ let out = tpl
70
+ .render(&context)
71
+ .map_err(|e| Error::from_reason(format!("template render failed: {e}")))?;
72
+ Ok(Buffer::from(out.into_bytes()))
73
+ }
74
+
75
+ /// Parallel render batch: packed `[u32 count]{[u32 len][context-json]}` in
76
+ /// → packed `[u32 count]{[u32 len][rendered]}` out. The compiled template
77
+ /// is fetched ONCE and reused for every item — no per-call recompilation.
78
+ #[napi]
79
+ pub fn render_batch_packed(&self, data: Uint8Array) -> Result<Buffer> {
80
+ // get_template is a cheap internal Arc lookup — the expensive part
81
+ // (compiling the template) happened once in the constructor.
82
+ let tpl = self
83
+ .env
84
+ .get_template("main")
85
+ .map_err(|e| Error::from_reason(format!("template compile failed: {e}")))?;
86
+
87
+ let render_one = |context_json: &[u8]| -> Vec<u8> {
88
+ let Ok(context) = sonic_rs::from_slice::<sonic_rs::Value>(context_json) else {
89
+ return Vec::new();
90
+ };
91
+ tpl.render(&context)
92
+ .map(|s| s.into_bytes())
93
+ .unwrap_or_default()
94
+ };
95
+
96
+ crate::util::run_packed_batch(data.as_ref(), render_one).map(Buffer::from)
97
+ }
98
+ }
99
+
100
+ /// C-ABI support: render the compiled template with a pre-serialized JSON
101
+ /// context. `Err(())` on invalid JSON context / render error (napi parity).
102
+ ///
103
+ /// # Safety
104
+ /// `p` must be a valid `*const TemplateRenderer` from `inner_ptr`, alive for
105
+ /// the call (the JS wrapper holds the napi instance).
106
+ pub(crate) unsafe fn template_render_core(
107
+ p: *const TemplateRenderer,
108
+ context_json: &[u8],
109
+ ) -> std::result::Result<Vec<u8>, ()> {
110
+ let this = &*p;
111
+ let tpl = this.env.get_template("main").map_err(|_| ())?;
112
+ let context: sonic_rs::Value = sonic_rs::from_slice(context_json).map_err(|_| ())?;
113
+ let out = tpl.render(&context).map_err(|_| ())?;
114
+ Ok(out.into_bytes())
115
+ }
116
+
117
+ /// Parallel render batch: packed `[u32 count]{[u32 len][context-json]}` in →
118
+ /// packed `[u32 count]{[u32 len][rendered]}` out. A new `TemplateRenderer` is
119
+ /// built once per call from `source`, then all contexts render in parallel.
120
+ #[napi]
121
+ pub fn template_render_batch_packed(data: Uint8Array, source: String) -> Result<Buffer> {
122
+ let mut env = minijinja::Environment::new();
123
+ env.add_template_owned("main", source)
124
+ .map_err(|e| Error::from_reason(format!("template compile failed: {e}")))?;
125
+
126
+ let render_one = |context_json: &[u8]| -> Vec<u8> {
127
+ let Ok(context) = sonic_rs::from_slice::<sonic_rs::Value>(context_json) else {
128
+ return Vec::new();
129
+ };
130
+ let Ok(tpl) = env.get_template("main") else {
131
+ return Vec::new();
132
+ };
133
+ tpl.render(&context)
134
+ .map(|s| s.into_bytes())
135
+ .unwrap_or_default()
136
+ };
137
+
138
+ crate::util::run_packed_batch(data.as_ref(), render_one).map(Buffer::from)
139
+ }
140
+
141
+ #[cfg(test)]
142
+ mod tests {
143
+ use super::*;
144
+ use std::collections::BTreeMap;
145
+
146
+ #[test]
147
+ fn renders_variables() {
148
+ let mut env = minijinja::Environment::new();
149
+ env.add_template("t", "Hello {{ name }}!").unwrap();
150
+ let tpl = env.get_template("t").unwrap();
151
+ let mut ctx = BTreeMap::new();
152
+ ctx.insert("name", "World");
153
+ assert_eq!(tpl.render(ctx).unwrap(), "Hello World!");
154
+ }
155
+
156
+ #[test]
157
+ fn renders_loops_over_serde_value() {
158
+ let mut env = minijinja::Environment::new();
159
+ env.add_template(
160
+ "t",
161
+ "{% for u in users %}<li>{{ u.name }} ({{ u.id }})</li>{% endfor %}",
162
+ )
163
+ .unwrap();
164
+ let tpl = env.get_template("t").unwrap();
165
+
166
+ let context = serde_json::json!({
167
+ "users": [
168
+ { "name": "Alice", "id": 1 },
169
+ { "name": "Bob", "id": 2 },
170
+ ]
171
+ });
172
+ let out = tpl.render(&context).unwrap();
173
+ assert_eq!(out, "<li>Alice (1)</li><li>Bob (2)</li>");
174
+ }
175
+
176
+ #[test]
177
+ fn compile_error_is_reported() {
178
+ let mut env = minijinja::Environment::new();
179
+ assert!(env.add_template("t", "{% for x in %}").is_err());
180
+ }
181
+
182
+ #[test]
183
+ fn missing_variable_renders_empty() {
184
+ let mut env = minijinja::Environment::new();
185
+ env.add_template("t", "[{{ missing }}]").unwrap();
186
+ let tpl = env.get_template("t").unwrap();
187
+ let ctx = serde_json::json!({});
188
+ assert_eq!(tpl.render(&ctx).unwrap(), "[]");
189
+ }
190
+
191
+ fn pack_contexts(contexts: &[serde_json::Value]) -> Vec<u8> {
192
+ let mut out = Vec::new();
193
+ out.extend_from_slice(&(contexts.len() as u32).to_le_bytes());
194
+ for c in contexts {
195
+ let bytes = serde_json::to_vec(c).unwrap();
196
+ out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
197
+ out.extend_from_slice(&bytes);
198
+ }
199
+ out
200
+ }
201
+
202
+ #[test]
203
+ fn renderer_instance_reuses_compiled_template() {
204
+ let r = TemplateRenderer::new("Hello {{ name }}!".to_string()).unwrap();
205
+ let ctx = serde_json::json!({ "name": "World" });
206
+ assert_eq!(&r.render(ctx).unwrap()[..], b"Hello World!");
207
+
208
+ // Batch path must reuse the SAME compiled template.
209
+ let data = pack_contexts(&[
210
+ serde_json::json!({ "name": "Alice" }),
211
+ serde_json::json!({ "name": "Bob" }),
212
+ ]);
213
+ let batch = r.render_batch_packed(Uint8Array::new(data)).unwrap();
214
+ assert_eq!(u32::from_le_bytes(batch[..4].try_into().unwrap()), 2);
215
+
216
+ let mut pos = 4usize;
217
+ let mut renders = Vec::new();
218
+ for _ in 0..2 {
219
+ let len = u32::from_le_bytes(batch[pos..pos + 4].try_into().unwrap()) as usize;
220
+ pos += 4;
221
+ renders.push(String::from_utf8(batch[pos..pos + len].to_vec()).unwrap());
222
+ pos += len;
223
+ }
224
+ assert_eq!(
225
+ renders,
226
+ vec!["Hello Alice!".to_string(), "Hello Bob!".to_string()]
227
+ );
228
+ }
229
+
230
+ #[test]
231
+ fn render_bytes_matches_render() {
232
+ let r = TemplateRenderer::new("Hello {{ name }}!".to_string()).unwrap();
233
+ let via_value = r.render(serde_json::json!({ "name": "World" })).unwrap();
234
+ let via_bytes = r
235
+ .render_bytes(Uint8Array::new(br#"{"name":"World"}"#.to_vec()))
236
+ .unwrap();
237
+ assert_eq!(via_value.as_ref(), via_bytes.as_ref());
238
+ assert_eq!(&via_bytes[..], b"Hello World!");
239
+ }
240
+
241
+ #[test]
242
+ fn render_bytes_rejects_invalid_json_context() {
243
+ let r = TemplateRenderer::new("x".to_string()).unwrap();
244
+ assert!(r
245
+ .render_bytes(Uint8Array::new(b"not json".to_vec()))
246
+ .is_err());
247
+ }
248
+
249
+ #[test]
250
+ fn renderer_rejects_bad_compile() {
251
+ assert!(TemplateRenderer::new("{% for x in %}".to_string()).is_err());
252
+ }
253
+ }
@@ -0,0 +1,69 @@
1
+ // rust/payload/websocket.rs — RFC 6455 Sec-WebSocket-Accept key derivation.
2
+
3
+ use aws_lc_rs::digest;
4
+ use base64::engine::general_purpose::STANDARD as BASE64;
5
+ use base64::Engine;
6
+ use napi::bindgen_prelude::*;
7
+ use napi_derive::napi;
8
+
9
+ const WS_MAGIC: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
10
+
11
+ /// Zero-alloc core: RFC 6455 Sec-WebSocket-Accept (exactly 28 bytes) into a
12
+ /// caller-provided buffer. Shared by the C-ABI (`bun:ffi`) path so it never
13
+ /// allocates the accept string.
14
+ pub fn ws_accept_key_into(key: &[u8], out: &mut [u8]) -> Result<usize> {
15
+ let mut ctx = digest::Context::new(&digest::SHA1_FOR_LEGACY_USE_ONLY);
16
+ ctx.update(key);
17
+ ctx.update(WS_MAGIC);
18
+
19
+ let hash = ctx.finish();
20
+
21
+ BASE64
22
+ .encode_slice(hash.as_ref(), out)
23
+ .map_err(|e| Error::from_reason(e.to_string()))
24
+ }
25
+
26
+ /// RFC 6455 Sec-WebSocket-Accept core over a raw key slice. Shared by the
27
+ /// scalar napi path and the packed batch path.
28
+ pub fn ws_accept_key_bytes(key: &[u8]) -> Result<Vec<u8>> {
29
+ let mut out = [0u8; 28];
30
+ let n = ws_accept_key_into(key, &mut out)?;
31
+ Ok(out[..n].to_vec())
32
+ }
33
+
34
+ #[napi]
35
+ pub fn ws_accept_key(key: Uint8Array) -> Result<Buffer> {
36
+ Ok(Buffer::from(ws_accept_key_bytes(key.as_ref())?))
37
+ }
38
+
39
+ /// Packed WebSocket accept-key batch: `[u32 count]{[u32 len][key]}` in →
40
+ /// `[u32 count]{[u32 len][accept]}` out.
41
+ #[napi]
42
+ pub fn ws_accept_key_batch_packed(input: Uint8Array) -> Result<Buffer> {
43
+ crate::util::run_packed_batch(input.as_ref(), |k| {
44
+ ws_accept_key_bytes(k).unwrap_or_default()
45
+ })
46
+ .map(Buffer::from)
47
+ }
48
+
49
+ #[cfg(test)]
50
+ mod tests {
51
+ use super::*;
52
+
53
+ #[test]
54
+ fn rfc6455_accept_key_vector() {
55
+ // RFC 6455 §1.3: accept = base64(SHA1(key + magic GUID)) where `key` is
56
+ // the Sec-WebSocket-Key header value (the base64 string) concatenated
57
+ // with the magic GUID. Verified against node:crypto.
58
+ let key = b"dGhlIHNhbXBsZSBub25jZQ==";
59
+ let out = ws_accept_key(Uint8Array::new(key.to_vec())).unwrap();
60
+ assert_eq!(out.as_ref(), b"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
61
+ }
62
+
63
+ #[test]
64
+ fn different_keys_differ() {
65
+ let a = ws_accept_key(Uint8Array::new(b"AAAA".to_vec())).unwrap();
66
+ let b = ws_accept_key(Uint8Array::new(b"BBBB".to_vec())).unwrap();
67
+ assert_ne!(a.as_ref(), b.as_ref());
68
+ }
69
+ }