@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 +35 -16
- package/dist/index.js +226 -24
- package/dist/index.js.map +1 -1
- package/dist/workers/sync-relay.worker.js +64 -13
- package/dist/workers/sync-relay.worker.js.map +1 -1
- package/package.json +1 -1
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
|
|
316
|
-
|
|
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 (
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
953
|
-
let
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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
|
|
1237
|
-
|
|
1238
|
-
|
|
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) {
|