@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 +50 -0
- package/examples/watch.ts +57 -0
- package/package.json +9 -9
- package/src/fff-api.ts +62 -15
- package/src/ffi.ts +161 -1
- package/src/finder.ts +138 -3
- package/src/index.ts +5 -0
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.
|
|
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.
|
|
62
|
-
"@ff-labs/fff-bin-darwin-x64": "0.
|
|
63
|
-
"@ff-labs/fff-bin-linux-x64-gnu": "0.
|
|
64
|
-
"@ff-labs/fff-bin-linux-arm64-gnu": "0.
|
|
65
|
-
"@ff-labs/fff-bin-linux-x64-musl": "0.
|
|
66
|
-
"@ff-labs/fff-bin-linux-arm64-musl": "0.
|
|
67
|
-
"@ff-labs/fff-bin-win32-x64": "0.
|
|
68
|
-
"@ff-labs/fff-bin-win32-arm64": "0.
|
|
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
|
-
//
|
|
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 (`/`).
|
|
101
|
-
* will generally require
|
|
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
|
-
*
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
*
|