@ff-labs/fff-bun 0.9.7-nightly.fce72fa → 0.10.0

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.
package/README.md CHANGED
@@ -95,6 +95,56 @@ finder.destroy();
95
95
  ```
96
96
 
97
97
 
98
+ ## Watching files
99
+
100
+ Subscribe to filesystem changes with a glob, an exact path, or a directory
101
+ subtree. Events reflect applied index changes and are delivered in batches of
102
+ up to 128, so callbacks stay cheap even under heavy churn.
103
+
104
+ ```typescript
105
+ // Each path appears at most once per batch
106
+ const sub = finder.watch("src/**/*.ts", (events) => {
107
+ for (const e of events) console.log(e.kind, e.path); // created | modified | removed | rescan
108
+ });
109
+
110
+ // No pattern: watch the entire indexed tree
111
+ const all = finder.watch((events) => {
112
+ for (const e of events) console.log(e.kind, e.path);
113
+ });
114
+
115
+ // Unsubscribe: call the handle
116
+ if (sub.ok) sub.value();
117
+ ```
118
+
119
+ Directory subtrees are watched by passing the directory itself (parcel-watcher
120
+ style), with per-subscription excludes:
121
+
122
+ ```typescript
123
+ const dirSub = finder.watch(
124
+ projectRoot,
125
+ (events) => {
126
+ for (const e of events) console.log(e.kind, e.path);
127
+ },
128
+ { ignore: ["node_modules", "*.log"] },
129
+ );
130
+ ```
131
+
132
+ Notes:
133
+
134
+ - Globs are matched against the base-path-relative path; absolute globs must
135
+ live under `basePath`. Wildcard-free patterns resolve inside the indexed
136
+ tree: an existing directory watches its whole subtree, anything else is an
137
+ exact file path.
138
+ - `ignore` entries exclude matches per subscription: wildcards are globs,
139
+ everything else is a path prefix (a file or a whole subtree).
140
+ - Gitignored paths never produce events.
141
+ - A `rescan` event means changes were lost (index overflow, ignore-file
142
+ change) — re-stat anything you care about.
143
+ - Unsubscribing takes effect synchronously on the JS thread: once it
144
+ returns, the callback will not be invoked again.
145
+ - Watching requires the instance to be created with watching enabled
146
+ (the default).
147
+
98
148
  ## API Reference
99
149
 
100
150
  Verify the latest API in the local interface at [`./src/fff-api.ts`](./src/fff-api.ts). Every field and type is documented.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bun
2
+ import { FileFinder } from "../src/index";
3
+ import type { WatchEvent } from "../src/index";
4
+
5
+ const KIND = {
6
+ created: "+ created ",
7
+ modified: "~ modified",
8
+ removed: "- removed ",
9
+ rescan: "! rescan ",
10
+ } as const;
11
+
12
+ const targetDir = process.argv[2] || process.cwd();
13
+ const pattern = process.argv[3]; // if omitted watch the entire indexed tree
14
+
15
+ const created = FileFinder.create({ basePath: targetDir });
16
+ if (!created.ok) {
17
+ console.error(`Init failed: ${created.error}`);
18
+ process.exit(1);
19
+ }
20
+ const finder = created.value;
21
+
22
+ // Wait for the initial scan + watcher so indexing noise isn't reported.
23
+ await finder.waitForScan(30_000);
24
+ for (
25
+ let p = finder.getScanProgress();
26
+ !p.ok || !p.value.isWatcherReady;
27
+ p = finder.getScanProgress()
28
+ ) {
29
+ await Bun.sleep(50);
30
+ }
31
+
32
+ let batch = 0;
33
+ const onBatch = (events: WatchEvent[]) => {
34
+ console.log(`\nbatch #${++batch} (${events.length} events)`);
35
+ for (const e of events) console.log(` ${KIND[e.kind]} ${e.path}`);
36
+ };
37
+
38
+ const sub = pattern ? finder.watch(pattern, onBatch) : finder.watch(onBatch);
39
+ if (!sub.ok) {
40
+ console.error(`Watch failed: ${sub.error}`);
41
+ finder.destroy();
42
+ process.exit(1);
43
+ }
44
+
45
+ console.log(
46
+ `Watching ${targetDir} (pattern: ${pattern ?? "whole tree"}), Ctrl-C to stop.`,
47
+ );
48
+
49
+ // A recurring timer keeps the event loop alive so watch batches are delivered.
50
+ const keepAlive = setInterval(() => {}, 1000);
51
+
52
+ process.on("SIGINT", () => {
53
+ clearInterval(keepAlive);
54
+ sub.value();
55
+ finder.destroy();
56
+ process.exit(0);
57
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ff-labs/fff-bun",
3
- "version": "0.9.7-nightly.fce72fa",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "High-performance fuzzy file finder for Bun - perfect for LLM agent tools",
6
6
  "type": "module",
@@ -58,14 +58,14 @@
58
58
  },
59
59
  "homepage": "https://github.com/dmtrKovalenko/fff#readme",
60
60
  "optionalDependencies": {
61
- "@ff-labs/fff-bin-darwin-arm64": "0.9.7-nightly.fce72fa",
62
- "@ff-labs/fff-bin-darwin-x64": "0.9.7-nightly.fce72fa",
63
- "@ff-labs/fff-bin-linux-x64-gnu": "0.9.7-nightly.fce72fa",
64
- "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.7-nightly.fce72fa",
65
- "@ff-labs/fff-bin-linux-x64-musl": "0.9.7-nightly.fce72fa",
66
- "@ff-labs/fff-bin-linux-arm64-musl": "0.9.7-nightly.fce72fa",
67
- "@ff-labs/fff-bin-win32-x64": "0.9.7-nightly.fce72fa",
68
- "@ff-labs/fff-bin-win32-arm64": "0.9.7-nightly.fce72fa"
61
+ "@ff-labs/fff-bin-darwin-arm64": "0.10.0",
62
+ "@ff-labs/fff-bin-darwin-x64": "0.10.0",
63
+ "@ff-labs/fff-bin-linux-x64-gnu": "0.10.0",
64
+ "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.0",
65
+ "@ff-labs/fff-bin-linux-x64-musl": "0.10.0",
66
+ "@ff-labs/fff-bin-linux-arm64-musl": "0.10.0",
67
+ "@ff-labs/fff-bin-win32-x64": "0.10.0",
68
+ "@ff-labs/fff-bin-win32-arm64": "0.10.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@types/bun": "^1.3.8",
package/src/fff-api.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // ----------------------------------------------------------------------------
2
2
  // GENERATED FILE - DO NOT EDIT.
3
- // Source of truth: packages/shared/fff-api.ts
3
+ // Copied from: packages/shared/fff-api.ts
4
4
  // Run make sync-js-api from the repo root to regenerate.
5
5
  // ----------------------------------------------------------------------------
6
6
 
@@ -49,23 +49,17 @@ export interface InitOptions {
49
49
  frecencyDbPath?: string;
50
50
  /** Path to query history database (optional, omit to skip query tracker initialization) */
51
51
  historyDbPath?: string;
52
- /**
53
- * @deprecated No-op. The no-lock LMDB flags showed no measurable win under
54
- * realistic contention and are now ignored. Kept for source-compat.
55
- */
52
+ /** @deprecated no-op */
56
53
  useUnsafeNoLock?: boolean;
57
54
  /**
58
55
  * Disable mmap cache warmup after the initial scan. When mmap cache is
59
56
  * enabled (the default), the first grep search is as fast as subsequent
60
57
  * ones at the cost of a longer scan time and higher initial memory usage.
61
- * (default: false)
62
58
  */
63
59
  disableMmapCache?: boolean;
64
60
  /**
65
61
  * Disable the content index built after the initial scan.
66
62
  * Content indexing enables faster content-aware filtering during grep.
67
- * When omitted, follows `disableMmapCache` for backward compatibility.
68
- * (default: follows `disableMmapCache`)
69
63
  */
70
64
  disableContentIndexing?: boolean;
71
65
  /**
@@ -97,10 +91,10 @@ export interface InitOptions {
97
91
  /** Override for the per-file byte cap in the content cache. */
98
92
  cacheBudgetMaxFileSize?: number;
99
93
  /**
100
- * Allow indexing the filesystem root (`/`). Off by default, having fff instance at the large folder
101
- * will generally require file watcher
102
- * */
103
-
94
+ * Allow indexing the filesystem root (`/`).
95
+ * Off by default, having fff instance at the large folder will generally require
96
+ * file watcher and indexing which will consume a lot of resources if performed uncontrolled
97
+ **/
104
98
  enableFsRootScanning?: boolean;
105
99
  /** Allow indexing the user's home directory. Same trade-off as `enableFsRootScanning`. */
106
100
  enableHomeDirScanning?: boolean;
@@ -300,8 +294,36 @@ export interface ScanProgress {
300
294
  }
301
295
 
302
296
  /**
303
- * Database health information
297
+ * Normalized watch event kind.
298
+ * A file removed and recreated in one processed batch is marked as modified.
299
+ *
300
+ * rescan = internal OS buffers were overloaded, some events might be missing.
301
+ * The `path` is going to be a folder needs to be rescanned
302
+ */
303
+ export type WatchEventKind = "created" | "modified" | "removed" | "rescan";
304
+
305
+ /** A single filesystem change notification. */
306
+ export interface WatchEvent {
307
+ /** Absolute path of the affected file (base path to rescan if `kind ==rescan`) */
308
+ path: string;
309
+ kind: WatchEventKind;
310
+ }
311
+
312
+ /** Options for watch subscriptions. */
313
+ export interface WatchOptions {
314
+ /** Additional glob wildcard patterns to ignore */
315
+ ignore?: string[];
316
+ }
317
+
318
+ /**
319
+ * Receives normalized batches of up to 128 events. Each path appears once.
304
320
  */
321
+ export type WatchBatchCallback = (events: WatchEvent[]) => void;
322
+
323
+ /** Call me to unsubscribe. */
324
+ export type WatchUnsubscribe = () => void;
325
+
326
+ /** Database health information */
305
327
  export interface DbHealth {
306
328
  /** Path to the database */
307
329
  path: string;
@@ -551,10 +573,16 @@ export interface FileFinderApi {
551
573
  glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
552
574
 
553
575
  /** Fuzzy directory search. */
554
- directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
576
+ directorySearch(
577
+ query: string,
578
+ options?: DirSearchOptions,
579
+ ): Result<DirSearchResult>;
555
580
 
556
581
  /** Fuzzy search over files and directories interleaved by score. */
557
- mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
582
+ mixedSearch(
583
+ query: string,
584
+ options?: SearchOptions,
585
+ ): Result<MixedSearchResult>;
558
586
 
559
587
  /** Content search (live grep). */
560
588
  grep(query: string, options?: GrepOptions): Result<GrepResult>;
@@ -610,6 +638,25 @@ export interface FileFinderApi {
610
638
  /** Get a historical query by offset (0 = most recent). */
611
639
  getHistoricalQuery(offset: number): Result<string | null>;
612
640
 
641
+ /**
642
+ * Subscribe to filesystem changes matching `pattern`.
643
+ *
644
+ * Patterns may be base-relative globs (./ works), exact paths inside the indexed
645
+ * tree, or existing directories. An empty pattern watches the whole tree.
646
+ *
647
+ * Events are debounced and submitted in batches per 100-ms window at most 128 events.
648
+ * Gitignored and other ignored files are never triggering watcher.
649
+ */
650
+ watch(
651
+ callback: WatchBatchCallback,
652
+ options?: WatchOptions,
653
+ ): Result<WatchUnsubscribe>;
654
+ watch(
655
+ pattern: string,
656
+ callback: WatchBatchCallback,
657
+ options?: WatchOptions,
658
+ ): Result<WatchUnsubscribe>;
659
+
613
660
  /** Health/diagnostics information for this instance. */
614
661
  healthCheck(testPath?: string): Result<HealthCheck>;
615
662
  }
package/src/ffi.ts CHANGED
@@ -8,7 +8,15 @@
8
8
  * be passed to all subsequent calls and freed with `ffiDestroy`.
9
9
  */
10
10
 
11
- import { CString, dlopen, FFIType, type Pointer, ptr, read } from "bun:ffi";
11
+ import {
12
+ CString,
13
+ dlopen,
14
+ FFIType,
15
+ type JSCallback,
16
+ type Pointer,
17
+ ptr,
18
+ read,
19
+ } from "bun:ffi";
12
20
  import { findBinary } from "./download";
13
21
  import { embeddedLibPath } from "./embedded";
14
22
  import type {
@@ -24,6 +32,8 @@ import type {
24
32
  ScanProgress,
25
33
  Score,
26
34
  SearchResult,
35
+ WatchEvent,
36
+ WatchEventKind,
27
37
  } from "./fff-api";
28
38
  import { createGrepCursor, err } from "./fff-api";
29
39
 
@@ -192,6 +202,44 @@ const ffiDefinition = {
192
202
  returns: FFIType.ptr,
193
203
  },
194
204
 
205
+ // Watch subscriptions
206
+ fff_set_watch_callback: {
207
+ args: [
208
+ FFIType.ptr, // handle
209
+ FFIType.function, // FffWatchCallback (instance-wide)
210
+ FFIType.ptr, // user_data
211
+ ],
212
+ returns: FFIType.ptr,
213
+ },
214
+ fff_watch: {
215
+ args: [
216
+ FFIType.ptr, // handle
217
+ FFIType.cstring, // pattern
218
+ FFIType.ptr, // *const FffWatchOptions (or NULL)
219
+ ],
220
+ returns: FFIType.ptr,
221
+ },
222
+ fff_unwatch: {
223
+ args: [FFIType.ptr, FFIType.u64], // handle, watch_id
224
+ returns: FFIType.ptr,
225
+ },
226
+ fff_free_watch_events: {
227
+ args: [FFIType.ptr],
228
+ returns: FFIType.void,
229
+ },
230
+ fff_watch_events_count: {
231
+ args: [FFIType.ptr],
232
+ returns: FFIType.u32,
233
+ },
234
+ fff_watch_events_get_path: {
235
+ args: [FFIType.ptr, FFIType.u32],
236
+ returns: FFIType.ptr,
237
+ },
238
+ fff_watch_events_get_kind: {
239
+ args: [FFIType.ptr, FFIType.u32],
240
+ returns: FFIType.u8,
241
+ },
242
+
195
243
  // Git
196
244
  fff_refresh_git_status: {
197
245
  args: [FFIType.ptr],
@@ -1327,6 +1375,118 @@ export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<v
1327
1375
  return parseVoidResult(resultPtr);
1328
1376
  }
1329
1377
 
1378
+ // ---------------------------------------------------------------------------
1379
+ // Watch struct byte offsets (must match #[repr(C)] layout on 64-bit)
1380
+ // ---------------------------------------------------------------------------
1381
+
1382
+ // MUST match `crates/fff-c`::FffWatchOptions
1383
+ const FFF_WATCH_OPTIONS_VERSION = 1;
1384
+ const FFF_WATCH_OPTIONS_SIZE = 24;
1385
+ const FWO_VERSION = 0; // u32 (4 + 4 pad)
1386
+ const FWO_IGNORE = 8; // *const *const c_char (8)
1387
+ const FWO_IGNORE_COUNT = 16; // u32 (4 + 4 pad)
1388
+
1389
+ /** kind u8 -> WatchEventKind. Unknown values degrade to "rescan". */
1390
+ const WATCH_EVENT_KINDS: readonly WatchEventKind[] = [
1391
+ "created",
1392
+ "modified",
1393
+ "removed",
1394
+ "rescan",
1395
+ ];
1396
+
1397
+ /**
1398
+ * Parse an FffWatchEventBatch delivered to a watch callback, then free it.
1399
+ * Ownership of the batch transfers to JS at callback time, so this MUST be
1400
+ * called exactly once per delivered batch pointer.
1401
+ */
1402
+ export function readWatchEventBatch(batchPtr: Pointer | number | null): WatchEvent[] {
1403
+ if (batchPtr === null || (batchPtr as unknown as number) === 0) {
1404
+ return [];
1405
+ }
1406
+
1407
+ const bp = batchPtr as unknown as Pointer;
1408
+ const symbols = loadLibrary().symbols;
1409
+ const count = symbols.fff_watch_events_count(bp);
1410
+
1411
+ const events: WatchEvent[] = [];
1412
+ for (let i = 0; i < count; i++) {
1413
+ const path = symbols.fff_watch_events_get_path(bp, i) as Pointer | null;
1414
+ const kind = symbols.fff_watch_events_get_kind(bp, i) as number;
1415
+ events.push({
1416
+ path: readCString(path) ?? "",
1417
+ kind: WATCH_EVENT_KINDS[kind] ?? "rescan",
1418
+ });
1419
+ }
1420
+
1421
+ symbols.fff_free_watch_events(bp);
1422
+ return events;
1423
+ }
1424
+
1425
+ /**
1426
+ * Register the instance-wide watch callback. Must be called before the
1427
+ * first `ffiWatch`. The caller owns `callback` (a threadsafe `JSCallback`
1428
+ * built in finder.ts) and must keep it alive until after `ffiDestroy`
1429
+ * returns for this handle — that call is the delivery quiescence barrier.
1430
+ */
1431
+ export function ffiSetWatchCallback(
1432
+ handle: NativeHandle,
1433
+ callback: JSCallback,
1434
+ ): Result<void> {
1435
+ const library = loadLibrary();
1436
+ if (callback.ptr === null) {
1437
+ return err("watch callback has been closed");
1438
+ }
1439
+ const resultPtr = library.symbols.fff_set_watch_callback(handle, callback.ptr, null);
1440
+ return parseVoidResult(resultPtr);
1441
+ }
1442
+
1443
+ /**
1444
+ * Subscribe to filesystem changes; batches are delivered through the
1445
+ * instance callback registered with `ffiSetWatchCallback`, tagged with the
1446
+ * watch id this function returns.
1447
+ *
1448
+ * @returns The native watch id carried in `FffResult.int_value`.
1449
+ */
1450
+ export function ffiWatch(
1451
+ handle: NativeHandle,
1452
+ pattern: string,
1453
+ ignore: string[] = [],
1454
+ ): Result<number> {
1455
+ const library = loadLibrary();
1456
+
1457
+ // Keep every buffer referenced until the FFI call returns: the options
1458
+ // struct, the pointer array, and each encoded ignore string.
1459
+ const opts = Buffer.alloc(FFF_WATCH_OPTIONS_SIZE);
1460
+ opts.writeUInt32LE(FFF_WATCH_OPTIONS_VERSION, FWO_VERSION);
1461
+
1462
+ const ignoreBuffers = ignore.map((entry) => encodeString(entry));
1463
+ const ignorePtrs = Buffer.alloc(Math.max(ignoreBuffers.length, 1) * 8);
1464
+ for (let i = 0; i < ignoreBuffers.length; i++) {
1465
+ ignorePtrs.writeBigUInt64LE(BigInt(ptr(ignoreBuffers[i] as Uint8Array)), i * 8);
1466
+ }
1467
+ opts.writeBigUInt64LE(
1468
+ ignoreBuffers.length > 0 ? BigInt(ptr(ignorePtrs)) : 0n,
1469
+ FWO_IGNORE,
1470
+ );
1471
+ opts.writeUInt32LE(ignoreBuffers.length, FWO_IGNORE_COUNT);
1472
+
1473
+ const resultPtr = library.symbols.fff_watch(
1474
+ handle,
1475
+ ptr(encodeString(pattern)),
1476
+ ptr(opts),
1477
+ );
1478
+ return parseIntResult(resultPtr);
1479
+ }
1480
+
1481
+ /**
1482
+ * Remove a watch subscription. Returns true if the id was found.
1483
+ */
1484
+ export function ffiUnwatch(handle: NativeHandle, watchId: number): Result<boolean> {
1485
+ const library = loadLibrary();
1486
+ const resultPtr = library.symbols.fff_unwatch(handle, BigInt(watchId));
1487
+ return parseBoolResult(resultPtr);
1488
+ }
1489
+
1330
1490
  /**
1331
1491
  * Refresh git status.
1332
1492
  */
package/src/finder.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * All methods return Result types for explicit error handling.
9
9
  */
10
10
 
11
+ import { FFIType, JSCallback, type Pointer } from "bun:ffi";
12
+
11
13
  import {
12
14
  ensureLoaded,
13
15
  ffiCreate,
@@ -26,10 +28,14 @@ import {
26
28
  ffiSearch,
27
29
  ffiSearchDirectories,
28
30
  ffiSearchMixed,
31
+ ffiSetWatchCallback,
29
32
  ffiTrackQuery,
33
+ ffiUnwatch,
30
34
  ffiWaitForScan,
35
+ ffiWatch,
31
36
  isAvailable,
32
37
  type NativeHandle,
38
+ readWatchEventBatch,
33
39
  } from "./ffi";
34
40
 
35
41
  import type {
@@ -47,6 +53,9 @@ import type {
47
53
  ScanProgress,
48
54
  SearchOptions,
49
55
  SearchResult,
56
+ WatchBatchCallback,
57
+ WatchOptions,
58
+ WatchUnsubscribe,
50
59
  } from "./fff-api";
51
60
 
52
61
  import { err } from "./fff-api";
@@ -85,6 +94,14 @@ import { err } from "./fff-api";
85
94
  */
86
95
  export class FileFinder implements FileFinderApi {
87
96
  private handle: NativeHandle | null;
97
+ /** Active watch subscriptions: native watch id -> JS batch handler. */
98
+ private watchHandlers = new Map<number, WatchBatchCallback>();
99
+ /**
100
+ * ONE threadsafe JSCallback per instance, registered lazily with
101
+ * `fff_set_watch_callback` on the first subscription and closed in
102
+ * `destroy()` after `fff_destroy` returns (the quiescence barrier).
103
+ */
104
+ private watchJsCallback: JSCallback | null = null;
88
105
 
89
106
  private constructor(handle: NativeHandle) {
90
107
  this.handle = handle;
@@ -138,14 +155,19 @@ export class FileFinder implements FileFinderApi {
138
155
  /**
139
156
  * Destroy and clean up all resources.
140
157
  *
141
- * Call this when you're done using the file finder to free memory
142
- * and stop background file watching. After calling this, the instance
143
- * must not be used again.
158
+ * Frees the native instance (unsubscribing all watches), then closes the
159
+ * instance watch trampoline. After calling this, the instance must not be
160
+ * used again.
144
161
  */
145
162
  destroy(): void {
146
163
  if (this.handle !== null) {
164
+ this.watchHandlers.clear();
147
165
  ffiDestroy(this.handle);
148
166
  this.handle = null;
167
+ // Handlers were cleared first, so a delivery racing the destroy is a
168
+ // benign id-map miss before the trampoline is closed.
169
+ this.watchJsCallback?.close();
170
+ this.watchJsCallback = null;
149
171
  }
150
172
  }
151
173
 
@@ -571,6 +593,119 @@ export class FileFinder implements FileFinderApi {
571
593
  return ffiGetHistoricalQuery(guard.value, offset);
572
594
  }
573
595
 
596
+ /**
597
+ * Lazily create + register the instance-wide watch trampoline. Routes
598
+ * every delivered batch to the handler registered for its watch id;
599
+ * unknown ids (unsubscribe races) are benign — the batch is just freed.
600
+ */
601
+ private ensureWatchTrampoline(handle: NativeHandle): Result<void> {
602
+ if (this.watchJsCallback !== null) return { ok: true, value: undefined };
603
+
604
+ // Threadsafe: the native callback thread enqueues the invocation onto the
605
+ // JS event loop; the batch stays valid because JS owns it until it frees
606
+ // it inside readWatchEventBatch.
607
+ const jsCallback = new JSCallback(
608
+ (watchId: bigint | number, batchPtr: Pointer, _userData: Pointer) => {
609
+ const events = readWatchEventBatch(batchPtr);
610
+ const handler = this.watchHandlers.get(Number(watchId));
611
+ if (handler !== undefined && events.length > 0) {
612
+ handler(events);
613
+ }
614
+ },
615
+ {
616
+ // an attempt to fix the bug that is kept unfixed in the zig version of bun :()
617
+ // https://github.com/oven-sh/bun/issues/33840:
618
+ //
619
+ // watch_id is declared `ptr`, not `u64`: ABI-identical (one 64-bit
620
+ // register), but u64 args make bun allocate a JSBigInt on the CALLING
621
+ // (non-JS) thread, corrupting the JS heap
622
+ args: [FFIType.ptr, FFIType.ptr, FFIType.ptr],
623
+ returns: FFIType.void,
624
+ threadsafe: true,
625
+ },
626
+ );
627
+
628
+ const registered = ffiSetWatchCallback(handle, jsCallback);
629
+ if (!registered.ok) {
630
+ jsCallback.close();
631
+ return registered;
632
+ }
633
+ this.watchJsCallback = jsCallback;
634
+ return { ok: true, value: undefined };
635
+ }
636
+
637
+ /**
638
+ * Subscribe to filesystem changes matching `pattern` (glob, exact file,
639
+ * or directory subtree). Omit the pattern to watch the entire indexed
640
+ * tree. Normalized batches of up to 128 events are delivered on the JS event
641
+ * loop, with each path appearing at most once. See `FileFinderApi.watch`.
642
+ *
643
+ * @example
644
+ * ```typescript
645
+ * const sub = finder.watch("**\/*.ts", (events) => {
646
+ * for (const e of events) console.log(e.kind, e.path);
647
+ * });
648
+ * if (sub.ok) sub.value(); // unsubscribe
649
+ *
650
+ * // no pattern: everything under the indexed base path
651
+ * const all = finder.watch((events) => console.log(events.length));
652
+ * ```
653
+ */
654
+ watch(callback: WatchBatchCallback, options?: WatchOptions): Result<WatchUnsubscribe>;
655
+ watch(
656
+ pattern: string,
657
+ callback: WatchBatchCallback,
658
+ options?: WatchOptions,
659
+ ): Result<WatchUnsubscribe>;
660
+ watch(
661
+ patternOrCallback: string | WatchBatchCallback,
662
+ callbackOrOptions?: WatchBatchCallback | WatchOptions,
663
+ maybeOptions?: WatchOptions,
664
+ ): Result<WatchUnsubscribe> {
665
+ // Overload shift: watch(cb, opts?) -> empty pattern = whole tree.
666
+ const noPattern = typeof patternOrCallback === "function";
667
+ const pattern = noPattern ? "" : patternOrCallback;
668
+ const callback = noPattern
669
+ ? patternOrCallback
670
+ : (callbackOrOptions as WatchBatchCallback);
671
+ const options = noPattern
672
+ ? (callbackOrOptions as WatchOptions | undefined)
673
+ : maybeOptions;
674
+
675
+ if (typeof callback !== "function") {
676
+ return err("watch callback must be a function");
677
+ }
678
+
679
+ const guard = this.ensureAlive();
680
+ if (!guard.ok) return guard;
681
+
682
+ const trampoline = this.ensureWatchTrampoline(guard.value);
683
+ if (!trampoline.ok) return trampoline;
684
+
685
+ const result = ffiWatch(guard.value, pattern, options?.ignore ?? []);
686
+ if (!result.ok) return result;
687
+
688
+ // No startup race: the threadsafe trampoline only runs on the JS event
689
+ // loop, so this synchronous set always precedes the first routing lookup.
690
+ const watchId = result.value;
691
+ this.watchHandlers.set(watchId, callback);
692
+
693
+ return { ok: true, value: () => this.unwatchById(watchId) };
694
+ }
695
+
696
+ /**
697
+ * Remove a subscription from the routing map, then from the native side.
698
+ * Map removal is synchronous on the JS thread, so once this returns the
699
+ * handler can never run again (late native batches miss the lookup).
700
+ * Idempotent.
701
+ */
702
+ private unwatchById(watchId: number): void {
703
+ if (!this.watchHandlers.delete(watchId)) return;
704
+ if (this.handle !== null) {
705
+ ffiUnwatch(this.handle, watchId);
706
+ }
707
+ }
708
+
574
709
  /**
575
710
  * Get health check information.
576
711
  *
package/src/index.ts CHANGED
@@ -24,6 +24,11 @@ export type {
24
24
  Score,
25
25
  SearchOptions,
26
26
  SearchResult,
27
+ WatchBatchCallback,
28
+ WatchEvent,
29
+ WatchEventKind,
30
+ WatchOptions,
31
+ WatchUnsubscribe,
27
32
  } from "./fff-api";
28
33
 
29
34
  export { FileFinder } from "./finder";