@componentor/fs 3.0.5 → 3.0.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.
package/README.md CHANGED
@@ -509,6 +509,16 @@ Make sure `opfsSync` is enabled (it's `true` by default). Files are mirrored to
509
509
 
510
510
  ## Changelog
511
511
 
512
+ ### v3.0.6 (2026)
513
+
514
+ **Performance:**
515
+ - Bulk-read inode + path tables during mount — 2 I/O calls instead of 10,000+, dramatically faster initialization for large VFS files
516
+ - All active inodes pre-populated in cache on mount (no cold-read penalty for first operations)
517
+
518
+ **Fixes:**
519
+ - `.vfs.bin` now auto-shrinks: trailing free blocks are trimmed on every commit, reclaiming disk space when files are deleted
520
+ - Minimum of 1024 data blocks (4MB) retained to avoid excessive re-growth on small create/delete cycles
521
+
512
522
  ### v3.0.5 (2026)
513
523
 
514
524
  **Fixes:**
@@ -516,6 +526,8 @@ Make sure `opfsSync` is enabled (it's `true` by default). Files are mirrored to
516
526
  - Remove unnecessary `clients.claim()` from the service worker — it only acts as a MessagePort broker and never needs to control pages
517
527
  - Namespace leader lock, BroadcastChannel, and SW scope by `root` so multiple `VFSFileSystem` instances with different roots don't collide
518
528
  - Add `swScope` config option for custom service worker scope override
529
+ - Singleton registry: multiple `new VFSFileSystem()` calls with the same root return the same instance (no duplicate workers)
530
+ - Namespace `vfs-watch` BroadcastChannel by root so watch events don't leak between different roots
519
531
 
520
532
  ### v3.0.4 (2026)
521
533
 
package/dist/index.js CHANGED
@@ -1038,22 +1038,23 @@ function format(obj) {
1038
1038
  // src/methods/watch.ts
1039
1039
  var watchers = /* @__PURE__ */ new Set();
1040
1040
  var fileWatchers = /* @__PURE__ */ new Map();
1041
- var bc = null;
1042
- var bcRefCount = 0;
1043
- function ensureBc() {
1044
- if (bc) {
1045
- bcRefCount++;
1041
+ var bcMap = /* @__PURE__ */ new Map();
1042
+ function ensureBc(ns) {
1043
+ const entry = bcMap.get(ns);
1044
+ if (entry) {
1045
+ entry.refCount++;
1046
1046
  return;
1047
1047
  }
1048
- bc = new BroadcastChannel("vfs-watch");
1049
- bcRefCount = 1;
1048
+ const bc = new BroadcastChannel(`${ns}-watch`);
1049
+ bcMap.set(ns, { bc, refCount: 1 });
1050
1050
  bc.onmessage = onBroadcast;
1051
1051
  }
1052
- function releaseBc() {
1053
- if (--bcRefCount <= 0 && bc) {
1054
- bc.close();
1055
- bc = null;
1056
- bcRefCount = 0;
1052
+ function releaseBc(ns) {
1053
+ const entry = bcMap.get(ns);
1054
+ if (!entry) return;
1055
+ if (--entry.refCount <= 0) {
1056
+ entry.bc.close();
1057
+ bcMap.delete(ns);
1057
1058
  }
1058
1059
  }
1059
1060
  function onBroadcast(event) {
@@ -1086,24 +1087,25 @@ function matchWatcher(entry, mutatedPath) {
1086
1087
  if (recursive) return relativePath;
1087
1088
  return relativePath.indexOf("/") === -1 ? relativePath : null;
1088
1089
  }
1089
- function watch(filePath, options, listener) {
1090
+ function watch(ns, filePath, options, listener) {
1090
1091
  const opts = typeof options === "string" ? { } : options ?? {};
1091
1092
  const cb = listener ?? (() => {
1092
1093
  });
1093
1094
  const absPath = resolve(filePath);
1094
1095
  const signal = opts.signal;
1095
1096
  const entry = {
1097
+ ns,
1096
1098
  absPath,
1097
1099
  recursive: opts.recursive ?? false,
1098
1100
  listener: cb,
1099
1101
  signal
1100
1102
  };
1101
- ensureBc();
1103
+ ensureBc(ns);
1102
1104
  watchers.add(entry);
1103
1105
  if (signal) {
1104
1106
  const onAbort = () => {
1105
1107
  watchers.delete(entry);
1106
- releaseBc();
1108
+ releaseBc(ns);
1107
1109
  signal.removeEventListener("abort", onAbort);
1108
1110
  };
1109
1111
  if (signal.aborted) {
@@ -1115,7 +1117,7 @@ function watch(filePath, options, listener) {
1115
1117
  const watcher = {
1116
1118
  close() {
1117
1119
  watchers.delete(entry);
1118
- releaseBc();
1120
+ releaseBc(ns);
1119
1121
  },
1120
1122
  ref() {
1121
1123
  return watcher;
@@ -1126,7 +1128,7 @@ function watch(filePath, options, listener) {
1126
1128
  };
1127
1129
  return watcher;
1128
1130
  }
1129
- function watchFile(syncRequest, filePath, optionsOrListener, listener) {
1131
+ function watchFile(ns, syncRequest, filePath, optionsOrListener, listener) {
1130
1132
  let opts;
1131
1133
  let cb;
1132
1134
  if (typeof optionsOrListener === "function") {
@@ -1145,6 +1147,7 @@ function watchFile(syncRequest, filePath, optionsOrListener, listener) {
1145
1147
  } catch {
1146
1148
  }
1147
1149
  const entry = {
1150
+ ns,
1148
1151
  absPath,
1149
1152
  listener: cb,
1150
1153
  interval,
@@ -1152,7 +1155,7 @@ function watchFile(syncRequest, filePath, optionsOrListener, listener) {
1152
1155
  syncRequest,
1153
1156
  timerId: null
1154
1157
  };
1155
- ensureBc();
1158
+ ensureBc(ns);
1156
1159
  let set = fileWatchers.get(absPath);
1157
1160
  if (!set) {
1158
1161
  set = /* @__PURE__ */ new Set();
@@ -1161,7 +1164,7 @@ function watchFile(syncRequest, filePath, optionsOrListener, listener) {
1161
1164
  set.add(entry);
1162
1165
  entry.timerId = setInterval(() => triggerWatchFile(entry), interval);
1163
1166
  }
1164
- function unwatchFile(filePath, listener) {
1167
+ function unwatchFile(ns, filePath, listener) {
1165
1168
  const absPath = resolve(filePath);
1166
1169
  const set = fileWatchers.get(absPath);
1167
1170
  if (!set) return;
@@ -1170,7 +1173,7 @@ function unwatchFile(filePath, listener) {
1170
1173
  if (entry.listener === listener) {
1171
1174
  if (entry.timerId !== null) clearInterval(entry.timerId);
1172
1175
  set.delete(entry);
1173
- releaseBc();
1176
+ releaseBc(ns);
1174
1177
  break;
1175
1178
  }
1176
1179
  }
@@ -1178,7 +1181,7 @@ function unwatchFile(filePath, listener) {
1178
1181
  } else {
1179
1182
  for (const entry of set) {
1180
1183
  if (entry.timerId !== null) clearInterval(entry.timerId);
1181
- releaseBc();
1184
+ releaseBc(ns);
1182
1185
  }
1183
1186
  fileWatchers.delete(absPath);
1184
1187
  }
@@ -1229,13 +1232,14 @@ function emptyStats() {
1229
1232
  birthtime: zero
1230
1233
  };
1231
1234
  }
1232
- async function* watchAsync(_asyncRequest, filePath, options) {
1235
+ async function* watchAsync(ns, _asyncRequest, filePath, options) {
1233
1236
  const absPath = resolve(filePath);
1234
1237
  const recursive = options?.recursive ?? false;
1235
1238
  const signal = options?.signal;
1236
1239
  const queue = [];
1237
1240
  let resolve2 = null;
1238
1241
  const entry = {
1242
+ ns,
1239
1243
  absPath,
1240
1244
  recursive,
1241
1245
  listener: (eventType, filename) => {
@@ -1247,7 +1251,7 @@ async function* watchAsync(_asyncRequest, filePath, options) {
1247
1251
  },
1248
1252
  signal
1249
1253
  };
1250
- ensureBc();
1254
+ ensureBc(ns);
1251
1255
  watchers.add(entry);
1252
1256
  try {
1253
1257
  while (!signal?.aborted) {
@@ -1262,13 +1266,14 @@ async function* watchAsync(_asyncRequest, filePath, options) {
1262
1266
  }
1263
1267
  } finally {
1264
1268
  watchers.delete(entry);
1265
- releaseBc();
1269
+ releaseBc(ns);
1266
1270
  }
1267
1271
  }
1268
1272
 
1269
1273
  // src/filesystem.ts
1270
1274
  var encoder9 = new TextEncoder();
1271
1275
  var DEFAULT_SAB_SIZE = 2 * 1024 * 1024;
1276
+ var instanceRegistry = /* @__PURE__ */ new Map();
1272
1277
  var HEADER_SIZE = SAB_OFFSETS.HEADER_SIZE;
1273
1278
  var _canAtomicsWait = typeof globalThis.WorkerGlobalScope !== "undefined";
1274
1279
  function spinWait(arr, index, value) {
@@ -1299,7 +1304,7 @@ var VFSFileSystem = class {
1299
1304
  readyPromise;
1300
1305
  resolveReady;
1301
1306
  isReady = false;
1302
- // Config
1307
+ // Config (definite assignment — always set when constructor doesn't return singleton)
1303
1308
  config;
1304
1309
  tabId;
1305
1310
  /** Namespace string derived from root — used for lock names, BroadcastChannel, and SW scope
@@ -1317,8 +1322,12 @@ var VFSFileSystem = class {
1317
1322
  // Promises API namespace
1318
1323
  promises;
1319
1324
  constructor(config = {}) {
1325
+ const root = config.root ?? "/";
1326
+ const ns = `vfs-${root.replace(/[^a-zA-Z0-9]/g, "_")}`;
1327
+ const existing = instanceRegistry.get(ns);
1328
+ if (existing) return existing;
1320
1329
  this.config = {
1321
- root: config.root ?? "/",
1330
+ root,
1322
1331
  opfsSync: config.opfsSync ?? true,
1323
1332
  opfsSyncRoot: config.opfsSyncRoot,
1324
1333
  uid: config.uid ?? 0,
@@ -1330,11 +1339,12 @@ var VFSFileSystem = class {
1330
1339
  swScope: config.swScope
1331
1340
  };
1332
1341
  this.tabId = crypto.randomUUID();
1333
- this.ns = `vfs-${this.config.root.replace(/[^a-zA-Z0-9]/g, "_")}`;
1342
+ this.ns = ns;
1334
1343
  this.readyPromise = new Promise((resolve2) => {
1335
1344
  this.resolveReady = resolve2;
1336
1345
  });
1337
- this.promises = new VFSPromises(this._async);
1346
+ this.promises = new VFSPromises(this._async, ns);
1347
+ instanceRegistry.set(ns, this);
1338
1348
  this.bootstrap();
1339
1349
  }
1340
1350
  /** Spawn workers and establish communication */
@@ -1438,6 +1448,7 @@ var VFSFileSystem = class {
1438
1448
  tabId: this.tabId,
1439
1449
  config: {
1440
1450
  root: this.config.root,
1451
+ ns: this.ns,
1441
1452
  opfsSync: this.config.opfsSync,
1442
1453
  opfsSyncRoot: this.config.opfsSyncRoot,
1443
1454
  uid: this.config.uid,
@@ -1536,9 +1547,9 @@ var VFSFileSystem = class {
1536
1547
  }
1537
1548
  };
1538
1549
  mc.port1.start();
1539
- const bc2 = new BroadcastChannel(`${this.ns}-leader-change`);
1540
- bc2.postMessage({ type: "leader-changed" });
1541
- bc2.close();
1550
+ const bc = new BroadcastChannel(`${this.ns}-leader-change`);
1551
+ bc.postMessage({ type: "leader-changed" });
1552
+ bc.close();
1542
1553
  }).catch((err) => {
1543
1554
  console.warn("[VFS] SW broker unavailable, single-tab only:", err.message);
1544
1555
  });
@@ -1804,13 +1815,13 @@ var VFSFileSystem = class {
1804
1815
  }
1805
1816
  // ---- Watch methods ----
1806
1817
  watch(filePath, options, listener) {
1807
- return watch(filePath, options, listener);
1818
+ return watch(this.ns, filePath, options, listener);
1808
1819
  }
1809
1820
  watchFile(filePath, optionsOrListener, listener) {
1810
- watchFile(this._sync, filePath, optionsOrListener, listener);
1821
+ watchFile(this.ns, this._sync, filePath, optionsOrListener, listener);
1811
1822
  }
1812
1823
  unwatchFile(filePath, listener) {
1813
- unwatchFile(filePath, listener);
1824
+ unwatchFile(this.ns, filePath, listener);
1814
1825
  }
1815
1826
  // ---- Stream methods ----
1816
1827
  createReadStream(filePath, options) {
@@ -1881,8 +1892,10 @@ var VFSFileSystem = class {
1881
1892
  };
1882
1893
  var VFSPromises = class {
1883
1894
  _async;
1884
- constructor(asyncRequest) {
1895
+ _ns;
1896
+ constructor(asyncRequest, ns) {
1885
1897
  this._async = asyncRequest;
1898
+ this._ns = ns;
1886
1899
  }
1887
1900
  readFile(filePath, options) {
1888
1901
  return readFile(this._async, filePath, options);
@@ -1960,7 +1973,7 @@ var VFSPromises = class {
1960
1973
  return mkdtemp(this._async, prefix);
1961
1974
  }
1962
1975
  async *watch(filePath, options) {
1963
- yield* watchAsync(this._async, filePath, options);
1976
+ yield* watchAsync(this._ns, this._async, filePath, options);
1964
1977
  }
1965
1978
  async flush() {
1966
1979
  await this._async(OP.FSYNC, "");
@@ -2148,6 +2161,10 @@ var VFSEngine = class {
2148
2161
  if (hi > this.bitmapDirtyHi) this.bitmapDirtyHi = hi;
2149
2162
  }
2150
2163
  commitPending() {
2164
+ if (this.blocksFreedsinceTrim) {
2165
+ this.trimTrailingBlocks();
2166
+ this.blocksFreedsinceTrim = false;
2167
+ }
2151
2168
  if (this.bitmapDirtyHi >= 0) {
2152
2169
  const lo = this.bitmapDirtyLo;
2153
2170
  const hi = this.bitmapDirtyHi;
@@ -2160,13 +2177,69 @@ var VFSEngine = class {
2160
2177
  this.superblockDirty = false;
2161
2178
  }
2162
2179
  }
2163
- /** Rebuild in-memory path→inode index from disk */
2180
+ /** Shrink the OPFS file by removing trailing free blocks from the data region.
2181
+ * Scans bitmap from end to find the last used block, then truncates. */
2182
+ trimTrailingBlocks() {
2183
+ const bitmap = this.bitmap;
2184
+ let lastUsed = -1;
2185
+ for (let byteIdx = Math.ceil(this.totalBlocks / 8) - 1; byteIdx >= 0; byteIdx--) {
2186
+ if (bitmap[byteIdx] !== 0) {
2187
+ for (let bit = 7; bit >= 0; bit--) {
2188
+ const blockIdx = byteIdx * 8 + bit;
2189
+ if (blockIdx < this.totalBlocks && bitmap[byteIdx] & 1 << bit) {
2190
+ lastUsed = blockIdx;
2191
+ break;
2192
+ }
2193
+ }
2194
+ break;
2195
+ }
2196
+ }
2197
+ const newTotal = Math.max(lastUsed + 1, INITIAL_DATA_BLOCKS);
2198
+ if (newTotal >= this.totalBlocks) return;
2199
+ this.handle.truncate(this.dataOffset + newTotal * this.blockSize);
2200
+ const newBitmapSize = Math.ceil(newTotal / 8);
2201
+ this.bitmap = bitmap.slice(0, newBitmapSize);
2202
+ const trimmed = this.totalBlocks - newTotal;
2203
+ this.freeBlocks -= trimmed;
2204
+ this.totalBlocks = newTotal;
2205
+ this.superblockDirty = true;
2206
+ this.bitmapDirtyLo = 0;
2207
+ this.bitmapDirtyHi = newBitmapSize - 1;
2208
+ }
2209
+ /** Rebuild in-memory path→inode index from disk.
2210
+ * Bulk-reads the entire inode table + path table in 2 I/O calls,
2211
+ * then parses in memory (avoids 10k+ individual reads). */
2164
2212
  rebuildIndex() {
2165
2213
  this.pathIndex.clear();
2214
+ this.inodeCache.clear();
2215
+ const inodeTableSize = this.inodeCount * INODE_SIZE;
2216
+ const inodeBuf = new Uint8Array(inodeTableSize);
2217
+ this.handle.read(inodeBuf, { at: this.inodeTableOffset });
2218
+ const inodeView = new DataView(inodeBuf.buffer);
2219
+ const pathBuf = this.pathTableUsed > 0 ? new Uint8Array(this.pathTableUsed) : null;
2220
+ if (pathBuf) {
2221
+ this.handle.read(pathBuf, { at: this.pathTableOffset });
2222
+ }
2166
2223
  for (let i = 0; i < this.inodeCount; i++) {
2167
- const inode = this.readInode(i);
2168
- if (inode.type === INODE_TYPE.FREE) continue;
2169
- const path = this.readPath(inode.pathOffset, inode.pathLength);
2224
+ const off = i * INODE_SIZE;
2225
+ const type = inodeView.getUint8(off + INODE.TYPE);
2226
+ if (type === INODE_TYPE.FREE) continue;
2227
+ const inode = {
2228
+ type,
2229
+ pathOffset: inodeView.getUint32(off + INODE.PATH_OFFSET, true),
2230
+ pathLength: inodeView.getUint16(off + INODE.PATH_LENGTH, true),
2231
+ mode: inodeView.getUint32(off + INODE.MODE, true),
2232
+ size: inodeView.getFloat64(off + INODE.SIZE, true),
2233
+ firstBlock: inodeView.getUint32(off + INODE.FIRST_BLOCK, true),
2234
+ blockCount: inodeView.getUint32(off + INODE.BLOCK_COUNT, true),
2235
+ mtime: inodeView.getFloat64(off + INODE.MTIME, true),
2236
+ ctime: inodeView.getFloat64(off + INODE.CTIME, true),
2237
+ atime: inodeView.getFloat64(off + INODE.ATIME, true),
2238
+ uid: inodeView.getUint32(off + INODE.UID, true),
2239
+ gid: inodeView.getUint32(off + INODE.GID, true)
2240
+ };
2241
+ this.inodeCache.set(i, inode);
2242
+ const path = pathBuf ? decoder8.decode(pathBuf.subarray(inode.pathOffset, inode.pathOffset + inode.pathLength)) : this.readPath(inode.pathOffset, inode.pathLength);
2170
2243
  this.pathIndex.set(path, i);
2171
2244
  }
2172
2245
  }
@@ -2307,6 +2380,7 @@ var VFSEngine = class {
2307
2380
  this.superblockDirty = true;
2308
2381
  return start;
2309
2382
  }
2383
+ blocksFreedsinceTrim = false;
2310
2384
  freeBlockRange(start, count) {
2311
2385
  if (count === 0) return;
2312
2386
  const bitmap = this.bitmap;
@@ -2318,6 +2392,7 @@ var VFSEngine = class {
2318
2392
  this.markBitmapDirty(start >>> 3, start + count - 1 >>> 3);
2319
2393
  this.freeBlocks += count;
2320
2394
  this.superblockDirty = true;
2395
+ this.blocksFreedsinceTrim = true;
2321
2396
  }
2322
2397
  // updateSuperblockFreeBlocks is no longer needed — superblock writes are coalesced via commitPending()
2323
2398
  // ========== Inode allocation ==========