@componentor/fs 2.0.2 → 2.0.5

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
@@ -446,10 +446,20 @@ Another tab or operation has the file open. The library uses `navigator.locks` t
446
446
 
447
447
  ## Changelog
448
448
 
449
+ ### v2.0.4 (2025)
450
+
451
+ **Bug Fixes:**
452
+ - Handle idle timeout now works for both Tier 1 and Tier 2
453
+ - Previously only Tier 1 kernel had idle release; Tier 2 kernel now also releases handles after 2s
454
+
455
+ ### v2.0.3 (2025)
456
+ - Reduced handle idle timeout from 5s to 2s for faster external tool access
457
+ - Added tests verifying handles are properly released after idle timeout
458
+
449
459
  ### v2.0.2 (2025)
450
460
 
451
461
  **Improvements:**
452
- - Sync access handles now auto-release after 5 seconds of inactivity
462
+ - Sync access handles now auto-release after idle timeout
453
463
  - Allows external tools (like OPFS Chrome extension) to access files when idle
454
464
  - Maintains full performance during active operations
455
465
 
package/dist/index.cjs CHANGED
@@ -431,31 +431,56 @@ let cachedRoot = null;
431
431
  const dirCache = new Map();
432
432
 
433
433
  // Sync handle cache - MAJOR performance optimization
434
+ // Handles auto-release after idle timeout to allow external tools to access files
434
435
  const syncHandleCache = new Map();
436
+ const syncHandleLastAccess = new Map();
435
437
  const MAX_HANDLES = 100;
438
+ const HANDLE_IDLE_TIMEOUT = 2000;
439
+ let idleCleanupTimer = null;
440
+
441
+ function scheduleIdleCleanup() {
442
+ if (idleCleanupTimer) return;
443
+ idleCleanupTimer = setTimeout(() => {
444
+ idleCleanupTimer = null;
445
+ const now = Date.now();
446
+ for (const [p, lastAccess] of syncHandleLastAccess) {
447
+ if (now - lastAccess > HANDLE_IDLE_TIMEOUT) {
448
+ const h = syncHandleCache.get(p);
449
+ if (h) { try { h.flush(); h.close(); } catch {} syncHandleCache.delete(p); }
450
+ syncHandleLastAccess.delete(p);
451
+ }
452
+ }
453
+ if (syncHandleCache.size > 0) scheduleIdleCleanup();
454
+ }, HANDLE_IDLE_TIMEOUT);
455
+ }
436
456
 
437
457
  async function getSyncHandle(filePath, create) {
438
458
  const cached = syncHandleCache.get(filePath);
439
- if (cached) return cached;
459
+ if (cached) {
460
+ syncHandleLastAccess.set(filePath, Date.now());
461
+ return cached;
462
+ }
440
463
 
441
464
  // Evict oldest handles if cache is full
442
465
  if (syncHandleCache.size >= MAX_HANDLES) {
443
466
  const keys = Array.from(syncHandleCache.keys()).slice(0, 10);
444
467
  for (const key of keys) {
445
468
  const h = syncHandleCache.get(key);
446
- if (h) { try { h.close(); } catch {} syncHandleCache.delete(key); }
469
+ if (h) { try { h.close(); } catch {} syncHandleCache.delete(key); syncHandleLastAccess.delete(key); }
447
470
  }
448
471
  }
449
472
 
450
473
  const fh = await getFileHandle(filePath, create);
451
474
  const access = await fh.createSyncAccessHandle();
452
475
  syncHandleCache.set(filePath, access);
476
+ syncHandleLastAccess.set(filePath, Date.now());
477
+ scheduleIdleCleanup();
453
478
  return access;
454
479
  }
455
480
 
456
481
  function closeSyncHandle(filePath) {
457
482
  const h = syncHandleCache.get(filePath);
458
- if (h) { try { h.close(); } catch {} syncHandleCache.delete(filePath); }
483
+ if (h) { try { h.close(); } catch {} syncHandleCache.delete(filePath); syncHandleLastAccess.delete(filePath); }
459
484
  }
460
485
 
461
486
  function closeHandlesUnder(prefix) {
@@ -463,6 +488,7 @@ function closeHandlesUnder(prefix) {
463
488
  if (p === prefix || p.startsWith(prefix + '/')) {
464
489
  try { h.close(); } catch {}
465
490
  syncHandleCache.delete(p);
491
+ syncHandleLastAccess.delete(p);
466
492
  }
467
493
  }
468
494
  }
@@ -545,6 +571,47 @@ async function handleRead(filePath, payload) {
545
571
  return { data: buf.slice(0, bytesRead) };
546
572
  }
547
573
 
574
+ // Non-blocking read using getFile() - does NOT lock the file
575
+ // Use this for HMR scenarios where external tools need to modify files
576
+ async function handleReadAsync(filePath, payload) {
577
+ const parts = parsePath(filePath);
578
+ const fileName = parts.pop();
579
+ if (!fileName) throw new Error('Invalid file path');
580
+ const dir = parts.length > 0 ? await getDirectoryHandle(parts, false) : await getRoot();
581
+ const fh = await dir.getFileHandle(fileName);
582
+ const file = await fh.getFile();
583
+
584
+ const offset = payload?.offset || 0;
585
+ const len = payload?.len || (file.size - offset);
586
+
587
+ if (offset === 0 && len === file.size) {
588
+ // Fast path: read entire file
589
+ const buf = new Uint8Array(await file.arrayBuffer());
590
+ return { data: buf };
591
+ }
592
+
593
+ // Partial read using slice
594
+ const slice = file.slice(offset, offset + len);
595
+ const buf = new Uint8Array(await slice.arrayBuffer());
596
+ return { data: buf };
597
+ }
598
+
599
+ // Force release a file handle - allows external tools to modify the file
600
+ function handleReleaseHandle(filePath) {
601
+ closeSyncHandle(filePath);
602
+ return { success: true };
603
+ }
604
+
605
+ // Force release ALL file handles - use before HMR notifications
606
+ function handleReleaseAllHandles() {
607
+ for (const [p, h] of syncHandleCache) {
608
+ try { h.close(); } catch {}
609
+ }
610
+ syncHandleCache.clear();
611
+ syncHandleLastAccess.clear();
612
+ return { success: true };
613
+ }
614
+
548
615
  async function handleWrite(filePath, payload) {
549
616
  const access = await getSyncHandle(filePath, true);
550
617
  if (payload?.data) {
@@ -754,6 +821,7 @@ async function processMessage(msg) {
754
821
  const { type, path, payload } = msg;
755
822
  switch (type) {
756
823
  case 'read': return handleRead(path, payload);
824
+ case 'readAsync': return handleReadAsync(path, payload);
757
825
  case 'write': return handleWrite(path, payload);
758
826
  case 'append': return handleAppend(path, payload);
759
827
  case 'truncate': return handleTruncate(path, payload);
@@ -767,6 +835,8 @@ async function processMessage(msg) {
767
835
  case 'copy': return handleCopy(path, payload);
768
836
  case 'flush': return handleFlush();
769
837
  case 'purge': return handlePurge();
838
+ case 'releaseHandle': return handleReleaseHandle(path);
839
+ case 'releaseAllHandles': return handleReleaseAllHandles();
770
840
  default: throw new Error('Unknown operation: ' + type);
771
841
  }
772
842
  }
@@ -2007,6 +2077,22 @@ var OPFSFileSystem = class _OPFSFileSystem {
2007
2077
  await this.fastCall("purge", "/");
2008
2078
  this.statCache.clear();
2009
2079
  }
2080
+ /**
2081
+ * Release all cached file handles.
2082
+ * Call this before expecting external tools (OPFS Explorer, browser console, etc.)
2083
+ * to modify files. This allows external access without waiting for the idle timeout.
2084
+ * Unlike purge(), this only releases file handles without clearing directory caches.
2085
+ */
2086
+ async releaseAllHandles() {
2087
+ await this.fastCall("releaseAllHandles", "/");
2088
+ }
2089
+ /**
2090
+ * Release a specific file's handle.
2091
+ * Use this when you know a specific file needs to be externally modified.
2092
+ */
2093
+ async releaseHandle(filePath) {
2094
+ await this.fastCall("releaseHandle", filePath);
2095
+ }
2010
2096
  // Constants
2011
2097
  constants = constants;
2012
2098
  // --- FileHandle Implementation ---
@@ -2332,8 +2418,9 @@ var OPFSFileSystem = class _OPFSFileSystem {
2332
2418
  const self2 = this;
2333
2419
  let observer = null;
2334
2420
  let closed = false;
2335
- const callback = (records) => {
2421
+ const callback = async (records) => {
2336
2422
  if (closed) return;
2423
+ await self2.releaseAllHandles();
2337
2424
  for (const record of records) {
2338
2425
  if (record.type === "errored" || record.type === "unknown") continue;
2339
2426
  const filename = record.relativePathComponents.length > 0 ? record.relativePathComponents[record.relativePathComponents.length - 1] : basename(absPath);
@@ -2373,26 +2460,38 @@ var OPFSFileSystem = class _OPFSFileSystem {
2373
2460
  const entries = await this.promises.readdir(absPath);
2374
2461
  const currentEntries = new Set(entries);
2375
2462
  if (lastEntries !== null) {
2463
+ let hasChanges = false;
2464
+ const added = [];
2465
+ const removed = [];
2376
2466
  for (const entry of currentEntries) {
2377
2467
  if (!lastEntries.has(entry)) {
2378
- cb?.("rename", entry);
2468
+ added.push(entry);
2469
+ hasChanges = true;
2379
2470
  }
2380
2471
  }
2381
2472
  for (const entry of lastEntries) {
2382
2473
  if (!currentEntries.has(entry)) {
2383
- cb?.("rename", entry);
2474
+ removed.push(entry);
2475
+ hasChanges = true;
2384
2476
  }
2385
2477
  }
2478
+ if (hasChanges) {
2479
+ await this.releaseAllHandles();
2480
+ for (const entry of added) cb?.("rename", entry);
2481
+ for (const entry of removed) cb?.("rename", entry);
2482
+ }
2386
2483
  }
2387
2484
  lastEntries = currentEntries;
2388
2485
  } else {
2389
2486
  if (lastMtimeMs !== null && stat.mtimeMs !== lastMtimeMs) {
2487
+ await this.releaseAllHandles();
2390
2488
  cb?.("change", basename(absPath));
2391
2489
  }
2392
2490
  lastMtimeMs = stat.mtimeMs;
2393
2491
  }
2394
2492
  } catch {
2395
2493
  if (lastMtimeMs !== null || lastEntries !== null) {
2494
+ await this.releaseAllHandles();
2396
2495
  cb?.("rename", basename(absPath));
2397
2496
  lastMtimeMs = null;
2398
2497
  lastEntries = null;
@@ -2426,6 +2525,7 @@ var OPFSFileSystem = class _OPFSFileSystem {
2426
2525
  const stat = await this.promises.stat(absPath);
2427
2526
  if (lastStat !== null) {
2428
2527
  if (stat.mtimeMs !== lastStat.mtimeMs || stat.size !== lastStat.size) {
2528
+ await this.releaseAllHandles();
2429
2529
  cb?.(stat, lastStat);
2430
2530
  }
2431
2531
  }
@@ -2433,6 +2533,7 @@ var OPFSFileSystem = class _OPFSFileSystem {
2433
2533
  } catch {
2434
2534
  const emptyStat = createStats({ type: "file", size: 0, mtimeMs: 0, mode: 0 });
2435
2535
  if (lastStat !== null) {
2536
+ await this.releaseAllHandles();
2436
2537
  cb?.(emptyStat, lastStat);
2437
2538
  }
2438
2539
  lastStat = emptyStat;
@@ -2441,6 +2542,7 @@ var OPFSFileSystem = class _OPFSFileSystem {
2441
2542
  if (_OPFSFileSystem.hasNativeObserver && cb) {
2442
2543
  const self2 = this;
2443
2544
  const observerCallback = async () => {
2545
+ await self2.releaseAllHandles();
2444
2546
  try {
2445
2547
  const stat = await self2.promises.stat(absPath);
2446
2548
  if (lastStat !== null && (stat.mtimeMs !== lastStat.mtimeMs || stat.size !== lastStat.size)) {