@componentor/fs 3.0.2 → 3.0.3

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
@@ -311,17 +311,23 @@ await writer.close();
311
311
  ### Watch API
312
312
 
313
313
  ```typescript
314
- // Watch for changes
315
- const watcher = fs.watch('/dir', { recursive: true }, (eventType, filename) => {
316
- console.log(eventType, filename); // 'change' 'file.txt'
314
+ // Watch for changes (supports recursive + AbortSignal)
315
+ const ac = new AbortController();
316
+ const watcher = fs.watch('/dir', { recursive: true, signal: ac.signal }, (eventType, filename) => {
317
+ console.log(eventType, filename); // 'rename' 'newfile.txt' or 'change' 'file.txt'
317
318
  });
318
- watcher.close();
319
+ watcher.close(); // or ac.abort()
319
320
 
320
- // Watch specific file with polling
321
+ // Watch specific file with stat polling
321
322
  fs.watchFile('/file.txt', { interval: 1000 }, (curr, prev) => {
322
323
  console.log('File changed:', curr.mtimeMs !== prev.mtimeMs);
323
324
  });
324
325
  fs.unwatchFile('/file.txt');
326
+
327
+ // Async iterable (promises API)
328
+ for await (const event of fs.promises.watch('/dir', { recursive: true })) {
329
+ console.log(event.eventType, event.filename);
330
+ }
325
331
  ```
326
332
 
327
333
  ### Path Utilities
@@ -412,23 +418,23 @@ await git.commit({
412
418
  │ sync-relay Worker (Leader) │
413
419
  │ ┌────────────────────────────────────────────────────────────┐ │
414
420
  │ │ VFS Engine │ │
415
- │ │ ┌──────────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
416
- │ │ │ VFS Binary File │ │ Inode/Path │ │ Block Data │ │
417
- │ │ │ (.vfs.bin OPFS) │ │ Table │ │ Region │ │
418
- │ │ └──────────────────┘ └─────────────┘ └──────────────┘ │ │
421
+ │ │ ┌──────────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
422
+ │ │ │ VFS Binary File │ │ Inode/Path │ │ Block Data │ │
423
+ │ │ │ (.vfs.bin OPFS) │ │ Table │ │ Region │ │
424
+ │ │ └──────────────────┘ └─────────────┘ └──────────────┘ │ │
419
425
  │ └────────────────────────────────────────────────────────────┘ │
420
426
  │ │ │
421
- │ notifyOPFSSync()
422
- │ (fire & forget)
427
+ │ notifyOPFSSync()
428
+ │ (fire & forget)
423
429
  └────────────────────────────┼─────────────────────────────────────┘
424
430
 
425
431
 
426
432
  ┌──────────────────────────────────────────────────────────────────┐
427
- │ opfs-sync Worker
433
+ │ opfs-sync Worker
428
434
  │ ┌────────────────────┐ ┌────────────────────────────────────┐ │
429
- │ │ VFS → OPFS Mirror │ │ FileSystemObserver (OPFS → VFS) │ │
430
- │ │ (queue + echo │ │ External changes detected and │ │
431
- │ │ suppression) │ │ synced back to VFS engine │ │
435
+ │ │ VFS → OPFS Mirror │ │ FileSystemObserver (OPFS → VFS) │ │
436
+ │ │ (queue + echo │ │ External changes detected and │ │
437
+ │ │ suppression) │ │ synced back to VFS engine │ │
432
438
  │ └────────────────────┘ └────────────────────────────────────┘ │
433
439
  └──────────────────────────────────────────────────────────────────┘
434
440
 
@@ -475,6 +481,19 @@ Make sure `opfsSync` is enabled (it's `true` by default). Files are mirrored to
475
481
 
476
482
  ## Changelog
477
483
 
484
+ ### v3.0.3 (2026)
485
+
486
+ **Features:**
487
+ - Implement `fs.watch()`, `fs.watchFile()`, `fs.unwatchFile()`, and `promises.watch()` as Node.js-compatible polyfills
488
+ - Watch events propagate across all tabs via `BroadcastChannel`
489
+ - `fs.watch()` supports `recursive` option and `AbortSignal` for cleanup
490
+ - `fs.watchFile()` supports stat-based polling with configurable `interval` (default 5007ms per Node.js)
491
+ - `promises.watch()` returns an async iterable of watch events
492
+
493
+ **Internal:**
494
+ - Leader broadcasts `{ eventType, path }` on every successful VFS mutation (no new opcodes or protocol changes)
495
+ - Mutation tracking now runs unconditionally (previously gated on `opfsSync`)
496
+
478
497
  ### v3.0.2 (2026)
479
498
 
480
499
  **Bug Fixes:**
@@ -529,7 +548,7 @@ git clone https://github.com/componentor/fs
529
548
  cd fs
530
549
  npm install
531
550
  npm run build # Build the library
532
- npm test # Run unit tests (77 tests)
551
+ npm test # Run unit tests (84 tests)
533
552
  npm run benchmark:open # Run benchmarks in browser
534
553
  ```
535
554
 
package/dist/index.js CHANGED
@@ -939,33 +939,233 @@ function format(obj) {
939
939
  }
940
940
 
941
941
  // src/methods/watch.ts
942
- function watch(_filePath, _options, _listener) {
943
- const interval = setInterval(() => {
944
- }, 1e3);
942
+ var watchers = /* @__PURE__ */ new Set();
943
+ var fileWatchers = /* @__PURE__ */ new Map();
944
+ var bc = null;
945
+ var bcRefCount = 0;
946
+ function ensureBc() {
947
+ if (bc) {
948
+ bcRefCount++;
949
+ return;
950
+ }
951
+ bc = new BroadcastChannel("vfs-watch");
952
+ bcRefCount = 1;
953
+ bc.onmessage = onBroadcast;
954
+ }
955
+ function releaseBc() {
956
+ if (--bcRefCount <= 0 && bc) {
957
+ bc.close();
958
+ bc = null;
959
+ bcRefCount = 0;
960
+ }
961
+ }
962
+ function onBroadcast(event) {
963
+ const { eventType, path: mutatedPath } = event.data;
964
+ for (const entry of watchers) {
965
+ const filename = matchWatcher(entry, mutatedPath);
966
+ if (filename !== null) {
967
+ try {
968
+ entry.listener(eventType, filename);
969
+ } catch {
970
+ }
971
+ }
972
+ }
973
+ const fileSet = fileWatchers.get(mutatedPath);
974
+ if (fileSet) {
975
+ for (const entry of fileSet) {
976
+ triggerWatchFile(entry);
977
+ }
978
+ }
979
+ }
980
+ function matchWatcher(entry, mutatedPath) {
981
+ const { absPath, recursive } = entry;
982
+ if (mutatedPath === absPath) {
983
+ return basename(mutatedPath);
984
+ }
985
+ if (!mutatedPath.startsWith(absPath) || mutatedPath.charAt(absPath.length) !== "/") {
986
+ return null;
987
+ }
988
+ const relativePath = mutatedPath.substring(absPath.length + 1);
989
+ if (recursive) return relativePath;
990
+ return relativePath.indexOf("/") === -1 ? relativePath : null;
991
+ }
992
+ function watch(filePath, options, listener) {
993
+ const opts = typeof options === "string" ? { } : options ?? {};
994
+ const cb = listener ?? (() => {
995
+ });
996
+ const absPath = resolve(filePath);
997
+ const signal = opts.signal;
998
+ const entry = {
999
+ absPath,
1000
+ recursive: opts.recursive ?? false,
1001
+ listener: cb,
1002
+ signal
1003
+ };
1004
+ ensureBc();
1005
+ watchers.add(entry);
1006
+ if (signal) {
1007
+ const onAbort = () => {
1008
+ watchers.delete(entry);
1009
+ releaseBc();
1010
+ signal.removeEventListener("abort", onAbort);
1011
+ };
1012
+ if (signal.aborted) {
1013
+ onAbort();
1014
+ } else {
1015
+ signal.addEventListener("abort", onAbort);
1016
+ }
1017
+ }
945
1018
  const watcher = {
946
- close: () => clearInterval(interval),
947
- ref: () => watcher,
948
- unref: () => watcher
1019
+ close() {
1020
+ watchers.delete(entry);
1021
+ releaseBc();
1022
+ },
1023
+ ref() {
1024
+ return watcher;
1025
+ },
1026
+ unref() {
1027
+ return watcher;
1028
+ }
949
1029
  };
950
1030
  return watcher;
951
1031
  }
952
- async function* watchAsync(asyncRequest, filePath, options) {
953
- let lastMtime = 0;
954
- const signal = options?.signal;
955
- while (!signal?.aborted) {
956
- try {
957
- const s = await stat(asyncRequest, filePath);
958
- if (s.mtimeMs !== lastMtime) {
959
- if (lastMtime !== 0) {
960
- yield { eventType: "change", filename: basename(filePath) };
961
- }
962
- lastMtime = s.mtimeMs;
1032
+ function watchFile(syncRequest, filePath, optionsOrListener, listener) {
1033
+ let opts;
1034
+ let cb;
1035
+ if (typeof optionsOrListener === "function") {
1036
+ cb = optionsOrListener;
1037
+ opts = {};
1038
+ } else {
1039
+ opts = optionsOrListener ?? {};
1040
+ cb = listener;
1041
+ }
1042
+ if (!cb) return;
1043
+ const absPath = resolve(filePath);
1044
+ const interval = opts.interval ?? 5007;
1045
+ let prevStats = null;
1046
+ try {
1047
+ prevStats = statSync(syncRequest, absPath);
1048
+ } catch {
1049
+ }
1050
+ const entry = {
1051
+ absPath,
1052
+ listener: cb,
1053
+ interval,
1054
+ prevStats,
1055
+ syncRequest,
1056
+ timerId: null
1057
+ };
1058
+ ensureBc();
1059
+ let set = fileWatchers.get(absPath);
1060
+ if (!set) {
1061
+ set = /* @__PURE__ */ new Set();
1062
+ fileWatchers.set(absPath, set);
1063
+ }
1064
+ set.add(entry);
1065
+ entry.timerId = setInterval(() => triggerWatchFile(entry), interval);
1066
+ }
1067
+ function unwatchFile(filePath, listener) {
1068
+ const absPath = resolve(filePath);
1069
+ const set = fileWatchers.get(absPath);
1070
+ if (!set) return;
1071
+ if (listener) {
1072
+ for (const entry of set) {
1073
+ if (entry.listener === listener) {
1074
+ if (entry.timerId !== null) clearInterval(entry.timerId);
1075
+ set.delete(entry);
1076
+ releaseBc();
1077
+ break;
963
1078
  }
1079
+ }
1080
+ if (set.size === 0) fileWatchers.delete(absPath);
1081
+ } else {
1082
+ for (const entry of set) {
1083
+ if (entry.timerId !== null) clearInterval(entry.timerId);
1084
+ releaseBc();
1085
+ }
1086
+ fileWatchers.delete(absPath);
1087
+ }
1088
+ }
1089
+ function triggerWatchFile(entry) {
1090
+ let currStats = null;
1091
+ try {
1092
+ currStats = statSync(entry.syncRequest, entry.absPath);
1093
+ } catch {
1094
+ }
1095
+ const prev = entry.prevStats ?? emptyStats();
1096
+ const curr = currStats ?? emptyStats();
1097
+ if (prev.mtimeMs !== curr.mtimeMs || prev.size !== curr.size || prev.ino !== curr.ino) {
1098
+ entry.prevStats = currStats;
1099
+ try {
1100
+ entry.listener(curr, prev);
964
1101
  } catch {
965
- yield { eventType: "rename", filename: basename(filePath) };
966
- return;
967
1102
  }
968
- await new Promise((r) => setTimeout(r, 100));
1103
+ }
1104
+ }
1105
+ function emptyStats() {
1106
+ const zero = /* @__PURE__ */ new Date(0);
1107
+ return {
1108
+ isFile: () => false,
1109
+ isDirectory: () => false,
1110
+ isBlockDevice: () => false,
1111
+ isCharacterDevice: () => false,
1112
+ isSymbolicLink: () => false,
1113
+ isFIFO: () => false,
1114
+ isSocket: () => false,
1115
+ dev: 0,
1116
+ ino: 0,
1117
+ mode: 0,
1118
+ nlink: 0,
1119
+ uid: 0,
1120
+ gid: 0,
1121
+ rdev: 0,
1122
+ size: 0,
1123
+ blksize: 4096,
1124
+ blocks: 0,
1125
+ atimeMs: 0,
1126
+ mtimeMs: 0,
1127
+ ctimeMs: 0,
1128
+ birthtimeMs: 0,
1129
+ atime: zero,
1130
+ mtime: zero,
1131
+ ctime: zero,
1132
+ birthtime: zero
1133
+ };
1134
+ }
1135
+ async function* watchAsync(_asyncRequest, filePath, options) {
1136
+ const absPath = resolve(filePath);
1137
+ const recursive = options?.recursive ?? false;
1138
+ const signal = options?.signal;
1139
+ const queue = [];
1140
+ let resolve2 = null;
1141
+ const entry = {
1142
+ absPath,
1143
+ recursive,
1144
+ listener: (eventType, filename) => {
1145
+ queue.push({ eventType, filename });
1146
+ if (resolve2) {
1147
+ resolve2();
1148
+ resolve2 = null;
1149
+ }
1150
+ },
1151
+ signal
1152
+ };
1153
+ ensureBc();
1154
+ watchers.add(entry);
1155
+ try {
1156
+ while (!signal?.aborted) {
1157
+ if (queue.length === 0) {
1158
+ await new Promise((r) => {
1159
+ resolve2 = r;
1160
+ });
1161
+ }
1162
+ while (queue.length > 0) {
1163
+ yield queue.shift();
1164
+ }
1165
+ }
1166
+ } finally {
1167
+ watchers.delete(entry);
1168
+ releaseBc();
969
1169
  }
970
1170
  }
971
1171
 
@@ -1233,9 +1433,9 @@ var VFSFileSystem = class {
1233
1433
  }
1234
1434
  };
1235
1435
  mc.port1.start();
1236
- const bc = new BroadcastChannel("vfs-leader-change");
1237
- bc.postMessage({ type: "leader-changed" });
1238
- bc.close();
1436
+ const bc2 = new BroadcastChannel("vfs-leader-change");
1437
+ bc2.postMessage({ type: "leader-changed" });
1438
+ bc2.close();
1239
1439
  }).catch((err) => {
1240
1440
  console.warn("[VFS] SW broker unavailable, single-tab only:", err.message);
1241
1441
  });
@@ -1501,11 +1701,13 @@ var VFSFileSystem = class {
1501
1701
  }
1502
1702
  // ---- Watch methods ----
1503
1703
  watch(filePath, options, listener) {
1504
- return watch();
1704
+ return watch(filePath, options, listener);
1505
1705
  }
1506
1706
  watchFile(filePath, optionsOrListener, listener) {
1707
+ watchFile(this._sync, filePath, optionsOrListener, listener);
1507
1708
  }
1508
1709
  unwatchFile(filePath, listener) {
1710
+ unwatchFile(filePath, listener);
1509
1711
  }
1510
1712
  // ---- Stream methods ----
1511
1713
  createReadStream(filePath, options) {