castrum 0.9.3 → 0.9.6

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 (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,247 @@
1
+ // src/native/ffi/build/metrics.ts — metrics-registry BunFFI methods.
2
+ //
3
+ // Wraps the `castrum_metrics_*` caller-owned-handle surface (route-stack
4
+ // ownership model): create returns a native `Box<MetricsRegistry>` handle,
5
+ // declare fns cross name/label-keys as cstring ARGS and return the u32 series
6
+ // id, and record/set/render take packed byte slices. Receives the raw dlopen'd
7
+ // symbols from `build()`.
8
+
9
+ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
10
+ import type { BunFFI, Raw0, Raw3, Raw4, Raw5 } from '../types'
11
+ import type { BuildCtx } from './util'
12
+ import { hasNul } from './util'
13
+
14
+ /**
15
+ * Declare-time NUL guard: a metric NAME or label-key set containing `U+0000`
16
+ * would be truncated by the `cstring` arg, silently colliding two series. This
17
+ * runs once at declaration, so failing fast costs nothing on the hot path.
18
+ */
19
+ function throwIfNul(value: string, what: string): void {
20
+ if (hasNul(value)) {
21
+ throw new Error(`metrics: ${what} must not contain a NUL character (U+0000)`)
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Sentinel returned by the declare fns (`u32::MAX`) on invalid input — fits
27
+ * f64 exactly and can never collide with a real dense family id.
28
+ */
29
+ export const METRICS_DECLARE_ERR = 0xffffffff
30
+
31
+ /** Empty label-values view (zero-label families). */
32
+ const EMPTY = new Uint8Array(0)
33
+
34
+ /**
35
+ * Build the metrics-registry methods of the BunFFI surface.
36
+ */
37
+ export function buildMetrics(
38
+ sym: Record<string, (...a: unknown[]) => unknown>,
39
+ ctx: BuildCtx,
40
+ ): Partial<BunFFI> {
41
+ const { lenOrView } = ctx
42
+ const metricsCreateRaw = sym.castrum_metrics_create as Raw0
43
+ const metricsCounterRaw = sym.castrum_metrics_counter as Raw3
44
+ const metricsGaugeRaw = sym.castrum_metrics_gauge as Raw3
45
+ const metricsHistogramRaw = sym.castrum_metrics_histogram as Raw4
46
+ const metricsRecordRaw = sym.castrum_metrics_record as Raw5
47
+ const metricsGaugeSetRaw = sym.castrum_metrics_gauge_set as Raw5
48
+ // Zero-encode siblings: joined values cross as a cstring ARG.
49
+ const metricsRecordStrRaw = sym.castrum_metrics_record_str as (...a: unknown[]) => number | bigint
50
+ const metricsGaugeSetStrRaw = sym.castrum_metrics_gauge_set_str as (
51
+ ...a: unknown[]
52
+ ) => number | bigint
53
+ const metricsRenderRaw = sym.castrum_metrics_render as Raw3
54
+ const metricsSnapshotRaw = sym.castrum_metrics_snapshot as Raw3
55
+ const metricsRecordBatchRaw = sym.castrum_metrics_record_batch as Raw3
56
+ const metricsDestroyRaw = sym.castrum_metrics_destroy as (h: unknown) => void
57
+
58
+ /** Declare helper: maps the C sentinel to a throw (fail fast at declare). */
59
+ const declareOrThrow = (id: number | bigint, what: string): number => {
60
+ const n = Number(id)
61
+ if (n === METRICS_DECLARE_ERR) {
62
+ throw new Error(`metrics: failed to declare ${what} (invalid name/labels/buckets)`)
63
+ }
64
+ return n
65
+ }
66
+
67
+ return {
68
+ metricsCreate() {
69
+ const h = Number(metricsCreateRaw())
70
+ if (h === 0) throw new Error('metrics: registry allocation failed')
71
+ return h
72
+ },
73
+ metricsCounter(handle, name, labelKeys) {
74
+ throwIfNul(name, 'counter name')
75
+ throwIfNul(labelKeys, 'counter label keys')
76
+ return declareOrThrow(metricsCounterRaw(handle, name, labelKeys), `counter "${name}"`)
77
+ },
78
+ metricsGauge(handle, name, labelKeys) {
79
+ throwIfNul(name, 'gauge name')
80
+ throwIfNul(labelKeys, 'gauge label keys')
81
+ return declareOrThrow(metricsGaugeRaw(handle, name, labelKeys), `gauge "${name}"`)
82
+ },
83
+ metricsHistogram(handle, name, labelKeys, bucketsCsv) {
84
+ throwIfNul(name, 'histogram name')
85
+ throwIfNul(labelKeys, 'histogram label keys')
86
+ return declareOrThrow(
87
+ metricsHistogramRaw(handle, name, labelKeys, bucketsCsv),
88
+ `histogram "${name}"`,
89
+ )
90
+ },
91
+ metricsRecord(handle, series, values, amount) {
92
+ // Packed `\x1f`-separated values cross as `(ptr,len)`; u8 verdict.
93
+ return Number(metricsRecordRaw(handle, series, values, lenOrView(values), amount)) === 1
94
+ },
95
+ metricsGaugeSet(handle, series, values, value) {
96
+ return Number(metricsGaugeSetRaw(handle, series, values, lenOrView(values), value)) === 1
97
+ },
98
+ metricsRecordStr(handle, series, values, amount) {
99
+ // `values` is the JOINED `\x1f` string — engine-transcoded (zero encode).
100
+ // Label values come from user data, so a NUL would truncate the join and
101
+ // silently record the WRONG series (the native side rejects a NUL value,
102
+ // but only if it ever sees it). Fail loudly and say why.
103
+ throwIfNul(values, 'label values')
104
+ return Number(metricsRecordStrRaw(handle, series, values, amount)) === 1
105
+ },
106
+ metricsGaugeSetStr(handle, series, values, value) {
107
+ throwIfNul(values, 'label values')
108
+ return Number(metricsGaugeSetStrRaw(handle, series, values, value)) === 1
109
+ },
110
+ metricsRender(handle, output) {
111
+ // Needed-size convention (routeRun parity): `0` = real error → throw;
112
+ // `> output.length` = the EXACT required size (caller grows once and
113
+ // retries — never a doubling re-run loop).
114
+ const w = Number(metricsRenderRaw(handle, output, lenOrView(output)))
115
+ if (w === 0) throw new Error('metrics render: registry handle invalid or render failed')
116
+ return w
117
+ },
118
+ metricsSnapshot(handle, output) {
119
+ // Needed-size convention (routeRun parity): 0 throws; > length = exact.
120
+ const w = Number(metricsSnapshotRaw(handle, output, lenOrView(output)))
121
+ if (w === 0) throw new Error('metrics snapshot: registry handle invalid')
122
+ return w
123
+ },
124
+ metricsRecordBatch(handle, packed) {
125
+ return Number(metricsRecordBatchRaw(handle, packed, lenOrView(packed))) === 1
126
+ },
127
+ metricsDestroy(handle) {
128
+ metricsDestroyRaw(handle)
129
+ },
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Bind-time self-test for the metrics surface (unique `ct.selftest.*` names).
135
+ * Exercises every symbol's ABI at bind time; `false` disables the ffi layer
136
+ * and forces the napi fallback.
137
+ */
138
+ export function selfTestMetrics(b: BunFFI): boolean {
139
+ let handle = 0
140
+ try {
141
+ handle = b.metricsCreate()
142
+ if (handle === 0) return false
143
+
144
+ const counter = b.metricsCounter(handle, 'ct_selftest_hits', 'route\u001fstatus')
145
+ if (!Number.isInteger(counter) || counter < 0 || counter === METRICS_DECLARE_ERR) {
146
+ console.error('FAIL: declare')
147
+ return false
148
+ }
149
+ if (!b.metricsRecord(handle, counter, encodeUtf8('/a\u001f200'), 2)) {
150
+ console.error('FAIL: record1')
151
+ return false
152
+ }
153
+ if (!b.metricsRecord(handle, counter, encodeUtf8('/a\u001f200'), 1)) {
154
+ console.error('FAIL: record2')
155
+ return false
156
+ }
157
+ if (!b.metricsRecordStr(handle, counter, '/b\u001f200', 2)) {
158
+ console.error('FAIL: recordStr')
159
+ return false
160
+ }
161
+
162
+ console.error('CK: counter')
163
+ const gauge = b.metricsGauge(handle, 'ct_selftest_depth', 'q')
164
+ if (gauge === METRICS_DECLARE_ERR) return false
165
+ if (!b.metricsGaugeSet(handle, gauge, encodeUtf8('jobs'), 4)) return false
166
+ if (!b.metricsGaugeSetStr(handle, gauge, 'io', 6)) return false
167
+
168
+ console.error('CK: gauge')
169
+ const hist = b.metricsHistogram(handle, 'ct_selftest_latency', '', '0.1,0.5')
170
+ if (hist === METRICS_DECLARE_ERR) return false
171
+ if (!b.metricsRecord(handle, hist, EMPTY, 0.25)) return false
172
+ if (b.metricsRecord(handle, hist, EMPTY, -1)) return false // negatives rejected
173
+
174
+ console.error('CK: hist')
175
+ // Arity mismatch must fail safely.
176
+ if (b.metricsRecord(handle, counter, encodeUtf8('/a'), 1)) return false
177
+
178
+ console.error('CK: arity')
179
+ // Render: probe with a small buffer → exact required size; then render.
180
+ const probe = new Uint8Array(16)
181
+ const needed = b.metricsRender(handle, probe)
182
+ if (needed <= probe.length) return false
183
+ const out = new Uint8Array(needed)
184
+ const written = b.metricsRender(handle, out)
185
+ if (written !== needed) return false
186
+ const text = decodeUtf8(out.subarray(0, written))
187
+ if (
188
+ !text.includes('ct_selftest_hits{route="/a",status="200"} 3\n') ||
189
+ !text.includes('ct_selftest_hits{route="/b",status="200"} 2\n') ||
190
+ !text.includes('ct_selftest_depth{q="jobs"} 4\n') ||
191
+ !text.includes('ct_selftest_depth{q="io"} 6\n') ||
192
+ !text.includes('ct_selftest_latency_bucket{le="0.5"} 1\n') ||
193
+ !text.includes('ct_selftest_latency_count 1\n')
194
+ ) {
195
+ return false
196
+ }
197
+
198
+ console.error('CK: render')
199
+ // Snapshot: packed dump starts with version=1 + familyCount>=3.
200
+ {
201
+ const probe2 = new Uint8Array(16)
202
+ const need = b.metricsSnapshot(handle, probe2)
203
+ if (need <= probe2.length) return false
204
+ const snap = new Uint8Array(need)
205
+ if (b.metricsSnapshot(handle, snap) !== need) return false
206
+ const sv = new DataView(snap.buffer)
207
+ if (sv.getUint32(0, true) !== 1) return false
208
+ if (sv.getUint32(4, true) < 3) return false
209
+ }
210
+
211
+ console.error('CK: snapshot')
212
+ // Batch: two entries in ONE crossing.
213
+ {
214
+ const c2 = b.metricsCounter(handle, 'ct_selftest_batch', 'k')
215
+ const buf = new Uint8Array(64)
216
+ const dv = new DataView(buf.buffer)
217
+ dv.setUint32(0, 1, true)
218
+ dv.setUint32(4, c2, true)
219
+ dv.setUint32(8, 1, true)
220
+ buf[12] = 'v'.charCodeAt(0)
221
+ dv.setFloat64(13, 4, true)
222
+ if (!b.metricsRecordBatch(handle, buf.subarray(0, 21))) return false
223
+ const probe3 = new Uint8Array(16)
224
+ const need3 = b.metricsRender(handle, probe3)
225
+ const out3 = new Uint8Array(need3)
226
+ b.metricsRender(handle, out3)
227
+ if (!decodeUtf8(out3.subarray(0, need3)).includes('ct_selftest_batch{k="v"} 4')) {
228
+ return false
229
+ }
230
+ }
231
+
232
+ console.error('CK: batch')
233
+ // A null handle must throw on declare (never dereference freed state).
234
+ try {
235
+ b.metricsCounter(0, 'ct_selftest_x', '')
236
+ return false
237
+ } catch {
238
+ // expected
239
+ }
240
+ return true
241
+ } catch (err) {
242
+ console.error('METRICS SELFTEST THROW:', err)
243
+ return false
244
+ } finally {
245
+ b.metricsDestroy(handle)
246
+ }
247
+ }
@@ -11,12 +11,29 @@ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
11
11
  import { EMPTY_VIEW, SELFTEST_HEX } from '../constants'
12
12
  import type { BunFFI, Raw3, Raw4, Raw6, Raw7, Raw10 } from '../types'
13
13
  import type { BuildCtx } from './util'
14
- import { flag, growExact } from './util'
14
+ import { allocOut, flag, growExact } from './util'
15
15
 
16
16
  /**
17
17
  * Build the parser/wire-format methods of the BunFFI surface. `ctx` is
18
18
  * destructured so the method bodies read exactly as the original `build()`.
19
19
  */
20
+
21
+ // ── SSE event/id encode memo ────────────────────────────────────────────────
22
+ // SSE streams repeat the same event NAME constantly ('message'/'update') and
23
+ // often repeat ids; re-encoding them per event is pure waste (~60ns each).
24
+ // One memo slot each: a `===` hit returns the cached bytes, a miss re-encodes.
25
+ // Per-Worker module state (single-threaded event loop), same safety model as
26
+ // the pooled scratches in BuildCtx.
27
+ const sseMemoEvent = { str: null as string | null, bytes: EMPTY_VIEW }
28
+ const sseMemoId = { str: null as string | null, bytes: EMPTY_VIEW }
29
+ function sseMemoEncode(slot: { str: string | null; bytes: Uint8Array }, s: string): Uint8Array {
30
+ if (s === slot.str) return slot.bytes
31
+ const bytes = encodeUtf8(s)
32
+ slot.str = s
33
+ slot.bytes = bytes
34
+ return bytes
35
+ }
36
+
20
37
  export function buildParse(
21
38
  sym: Record<string, (...a: unknown[]) => unknown>,
22
39
  ctx: BuildCtx,
@@ -67,8 +84,9 @@ export function buildParse(
67
84
  return w
68
85
  },
69
86
  wsFrameEncode(opcode, payload, mask, fin) {
70
- // Header (max 10) + payload + mask key (4).
71
- const out = new Uint8Array(payload.length + 14)
87
+ // Header (max 10) + payload + mask key (4). allocOut skips the zeroing
88
+ // (the native write fills [0,w) and only that slice escapes).
89
+ const out = allocOut(payload.length + 14)
72
90
  const w = Number(
73
91
  wsFrameEncode(
74
92
  opcode,
@@ -182,15 +200,24 @@ export function buildParse(
182
200
  parseMediaType(input) {
183
201
  // Packed verdict: [u32 mediaTypeLen][mediaType][u32 charsetLen
184
202
  // (0xFFFFFFFF = none)][charset][u32 boundaryLen][boundary]
185
- // [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}. 0 = invalid media
186
- // type (real error → growExact throws); w > output.length = exact needed
187
- // size (one exact retry).
188
- return growExact(
189
- (out) => Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out))),
190
- Math.min(input.length + 64, 64 * 1024),
191
- 1024 * 1024,
192
- 'media type parse: invalid media type or output buffer too small',
193
- )
203
+ // [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}.
204
+ // POOLED SCRATCH: the only consumer (the scalar `unpackMediaType`)
205
+ // decodes the verdict synchronously into a JS object before the next
206
+ // call, so the packed bytes never need to outlive this call — no
207
+ // growExact fresh-buffer per request. 0 = invalid media type (throw).
208
+ // 2x + 128 covers the worst-case packed expansion (every byte becomes a
209
+ // param key/value length prefix); a still-too-small write falls back to
210
+ // ONE exact-size buffer (needed-size convention).
211
+ let out = scratchFor(input.length * 2 + 128)
212
+ let w = Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out)))
213
+ if (w > out.length) {
214
+ out = new Uint8Array(w)
215
+ w = Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out)))
216
+ }
217
+ if (w === 0 || w > out.length) {
218
+ throw new Error('media type parse: invalid media type or output buffer too small')
219
+ }
220
+ return out.subarray(0, w)
194
221
  },
195
222
  parseHttpDate(input) {
196
223
  // Packed [u8 ok][i64 secs LE] (9 B) / 1 B (ok=0). A too-small buffer is
@@ -207,13 +234,18 @@ export function buildParse(
207
234
  },
208
235
  parseAcceptEncoding(input) {
209
236
  // Packed: [u32 count]{[u32 encLen][enc][f32 q][u32 order]} (empty header →
210
- // count 0, 4 bytes). 0 = too small (real error → growExact throws).
211
- return growExact(
212
- (out) => Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out))),
213
- Math.min(input.length + 64, 64 * 1024),
214
- 1024 * 1024,
215
- 'accept encoding parse: output buffer too small',
216
- )
237
+ // count 0, 4 bytes). POOLED SCRATCH: consumed synchronously by the scalar
238
+ // `unpackAcceptEncoding` — never escapes as raw bytes. 0 = too small.
239
+ let out = scratchFor(input.length * 2 + 128)
240
+ let w = Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out)))
241
+ if (w > out.length) {
242
+ out = new Uint8Array(w)
243
+ w = Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out)))
244
+ }
245
+ if (w === 0 || w > out.length) {
246
+ throw new Error('accept encoding parse: output buffer too small')
247
+ }
248
+ return out.subarray(0, w)
217
249
  },
218
250
  urlEncodeQuery(input) {
219
251
  // Packed pairs `[u32 count]{[u32 keyLen][key][u32 valLen][val]}` (the JS
@@ -255,8 +287,12 @@ export function buildParse(
255
287
  // non-null (flag bits 1/2/4 = present) so a present-but-empty string is
256
288
  // distinct from absent (napi Option parity). growExact with the needed-size
257
289
  // convention; `data.length + 64` covers the common single-line case.
258
- const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
259
- const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
290
+ // NOTE (2026-08-24): cstring-ARG variants were once blamed for JIT
291
+ // crashes here — docs/FFI_BUN_GUIDE.md §14 traced every report to
292
+ // dev-loop ABI desync, not bun:ffi. The memo captures the win without
293
+ // an ABI change anyway.
294
+ const ev = event === null ? EMPTY_VIEW : sseMemoEncode(sseMemoEvent, event)
295
+ const idv = id === null ? EMPTY_VIEW : sseMemoEncode(sseMemoId, id)
260
296
  const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
261
297
  return growExact(
262
298
  (out) =>
@@ -283,8 +319,8 @@ export function buildParse(
283
319
  // Pooled sibling — caller-owned output buffer (sized ≥ `data.length + 64`
284
320
  // for the common single-line case). Needed-size convention: w > output.length
285
321
  // = exact required size → throw; w === 0 = real error (invalid UTF-8).
286
- const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
287
- const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
322
+ const ev = event === null ? EMPTY_VIEW : sseMemoEncode(sseMemoEvent, event)
323
+ const idv = id === null ? EMPTY_VIEW : sseMemoEncode(sseMemoId, id)
288
324
  const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
289
325
  const w = Number(
290
326
  sseEncodeIntoRaw(
@@ -0,0 +1,79 @@
1
+ // src/native/ffi/build/task.ts — off-thread task runtime BunFFI methods.
2
+ //
3
+ // The thinnest possible binding: `rust/task/` owns the pool, the result ring,
4
+ // and the batched doorbell. This file only adapts the `(ptr,len)` ABI pair
5
+ // (probe-gated `buffer`/`buffer_length` vs explicit lengths) and converts the
6
+ // `u64_fast` byte counts to numbers.
7
+ //
8
+ // See docs/RND-CONCURRENCY.md for the design and the measured costs.
9
+
10
+ import type { BunFFI, Raw0, Raw2, Raw4, Raw6 } from '../types'
11
+ import type { BuildCtx } from './util'
12
+
13
+ /** Raw 1-arg C-ABI symbol signature (a lone scalar arg or a bare pointer). */
14
+ type Raw1 = (a: unknown) => number | bigint
15
+
16
+ /**
17
+ * Build the off-thread task-runtime methods of the BunFFI surface. `ctx`
18
+ * supplies the probe-gated length adapter (`lenOrView`).
19
+ */
20
+ export function buildTask(
21
+ sym: Record<string, (...a: unknown[]) => unknown>,
22
+ ctx: BuildCtx,
23
+ ): Partial<BunFFI> {
24
+ const { lenOrView } = ctx
25
+
26
+ const init = sym.castrum_task_init as Raw1
27
+ const submit = sym.castrum_task_submit as Raw4
28
+ const submitOut = sym.castrum_task_submit_out as Raw6
29
+ const submitSlice = sym.castrum_task_submit_slice as Raw6
30
+ const drain = sym.castrum_task_drain as Raw2
31
+ const pending = sym.castrum_task_pending as Raw0
32
+ const cancel = sym.castrum_task_cancel as Raw1
33
+ const setDoorbell = sym.castrum_task_set_doorbell as Raw1
34
+ const shutdown = sym.castrum_task_shutdown as Raw0
35
+ const threads = sym.castrum_task_threads as Raw0
36
+
37
+ return {
38
+ taskInit: (n) => Number(init(n)),
39
+ // `args` is copied onto the worker by Rust, so the caller's pooled buffer
40
+ // may be released as soon as this returns.
41
+ taskSubmit: (op, args, taskId) => Number(submit(op, args, lenOrView(args), taskId)),
42
+ taskSubmitOut: (op, args, taskId, output) =>
43
+ Number(submitOut(op, args, lenOrView(args), taskId, output, lenOrView(output))),
44
+ // Zero-copy INPUT: the header is copied by Rust, the payload is read in
45
+ // place — which is why the runtime must hold a reference to `data` for the
46
+ // whole task (see `Deferred.keep` in src/task/runtime.ts).
47
+ taskSubmitSlice: (op, hdr, data, taskId) =>
48
+ Number(submitSlice(op, hdr, lenOrView(hdr), data, lenOrView(data), taskId)),
49
+ taskDrain: (output) => Number(drain(output, lenOrView(output))),
50
+ taskPending: () => Number(pending()),
51
+ taskCancel: (taskId) => Number(cancel(taskId)),
52
+ taskSetDoorbell: (ptr) => Number(setDoorbell(ptr)),
53
+ taskShutdown: () => Number(shutdown()),
54
+ taskThreads: () => Number(threads()),
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Bind-time self-test for the task symbols. The pool is intentionally NOT
60
+ * started here (no threads at import time) and no completion is produced;
61
+ * the full submit→drain→doorbell path is exercised by `test/unit/task/`.
62
+ */
63
+ export function selfTestTask(b: BunFFI): boolean {
64
+ try {
65
+ if (typeof b.taskPending() !== 'number') return false
66
+ if (typeof b.taskThreads() !== 'number' || b.taskThreads() < 0) return false
67
+ // `0` disables the doorbell — safe at bind time (nothing is in flight).
68
+ if (b.taskSetDoorbell(0) !== 1) return false
69
+ // An empty ring → 0; exercises the drain symbol without allocating anything.
70
+ if (typeof b.taskDrain(new Uint8Array(4)) !== 'number') return false
71
+ // NOT called: submitting here would start the pool threads at bind time.
72
+ // The full submit/submit_out path is exercised by test/unit/task/.
73
+ if (typeof b.taskSubmitOut !== 'function') return false
74
+ if (typeof b.taskSubmitSlice !== 'function') return false
75
+ return true
76
+ } catch {
77
+ return false
78
+ }
79
+ }
@@ -42,6 +42,37 @@ export interface BuildCtx {
42
42
  /** Coerce an optional boolean to the C `u8` flag (1/0). */
43
43
  export const flag = (v?: boolean): number => (v ? 1 : 0)
44
44
 
45
+ /**
46
+ * Does this string contain a NUL (`U+0000`)?
47
+ *
48
+ * `bun:ffi` transcodes a JS string passed to a `cstring` ARG into a
49
+ * NUL-TERMINATED UTF-8 buffer, so an embedded NUL SILENTLY TRUNCATES the value
50
+ * on the native side (Bun's docs describe the transcode; the truncation is the
51
+ * C convention it inherits). That is fine when the string is developer-supplied
52
+ * config or NUL-free by construction — and it is a real bug when the callee
53
+ * answers with a VERDICT (validators, query/cookie gates, batch validators: a
54
+ * truncated prefix passes where the full input fails) or when the exact bytes
55
+ * are the result (`regexEscape`, metric label values).
56
+ *
57
+ * Guard contract for every `cstring` ARG reachable from user input: either
58
+ * reject/short-circuit on NUL, or route to the `(ptr, usize)` byte form which
59
+ * carries the exact length. `test/unit/native/cstring-nul.test.ts` pins this.
60
+ */
61
+ export { hasNul } from '../../../shared/bytes'
62
+
63
+ /**
64
+ * Output-buffer allocation for the FFI wrappers. The native write fills
65
+ * `[0, w)` and only `subarray(0, w)` escapes, so zero-initialization is pure
66
+ * waste: `Buffer.allocUnsafe` skips it (measured ~2x at 4 KB, ~3.4x at 16 KB
67
+ * on Bun 1.4; small sizes stay on the plain constructor where it is faster).
68
+ */
69
+ export function allocOut(n: number): Uint8Array {
70
+ if (n >= 512 && typeof Buffer !== 'undefined' && typeof Buffer.allocUnsafe === 'function') {
71
+ return Buffer.allocUnsafe(n)
72
+ }
73
+ return new Uint8Array(n)
74
+ }
75
+
45
76
  /**
46
77
  * Write with the C ABI's "needed" convention. The C side returns the EXACT
47
78
  * total bytes the result requires: `0` = real error (throw immediately — no
@@ -59,7 +90,7 @@ export function growExact(
59
90
  ): Uint8Array {
60
91
  let cap = Math.min(Math.max(initial, 16), max)
61
92
  for (;;) {
62
- const out = new Uint8Array(cap)
93
+ const out = allocOut(cap)
63
94
  const w = Number(write(out))
64
95
  if (w === 0) throw new Error(error)
65
96
  if (w <= out.length) return out.subarray(0, w)
@@ -14,7 +14,9 @@
14
14
  import { buildCodecs } from './build/codecs'
15
15
  import { buildCompress } from './build/compress'
16
16
  import { buildInstances } from './build/instances'
17
+ import { buildMetrics } from './build/metrics'
17
18
  import { buildParse } from './build/parse'
19
+ import { buildTask } from './build/task'
18
20
  import type { BuildCtx } from './build/util'
19
21
  import type { BunFFI } from './types'
20
22
 
@@ -90,5 +92,7 @@ export function build(
90
92
  ...buildCompress(sym, ctx),
91
93
  ...buildParse(sym, ctx),
92
94
  ...buildInstances(sym, ctx),
95
+ ...buildMetrics(sym, ctx),
96
+ ...buildTask(sym, ctx),
93
97
  } as BunFFI
94
98
  }
@@ -7,16 +7,25 @@
7
7
  // the safety net. New C-ABI symbols MUST be added to the self-test for their
8
8
  // domain: each check lives in the same `ffi/build/` file where the method is
9
9
  // bound (`selfTestCodecs` / `selfTestCompress` / `selfTestParse` /
10
- // `selfTestInstances`), and this entry point ANDs them together so any single
11
- // failure disables the whole layer.
10
+ // `selfTestInstances` / `selfTestMetrics`), and this entry point ANDs them
11
+ // together so any single failure disables the whole layer.
12
12
 
13
13
  import { selfTestCodecs } from './build/codecs'
14
14
  import { selfTestCompress } from './build/compress'
15
15
  import { selfTestInstances } from './build/instances'
16
+ import { selfTestMetrics } from './build/metrics'
16
17
  import { selfTestParse } from './build/parse'
18
+ import { selfTestTask } from './build/task'
17
19
  import type { BunFFI } from './types'
18
20
 
19
21
  /** Verify every bound function against known-good results; false disables ffi. */
20
22
  export function selfTest(b: BunFFI): boolean {
21
- return selfTestCodecs(b) && selfTestCompress(b) && selfTestParse(b) && selfTestInstances(b)
23
+ return (
24
+ selfTestCodecs(b) &&
25
+ selfTestCompress(b) &&
26
+ selfTestParse(b) &&
27
+ selfTestInstances(b) &&
28
+ selfTestMetrics(b) &&
29
+ selfTestTask(b)
30
+ )
22
31
  }