castrum 0.9.4 → 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 (58) hide show
  1. package/Cargo.lock +25 -42
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4432 -3641
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +154 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/ffi/mod.rs +1 -0
  27. package/rust/ffi/task.rs +172 -0
  28. package/rust/http/form.rs +6 -2
  29. package/rust/http/query_parser.rs +53 -22
  30. package/rust/ingress/native_route.rs +131 -140
  31. package/rust/ingress/pipeline.rs +3 -3
  32. package/rust/ingress/tests.rs +5 -4
  33. package/rust/json/json_ser.rs +13 -11
  34. package/rust/lib.rs +1 -0
  35. package/rust/task/completion.rs +119 -0
  36. package/rust/task/mod.rs +65 -0
  37. package/rust/task/ops.rs +503 -0
  38. package/rust/task/runtime.rs +220 -0
  39. package/rust/task/tests.rs +231 -0
  40. package/rust/util/bytes.rs +238 -51
  41. package/src/ingress/health.ts +1 -2
  42. package/src/native/ffi/build/codecs.ts +29 -7
  43. package/src/native/ffi/build/compress.ts +8 -0
  44. package/src/native/ffi/build/instances.ts +7 -2
  45. package/src/native/ffi/build/metrics.ts +23 -0
  46. package/src/native/ffi/build/task.ts +79 -0
  47. package/src/native/ffi/build/util.ts +18 -0
  48. package/src/native/ffi/build.ts +2 -0
  49. package/src/native/ffi/selftest.ts +3 -1
  50. package/src/native/ffi/types.ts +40 -0
  51. package/src/native/ffi.ts +73 -14
  52. package/src/native/loader.ts +53 -3
  53. package/src/rust-ffi/scalar/http.ts +54 -16
  54. package/src/rust-ffi/text.ts +25 -3
  55. package/src/shared/bytes.ts +19 -0
  56. package/src/task/index.ts +24 -0
  57. package/src/task/op.ts +171 -0
  58. package/src/task/runtime.ts +568 -0
@@ -16,6 +16,7 @@ import { buildCompress } from './build/compress'
16
16
  import { buildInstances } from './build/instances'
17
17
  import { buildMetrics } from './build/metrics'
18
18
  import { buildParse } from './build/parse'
19
+ import { buildTask } from './build/task'
19
20
  import type { BuildCtx } from './build/util'
20
21
  import type { BunFFI } from './types'
21
22
 
@@ -92,5 +93,6 @@ export function build(
92
93
  ...buildParse(sym, ctx),
93
94
  ...buildInstances(sym, ctx),
94
95
  ...buildMetrics(sym, ctx),
96
+ ...buildTask(sym, ctx),
95
97
  } as BunFFI
96
98
  }
@@ -15,6 +15,7 @@ import { selfTestCompress } from './build/compress'
15
15
  import { selfTestInstances } from './build/instances'
16
16
  import { selfTestMetrics } from './build/metrics'
17
17
  import { selfTestParse } from './build/parse'
18
+ import { selfTestTask } from './build/task'
18
19
  import type { BunFFI } from './types'
19
20
 
20
21
  /** Verify every bound function against known-good results; false disables ffi. */
@@ -24,6 +25,7 @@ export function selfTest(b: BunFFI): boolean {
24
25
  selfTestCompress(b) &&
25
26
  selfTestParse(b) &&
26
27
  selfTestInstances(b) &&
27
- selfTestMetrics(b)
28
+ selfTestMetrics(b) &&
29
+ selfTestTask(b)
28
30
  )
29
31
  }
@@ -218,6 +218,12 @@ export interface BunFFI {
218
218
  gzipDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
219
219
  /** gzip-decompress into `output`; returns bytes written (throws if too small). */
220
220
  gzipDecompressInto(data: Uint8Array, output: Uint8Array, maxDecompressed?: number): number
221
+ /**
222
+ * gzip ISIZE trailer probe → the uncompressed size for a single-member gzip
223
+ * stream, or `0` when unknown (invalid stream / multi-member). Used to
224
+ * PRE-SIZE a destination buffer for the zero-copy task path.
225
+ */
226
+ gzipIsize(data: Uint8Array): number
221
227
  /** brotli-compress into a fresh buffer (quality clamped 0..=11, default 5). */
222
228
  brotliCompress(data: Uint8Array, quality?: number): Uint8Array
223
229
  /** brotli-compress into `output`; returns bytes written (throws if too small). */
@@ -488,6 +494,40 @@ export interface BunFFI {
488
494
  * cstring return out (engine-cloned — zero JS decode). `null` = failure.
489
495
  */
490
496
  regexEscapeStr(input: string): string | null
497
+
498
+ // ── Off-thread task runtime (castrum_task_*) ──────────────────────
499
+ /** Start the shared native task pool; 0 = started, 1 = already running. */
500
+ taskInit(threads: number): number
501
+ /** Submit a task op with packed `args` under `taskId`; 1 = accepted. */
502
+ taskSubmit(op: number, args: Uint8Array, taskId: number): number
503
+ /**
504
+ * Zero-copy submit: the op writes its result DIRECTLY into `output` on the
505
+ * pool thread. `output` must be a stable, caller-owned buffer that stays
506
+ * alive (and untouched) until the completion is drained — the runtime holds
507
+ * the reference. The completion body is the 8-byte LE written length.
508
+ */
509
+ taskSubmitOut(op: number, args: Uint8Array, taskId: number, output: Uint8Array): number
510
+ /**
511
+ * Zero-copy INPUT: only `hdr` is copied; `data` is read in place by the pool
512
+ * thread, so the caller must keep it alive (and untouched) until completion.
513
+ */
514
+ taskSubmitSlice(op: number, hdr: Uint8Array, data: Uint8Array, taskId: number): number
515
+ /**
516
+ * Drain finished tasks into `output` (packed `[u32 count][entry…]`).
517
+ * Returns bytes written, `0` when nothing is pending, or the EXACT required
518
+ * size when `output` is too small (nothing is consumed in that case).
519
+ */
520
+ taskDrain(output: Uint8Array): number
521
+ /** Finished-but-undrained completion count. */
522
+ taskPending(): number
523
+ /** Mark a task cancelled; 1 = newly marked, 0 = unknown/already finished. */
524
+ taskCancel(taskId: number): number
525
+ /** Register the thread-safe doorbell trampoline address (`0` disables it). */
526
+ taskSetDoorbell(ptr: number): number
527
+ /** Stop the task pool and drop pending completions. */
528
+ taskShutdown(): number
529
+ /** Configured task-pool worker count (0 before the pool starts). */
530
+ taskThreads(): number
491
531
  }
492
532
 
493
533
  /**
package/src/native/ffi.ts CHANGED
@@ -34,7 +34,7 @@ import type { BunFFI, FfiMode } from './ffi/types'
34
34
  // Static import: loader.ts has no side effects (path resolution only — no
35
35
  // dlopen), so this adds zero import-time cost while letting `bind()` reuse the
36
36
  // exact same resolved `.node` path the napi fallback uses (the shared seam).
37
- import { getAddonPath } from './loader'
37
+ import { getAddonPath, getAddonPathCandidates } from './loader'
38
38
 
39
39
  // Re-export the type surface so `import type { BunFFI } from '../native/ffi'`
40
40
  // keeps working (existing call sites).
@@ -49,6 +49,12 @@ let cached: BunFFI | null | undefined
49
49
  */
50
50
  let bufferAbiMode: 'buffer-pair' | 'ptr-len' | null = null
51
51
 
52
+ /**
53
+ * The addon file the live `bun:ffi` binding was opened from, or `null` while
54
+ * unbound. Diagnostics only — a v3→baseline fallback is visible here.
55
+ */
56
+ let boundAddonPath: string | null = null
57
+
52
58
  // ── Transport selection (CASTRUM_FFI_MODE) ───────────────────────
53
59
 
54
60
  function resolveFfiMode(): FfiMode {
@@ -96,6 +102,20 @@ export function ffiBufferMode(): 'buffer-pair' | 'ptr-len' | null {
96
102
  return bufferAbiMode
97
103
  }
98
104
 
105
+ /**
106
+ * The addon file path the live `bun:ffi` binding was opened from, or `null`
107
+ * when the transport is unavailable (Node, `CASTRUM_FFI_MODE=napi`, or every
108
+ * candidate failed). Lazy — triggers the same one-time bind as
109
+ * {@link getBunFFI}. Use it to confirm WHICH artifact bound, e.g. when a stale
110
+ * v3 SIMD binary forced the baseline fallback.
111
+ */
112
+ export function ffiAddonPath(): string | null {
113
+ if (cached === undefined) {
114
+ getBunFFI()
115
+ }
116
+ return boundAddonPath
117
+ }
118
+
99
119
  /**
100
120
  * Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
101
121
  * `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
@@ -127,7 +147,7 @@ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: str
127
147
  }
128
148
  }
129
149
 
130
- function bind(): BunFFI | null {
150
+ function bind(forcedPath?: string, tried: string[] = []): BunFFI | null {
131
151
  bufferAbiMode = null // reset — the mode below is only valid for a live bind
132
152
  const mode = resolveFfiMode()
133
153
  if (!isBun()) {
@@ -143,12 +163,13 @@ function bind(): BunFFI | null {
143
163
  if (mode === 'napi') {
144
164
  return null
145
165
  }
166
+ // Resolve the SAME addon file napi uses (./loader.ts — statically imported
167
+ // above; it only resolves the path, it never dlopens the addon). On a retry,
168
+ // `forcedPath` is the next candidate from `getAddonPathCandidates()`.
169
+ const path = forcedPath ?? getAddonPath()
146
170
  try {
147
171
  // eslint-disable-next-line @typescript-eslint/no-require-imports
148
172
  const { dlopen } = require('bun:ffi') as typeof import('bun:ffi')
149
- // Resolve the SAME addon file napi uses (./loader.ts — statically imported
150
- // above; it only resolves the path, it never dlopens the addon).
151
- const path = getAddonPath()
152
173
 
153
174
  // Probe `buffer`/`buffer_length` (the engine reads ptr + len off the SAME
154
175
  // TypedArray at call time — an atomic snapshot) once; when supported we use
@@ -209,6 +230,14 @@ function bind(): BunFFI | null {
209
230
  // The `_bytes` siblings take a `(ptr,len)` pair — zero transcode when
210
231
  // the caller already holds bytes (a cstring arg would force a decode +
211
232
  // engine re-encode; docs/FFI_BUN_GUIDE.md §3 shape 4).
233
+ //
234
+ // SAFETY RULE for every `cstring` ARG added here: the transcode is
235
+ // NUL-TERMINATED, so an embedded U+0000 truncates the value in-engine. A
236
+ // verdict op bound this way can be fooled (`validateEmail` on
237
+ // 'a@b.com\0<script>' returned TRUE before the byte re-route). Either
238
+ // give the op a `_bytes` sibling and route byte callers to it, or guard
239
+ // the string form with `hasNul` — see docs/FFI_BUN_GUIDE.md §6.1,
240
+ // pinned by test/unit/native/cstring-nul.test.ts.
212
241
  castrum_validate_email: { args: ['cstring'], returns: 'u8' },
213
242
  castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
214
243
  castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
@@ -547,6 +576,27 @@ function bind(): BunFFI | null {
547
576
  args: abi(['cstring', 'cstring', 'ptr', 'usize']),
548
577
  returns: U64_FAST,
549
578
  },
579
+ // Off-thread task runtime: `submit` takes a packed `(ptr,len)` arg blob
580
+ // plus a JS-assigned id; `drain` writes the packed completion batch into
581
+ // a caller buffer (needed-size convention). `set_doorbell` takes the raw
582
+ // `JSCallback({threadsafe:true}).ptr` as a bare pointer (`abi` leaves a
583
+ // lone `ptr` untouched), so the pool can wake JS once per batch.
584
+ castrum_task_init: { args: ['u32'], returns: 'u32' },
585
+ castrum_task_submit: { args: abi(['u32', 'ptr', 'usize', 'usize']), returns: 'u32' },
586
+ castrum_task_submit_slice: {
587
+ args: abi(['u32', 'ptr', 'usize', 'ptr', 'usize', 'usize']),
588
+ returns: 'u32',
589
+ },
590
+ castrum_task_submit_out: {
591
+ args: abi(['u32', 'ptr', 'usize', 'usize', 'ptr', 'usize']),
592
+ returns: 'u32',
593
+ },
594
+ castrum_task_drain: { args: abi(['ptr', 'usize']), returns: U64_FAST },
595
+ castrum_task_pending: { args: [], returns: 'u32' },
596
+ castrum_task_cancel: { args: ['usize'], returns: 'u32' },
597
+ castrum_task_set_doorbell: { args: ['ptr'], returns: 'u32' },
598
+ castrum_task_shutdown: { args: [], returns: 'u32' },
599
+ castrum_task_threads: { args: [], returns: 'u32' },
550
600
  castrum_metrics_destroy: { args: ['usize'], returns: 'void' },
551
601
  })
552
602
 
@@ -557,20 +607,29 @@ function bind(): BunFFI | null {
557
607
  useBufferLength,
558
608
  )
559
609
  if (!selfTest(bindings)) {
560
- if (mode === 'ffi') {
561
- throw new Error(
562
- 'CASTRUM_FFI_MODE=ffi: the bun:ffi bind-time self-test failed, so the ' +
563
- 'primary transport cannot be trusted on this Bun/addon combination. ' +
564
- 'Unset CASTRUM_FFI_MODE (or use CASTRUM_FFI_MODE=auto) to fall back to napi.',
565
- )
566
- }
567
- return null
610
+ throw new Error(`bun:ffi bind-time self-test failed on ${path}`)
568
611
  }
612
+ boundAddonPath = path
569
613
  return bindings
570
614
  } catch (err) {
615
+ bufferAbiMode = null
616
+ // The preferred artifact failed to dlopen/bind/self-test. The common cause
617
+ // is a STALE v3 SIMD binary (`castrum.linux-x64-v3-gnu.node`) that predates
618
+ // a newly added `castrum_*` symbol: dlopen fails, and without this walk the
619
+ // whole FFI transport would be disabled (every call silently downgraded to
620
+ // napi, and the task runtime silently made synchronous). Try the remaining
621
+ // existing candidates — the baseline binary — before giving up.
622
+ for (const next of getAddonPathCandidates()) {
623
+ if (next === path || tried.includes(next)) continue
624
+ const bound = bind(next, [...tried, path])
625
+ if (bound) return bound
626
+ }
571
627
  if (mode === 'ffi') {
572
628
  const cause = err instanceof Error ? `: ${err.message}` : ''
573
- throw new Error(`CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause}`)
629
+ throw new Error(
630
+ `CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause} ` +
631
+ `(tried: ${[...tried, path].join(', ')})`,
632
+ )
574
633
  }
575
634
  return null
576
635
  }
@@ -67,15 +67,16 @@ export function supportsX8664V3(cpuinfo?: string): boolean {
67
67
  * @param arch `process.arch`
68
68
  * @param cpuinfo optional `/proc/cpuinfo` text (tests inject it; the real call
69
69
  * omits it and reads the file lazily on linux/x64 only)
70
- * @returns the first existing candidate path (throws if none exists)
70
+ * @returns every candidate path, in preference order (v3 SIMD first on a
71
+ * capable linux/x64 host, then the baseline names)
71
72
  */
72
- export function resolveAddonPathFrom(
73
+ export function addonCandidatesFrom(
73
74
  envOverride: string | undefined,
74
75
  baseDir: string,
75
76
  platform: string,
76
77
  arch: string,
77
78
  cpuinfo?: string,
78
- ): string {
79
+ ): string[] {
79
80
  // napi-rs artifact naming: castrum.<platform>-<arch>[-<libc>].node
80
81
  // e.g. linux-x64-gnu, linux-x64-musl, win32-x64-msvc, darwin-arm64.
81
82
  const libcVariants =
@@ -156,6 +157,22 @@ export function resolveAddonPathFrom(
156
157
  candidates.push(join(baseDir, '..', '..', 'target', 'release', name))
157
158
  }
158
159
 
160
+ return candidates
161
+ }
162
+
163
+ /**
164
+ * Resolve the addon path: the FIRST existing candidate from
165
+ * {@link addonCandidatesFrom} (v3 SIMD first on a capable linux/x64 host).
166
+ * Throws when nothing exists.
167
+ */
168
+ export function resolveAddonPathFrom(
169
+ envOverride: string | undefined,
170
+ baseDir: string,
171
+ platform: string,
172
+ arch: string,
173
+ cpuinfo?: string,
174
+ ): string {
175
+ const candidates = addonCandidatesFrom(envOverride, baseDir, platform, arch, cpuinfo)
159
176
  for (const candidate of candidates) {
160
177
  if (existsSync(candidate)) {
161
178
  return candidate
@@ -169,6 +186,39 @@ export function resolveAddonPathFrom(
169
186
  )
170
187
  }
171
188
 
189
+ /** Cached result of {@link getAddonPathCandidates} (existing files only). */
190
+ let cachedCandidates: string[] | undefined
191
+
192
+ /**
193
+ * Every EXISTING addon candidate, in preference order (v3 SIMD first on a
194
+ * capable linux/x64 host, then the baseline names).
195
+ *
196
+ * The bun:ffi binder walks this list so a stale or partial *preferred* artifact
197
+ * — e.g. a `castrum.linux-x64-v3-gnu.node` built before a newly added
198
+ * `castrum_*` symbol — falls back to the baseline binary instead of silently
199
+ * disabling the whole FFI transport (which would downgrade every native call to
200
+ * napi and make the off-thread task runtime run synchronously).
201
+ */
202
+ export function getAddonPathCandidates(): string[] {
203
+ if (cachedCandidates) return cachedCandidates
204
+ const all = addonCandidatesFrom(
205
+ resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
206
+ dirname(fileURLToPath(import.meta.url)),
207
+ process.platform,
208
+ process.arch,
209
+ )
210
+ const seen = new Set<string>()
211
+ const existing: string[] = []
212
+ for (const candidate of all) {
213
+ if (!seen.has(candidate) && existsSync(candidate)) {
214
+ seen.add(candidate)
215
+ existing.push(candidate)
216
+ }
217
+ }
218
+ cachedCandidates = existing
219
+ return existing
220
+ }
221
+
172
222
  function resolveAddonPath(): string {
173
223
  return resolveAddonPathFrom(
174
224
  resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
@@ -11,12 +11,19 @@
11
11
 
12
12
  import type { EncodingPrefResult, MediaTypeResult } from '../../native'
13
13
  import type { BunFFI } from '../../native/ffi'
14
- import { decoder } from '../../shared/bytes'
14
+ import { decoder, hasNul } from '../../shared/bytes'
15
15
  import { decodeUtf8Fatal, decodeUtf8Range } from '../../shared/codec'
16
16
  import { packPairs } from '../../shared/packed'
17
17
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
18
18
  import { writeInto } from '../into'
19
19
 
20
+ /**
21
+ * RFC 6455 `Sec-WebSocket-Accept` is always 28 chars. Reused across calls
22
+ * because the value is decoded to a string synchronously before returning —
23
+ * the same discipline as the other pooled scratches in this layer.
24
+ */
25
+ const WS_ACCEPT_SCRATCH = new Uint8Array(64)
26
+
20
27
  // Mirror napi `url_decode`'s UTF-8 validation (rust/http/url_codec.rs): only
21
28
  // validate when a high byte is present (the ASCII fast path skips it — same as
22
29
  // Rust's saw_high check). On the Bun path the check is a native C-ABI probe
@@ -144,11 +151,22 @@ export function buildHttp(ctx: RustClientContext) {
144
151
 
145
152
  return {
146
153
  mimeFromExtension(ext: Uint8Array): Uint8Array | string {
147
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
148
- // (cheap CString clone) before the call. napi keeps the memoized-bytes
149
- // path (cachedMime).
154
+ // FFI-first. This op has no `*_bytes` sibling, so the bytes must be decoded
155
+ // for the `cstring` ARG — the decode is the price of the cstring form, not
156
+ // a speed trick (route new byte-shaped ops to a byte sibling instead; see
157
+ // docs/FFI_BUN_GUIDE.md §6.1). napi keeps the memoized-bytes path
158
+ // (cachedMime).
159
+ //
160
+ // A NUL would truncate the DECODED string native-side, so `.js\0evil`
161
+ // would resolve as `.js` — a Content-Type from a prefix. An extension
162
+ // containing a NUL is not an extension, so it takes the unknown sentinel
163
+ // (the guard is `indexOf` on an already-decoded string: ~4 ns).
150
164
  const f = ffi()
151
- if (f) return f.mimeFromExtension(decoder.decode(ext)) ?? 'application/octet-stream'
165
+ if (f) {
166
+ const name = decoder.decode(ext)
167
+ if (hasNul(name)) return 'application/octet-stream'
168
+ return f.mimeFromExtension(name) ?? 'application/octet-stream'
169
+ }
152
170
  return ctx.cachedMime(ext)
153
171
  },
154
172
  urlEncode(input: Uint8Array): Uint8Array | string {
@@ -188,34 +206,54 @@ export function buildHttp(ctx: RustClientContext) {
188
206
  return n('urlDecodeInto')(input, output) as number
189
207
  },
190
208
  validateEmail(input: Uint8Array): boolean {
191
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
192
- // (cheap CString clone) before the call — still faster than the old
193
- // `(ptr,len)` view path (see bench:margin cstring scenario). napi
194
- // fallback keeps the byte path.
209
+ // FFI-first. The bytes cross as a `buffer`/`buffer_length` pair (an atomic
210
+ // ptr+len snapshot, zero transcode) rather than being decoded to a JS
211
+ // string for the `cstring` ARG. Measured on Bun 1.4.2 over 400k calls:
212
+ // 101 ns vs 234 ns (email), 65 vs 164 (uuid), 31 vs 138 (ipv4) — and the
213
+ // byte form cannot truncate at an embedded NUL the way a cstring ARG
214
+ // does (a NUL byte used to make "a@b.com\0<script>" validate as TRUE).
195
215
  const f = ffi()
196
- if (f) return f.validateEmail(decoder.decode(input))
216
+ if (f) return f.validateEmailBytes(input)
197
217
  return n('validateEmail')(input) as boolean
198
218
  },
199
219
  validateUuid(input: Uint8Array): boolean {
200
220
  const f = ffi()
201
- if (f) return f.validateUuid(decoder.decode(input))
221
+ if (f) return f.validateUuidBytes(input)
202
222
  return n('validateUuid')(input) as boolean
203
223
  },
204
224
  validateIpv4(input: Uint8Array): boolean {
205
225
  const f = ffi()
206
- if (f) return f.validateIpv4(decoder.decode(input))
226
+ if (f) return f.validateIpv4Bytes(input)
207
227
  return n('validateIpv4')(input) as boolean
208
228
  },
209
229
  validateIpv6(input: Uint8Array): boolean {
210
230
  const f = ffi()
211
- if (f) return f.validateIpv6(decoder.decode(input))
231
+ if (f) return f.validateIpv6Bytes(input)
212
232
  return n('validateIpv6')(input) as boolean
213
233
  },
214
234
  wsAcceptKey(key: Uint8Array): Uint8Array | string {
215
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
216
- // (cheap CString clone) before the call. napi fallback keeps the bytes.
235
+ // FFI-first: the `cstring` ARG + cstring return is the fast path (no JS
236
+ // encode, engine-cloned result) — end-to-end it measures ~367 ns, while
237
+ // routing every call through the byte sibling costs ~381 ns (the extra
238
+ // `subarray` + decode outweighs the transcode saved; measured over 300k
239
+ // calls on Bun 1.4.2 through THIS function, not the raw binding).
240
+ //
241
+ // Correctness rider: a NUL would truncate the `cstring` ARG, so the accept
242
+ // value would be computed for a PREFIX of the key — verified:
243
+ // `'dGhlIHNhbXBsZSBub25jZQ==\0junk'` returned exactly the value for the
244
+ // key WITHOUT the suffix. That case takes the byte sibling (exact length).
217
245
  const f = ffi()
218
- if (f) return f.wsAcceptKey(decoder.decode(key))
246
+ if (f) {
247
+ const text = decoder.decode(key)
248
+ if (!hasNul(text)) return f.wsAcceptKey(text)
249
+ const w = f.wsAcceptKeyInto(key, WS_ACCEPT_SCRATCH)
250
+ if (w > 0 && w <= WS_ACCEPT_SCRATCH.length) {
251
+ return decoder.decode(WS_ACCEPT_SCRATCH.subarray(0, w))
252
+ }
253
+ // Unreachable for a live view (the native side writes 28 for any key);
254
+ // the napi path is byte-exact, so it can never resurrect truncation.
255
+ return n('wsAcceptKey')(key) as Uint8Array | string
256
+ }
219
257
  return n('wsAcceptKey')(key) as Uint8Array | string
220
258
  },
221
259
  wsAcceptKeyInto(key: Uint8Array, output: Uint8Array): number {
@@ -1,16 +1,27 @@
1
1
  // src/rust-ffi/text.ts — String-oriented FFI namespace (text in → text out).
2
2
  //
3
3
  // `rust.text.*` is the ergonomic string API over the byte-level native
4
- // functions. Pure wrappers: encode input, call native, decode output.
4
+ // functions. MOST of it is FFI-first with `cstring` args/returns: the engine
5
+ // transcodes a JS string in-call, so there is no JS-side encode at all (and
6
+ // byte-arg ops are not involved). Encode/decode wrappers exist only for the
7
+ // napi fallback and for ops with no cstring form — plus a NUL guard wherever a
8
+ // `cstring` ARG could truncate a verdict or a byte-exact value
9
+ // (docs/FFI_BUN_GUIDE.md §6.1).
5
10
  //
6
11
  // Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
7
12
  // built-in delegations (urlEncode/urlDecode) come from `builtins.has(op)` and
8
13
  // the native call from `transport.ffi` / `transport.resolve` (bun:ffi first,
9
14
  // napi fallback) — no inline `isBun()` / `getBunFFI()`.
10
15
 
11
- import { decoder, encoder } from '../shared/bytes'
16
+ import { decoder, encoder, hasNul } from '../shared/bytes'
12
17
  import { type RustClientContext, resolveNative } from './context'
13
18
 
19
+ /**
20
+ * Scratch for the byte-path `Sec-WebSocket-Accept` fallback (28 chars; the
21
+ * NUL case only). Decoded synchronously before returning, so reuse is safe.
22
+ */
23
+ const WS_ACCEPT_OUT = new Uint8Array(64)
24
+
14
25
  /** String-oriented FFI namespace. */
15
26
  export interface RustText {
16
27
  /**
@@ -91,8 +102,19 @@ export function buildText(ctx: RustClientContext): RustText {
91
102
  // FFI-first: `cstring` ARG + cstring return (native transfer both ways —
92
103
  // the engine transcodes the key in-engine, no JS encode). napi keeps the
93
104
  // encode/decode path.
105
+ //
106
+ // Exception: a NUL would truncate the `cstring` ARG, so the accept value
107
+ // would be computed for a PREFIX of the key. Encode and take the byte path
108
+ // in that (practically unreachable) case: still a wrong key for a client
109
+ // that sent a NUL, but wrong for the bytes the caller actually passed.
94
110
  const f = transport.ffi
95
- if (f) return f.wsAcceptKey(key)
111
+ if (f) {
112
+ if (!hasNul(key)) return f.wsAcceptKey(key)
113
+ const w = f.wsAcceptKeyInto(encoder.encode(key), WS_ACCEPT_OUT)
114
+ return w > 0 && w <= WS_ACCEPT_OUT.length
115
+ ? decoder.decode(WS_ACCEPT_OUT.subarray(0, w))
116
+ : ''
117
+ }
96
118
  return decoder.decode(resolveNative(ctx, 'wsAcceptKey')(encoder.encode(key)) as Uint8Array)
97
119
  },
98
120
  validateEmail(input) {
@@ -134,3 +134,22 @@ export function toPlainBuffer(bytes: Uint8Array): Uint8Array {
134
134
  copy.set(bytes)
135
135
  return copy
136
136
  }
137
+
138
+ /**
139
+ * Does this string contain a NUL (`U+0000`)?
140
+ *
141
+ * `bun:ffi` transcodes a JS string passed to a `cstring` ARG into a
142
+ * NUL-TERMINATED UTF-8 buffer, so an embedded NUL SILENTLY TRUNCATES the value
143
+ * natively (Bun's docs describe the transcode; the truncation is the C
144
+ * convention it inherits). That is fine for developer-supplied config, and a
145
+ * real bug wherever the callee answers with a VERDICT (a truncated prefix
146
+ * passes where the full input fails) or where the exact bytes ARE the result.
147
+ *
148
+ * Cheap: one `indexOf` scan, ~4 ns for typical inputs.
149
+ *
150
+ * @param s - The string to check.
151
+ * @returns `true` when a NUL is present.
152
+ */
153
+ export function hasNul(s: string): boolean {
154
+ return s.indexOf('\u0000') !== -1
155
+ }
@@ -0,0 +1,24 @@
1
+ // src/task/index.ts — public barrel for the off-thread task runtime.
2
+
3
+ export {
4
+ TASK_OP,
5
+ TASK_STATUS_CANCELLED,
6
+ TASK_STATUS_ERROR,
7
+ TASK_STATUS_OK,
8
+ brotliCapacityGuess,
9
+ encodeArgon2VerifyArgs,
10
+ encodeBrotliDecompressArgs,
11
+ encodeGzipCompressHeader,
12
+ encodeGzipDecompressArgs,
13
+ encodePbkdf2Args,
14
+ gzipCompressUpperBound,
15
+ } from './op'
16
+ export type { TaskOpName } from './op'
17
+ export { createTaskRuntime } from './runtime'
18
+ export type {
19
+ Pbkdf2RunOptions,
20
+ TaskRunOptions,
21
+ TaskRuntime,
22
+ TaskRuntimeOptions,
23
+ TaskStats,
24
+ } from './runtime'