@componentor/fs 2.0.6 → 2.0.8

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,20 +446,14 @@ Another tab or operation has the file open. The library uses `navigator.locks` t
446
446
 
447
447
  ## Changelog
448
448
 
449
- ### v2.0.5 (2025)
450
-
451
- **Bug Fixes:**
452
- - Fixed idle timeout condition (`>` to `>=`) - handles now release at exactly 2s instead of ~4s
453
-
454
- ### v2.0.4 (2025)
455
-
456
- **Bug Fixes:**
457
- - Handle idle timeout now works for both Tier 1 and Tier 2
458
- - Previously only Tier 1 kernel had idle release; Tier 2 kernel now also releases handles after 2s
459
-
460
- ### v2.0.3 (2025)
461
- - Reduced handle idle timeout from 5s to 2s for faster external tool access
462
- - Added tests verifying handles are properly released after idle timeout
449
+ ### v2.0.7 (2025)
450
+
451
+ **High-Performance Handle Caching with `readwrite-unsafe`:**
452
+ - Uses `readwrite-unsafe` mode (Chrome 121+) - no exclusive locks
453
+ - Zero per-operation overhead: cache lookup is a single Map.get()
454
+ - Browser extensions can access files while handles are cached
455
+ - LRU eviction when cache exceeds 100 handles
456
+ - Falls back to 100ms debounced release on older browsers (handles block)
463
457
 
464
458
  ### v2.0.2 (2025)
465
459
 
package/dist/index.cjs CHANGED
@@ -430,57 +430,90 @@ let isReady = false;
430
430
  let cachedRoot = null;
431
431
  const dirCache = new Map();
432
432
 
433
- // Sync handle cache - MAJOR performance optimization
434
- // Handles auto-release after idle timeout to allow external tools to access files
433
+ // Sync handle cache - MAJOR performance optimization (2-5x speedup)
434
+ // Uses readwrite-unsafe mode when available (no exclusive lock, allows external access)
435
+ // Falls back to readwrite with debounced release for older browsers
435
436
  const syncHandleCache = new Map();
436
- const syncHandleLastAccess = new Map();
437
437
  const MAX_HANDLES = 100;
438
- const HANDLE_IDLE_TIMEOUT = 2000;
439
- let idleCleanupTimer = null;
440
438
 
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);
439
+ // Track if readwrite-unsafe mode is supported (detected on first use)
440
+ let unsafeModeSupported = null;
441
+
442
+ // Debug tracing - set via 'setDebug' message
443
+ let debugTrace = false;
444
+ function trace(...args) {
445
+ if (debugTrace) console.log('[OPFS-T2]', ...args);
446
+ }
447
+
448
+ // Minimal timer for legacy mode only
449
+ let releaseTimer = null;
450
+ const LEGACY_RELEASE_DELAY = 100;
451
+
452
+ function scheduleHandleRelease() {
453
+ if (unsafeModeSupported) return; // No release needed for readwrite-unsafe
454
+ if (releaseTimer) return; // Already scheduled
455
+ releaseTimer = setTimeout(() => {
456
+ releaseTimer = null;
457
+ const count = syncHandleCache.size;
458
+ for (const h of syncHandleCache.values()) {
459
+ try { h.flush(); h.close(); } catch {}
460
+ }
461
+ syncHandleCache.clear();
462
+ trace('Released ' + count + ' handles (legacy mode debounce)');
463
+ }, LEGACY_RELEASE_DELAY);
455
464
  }
456
465
 
457
466
  async function getSyncHandle(filePath, create) {
458
467
  const cached = syncHandleCache.get(filePath);
459
468
  if (cached) {
460
- syncHandleLastAccess.set(filePath, Date.now());
469
+ trace('Handle cache HIT: ' + filePath);
461
470
  return cached;
462
471
  }
463
472
 
464
473
  // Evict oldest handles if cache is full
465
474
  if (syncHandleCache.size >= MAX_HANDLES) {
466
475
  const keys = Array.from(syncHandleCache.keys()).slice(0, 10);
476
+ trace('LRU evicting ' + keys.length + ' handles');
467
477
  for (const key of keys) {
468
478
  const h = syncHandleCache.get(key);
469
- if (h) { try { h.close(); } catch {} syncHandleCache.delete(key); syncHandleLastAccess.delete(key); }
479
+ if (h) { try { h.close(); } catch {} syncHandleCache.delete(key); }
470
480
  }
471
481
  }
472
482
 
473
483
  const fh = await getFileHandle(filePath, create);
474
- const access = await fh.createSyncAccessHandle();
484
+
485
+ // Try readwrite-unsafe mode first (no exclusive lock, Chrome 121+)
486
+ let access;
487
+ if (unsafeModeSupported === null) {
488
+ // First time - detect support
489
+ try {
490
+ access = await fh.createSyncAccessHandle({ mode: 'readwrite-unsafe' });
491
+ unsafeModeSupported = true;
492
+ trace('readwrite-unsafe mode SUPPORTED - handles won\\'t block');
493
+ } catch {
494
+ // Not supported, use default mode
495
+ access = await fh.createSyncAccessHandle();
496
+ unsafeModeSupported = false;
497
+ trace('readwrite-unsafe mode NOT supported - using legacy mode');
498
+ }
499
+ } else if (unsafeModeSupported) {
500
+ access = await fh.createSyncAccessHandle({ mode: 'readwrite-unsafe' });
501
+ } else {
502
+ access = await fh.createSyncAccessHandle();
503
+ }
504
+
475
505
  syncHandleCache.set(filePath, access);
476
- syncHandleLastAccess.set(filePath, Date.now());
477
- scheduleIdleCleanup();
506
+ trace('Handle ACQUIRED: ' + filePath + ' (cache size: ' + syncHandleCache.size + ')');
478
507
  return access;
479
508
  }
480
509
 
481
510
  function closeSyncHandle(filePath) {
482
511
  const h = syncHandleCache.get(filePath);
483
- if (h) { try { h.close(); } catch {} syncHandleCache.delete(filePath); syncHandleLastAccess.delete(filePath); }
512
+ if (h) {
513
+ try { h.close(); } catch {}
514
+ syncHandleCache.delete(filePath);
515
+ trace('Handle RELEASED: ' + filePath);
516
+ }
484
517
  }
485
518
 
486
519
  function closeHandlesUnder(prefix) {
@@ -488,7 +521,6 @@ function closeHandlesUnder(prefix) {
488
521
  if (p === prefix || p.startsWith(prefix + '/')) {
489
522
  try { h.close(); } catch {}
490
523
  syncHandleCache.delete(p);
491
- syncHandleLastAccess.delete(p);
492
524
  }
493
525
  }
494
526
  }
@@ -604,11 +636,10 @@ function handleReleaseHandle(filePath) {
604
636
 
605
637
  // Force release ALL file handles - use before HMR notifications
606
638
  function handleReleaseAllHandles() {
607
- for (const [p, h] of syncHandleCache) {
639
+ for (const h of syncHandleCache.values()) {
608
640
  try { h.close(); } catch {}
609
641
  }
610
642
  syncHandleCache.clear();
611
- syncHandleLastAccess.clear();
612
643
  return { success: true };
613
644
  }
614
645
 
@@ -618,7 +649,6 @@ async function handleWrite(filePath, payload) {
618
649
  const offset = payload.offset ?? 0;
619
650
  if (offset === 0) access.truncate(0);
620
651
  access.write(payload.data, { at: offset });
621
- // Only flush if explicitly requested (default: true for safety)
622
652
  if (payload?.flush !== false) access.flush();
623
653
  }
624
654
  return { success: true };
@@ -807,8 +837,11 @@ function handleFlush() {
807
837
  }
808
838
 
809
839
  function handlePurge() {
810
- // Flush and close all cached sync handles
811
- for (const [, handle] of syncHandleCache) {
840
+ if (releaseTimer) {
841
+ clearTimeout(releaseTimer);
842
+ releaseTimer = null;
843
+ }
844
+ for (const handle of syncHandleCache.values()) {
812
845
  try { handle.flush(); handle.close(); } catch {}
813
846
  }
814
847
  syncHandleCache.clear();
@@ -837,6 +870,10 @@ async function processMessage(msg) {
837
870
  case 'purge': return handlePurge();
838
871
  case 'releaseHandle': return handleReleaseHandle(path);
839
872
  case 'releaseAllHandles': return handleReleaseAllHandles();
873
+ case 'setDebug':
874
+ debugTrace = !!payload?.enabled;
875
+ trace('Debug tracing ' + (debugTrace ? 'ENABLED' : 'DISABLED') + ', unsafeMode: ' + unsafeModeSupported);
876
+ return { success: true, debugTrace, unsafeModeSupported, cacheSize: syncHandleCache.size };
840
877
  default: throw new Error('Unknown operation: ' + type);
841
878
  }
842
879
  }
@@ -899,6 +936,9 @@ async function handleMessage(msg) {
899
936
  } else {
900
937
  self.postMessage({ id, error: errorCode, code: errorCode });
901
938
  }
939
+ } finally {
940
+ // Schedule handle release (debounced - waits 100ms after last operation)
941
+ scheduleHandleRelease();
902
942
  }
903
943
  }
904
944
 
@@ -1690,6 +1730,24 @@ var OPFSFileSystem = class _OPFSFileSystem {
1690
1730
  this.syncCall("purge", "/");
1691
1731
  this.statCache.clear();
1692
1732
  }
1733
+ /**
1734
+ * Enable or disable debug tracing for handle operations.
1735
+ * When enabled, logs handle cache hits, acquisitions, releases, and mode information.
1736
+ * @param enabled - Whether to enable debug tracing
1737
+ * @returns Debug state information including unsafeModeSupported and cache size
1738
+ */
1739
+ setDebugSync(enabled) {
1740
+ return this.syncCall("setDebug", "/", { enabled });
1741
+ }
1742
+ /**
1743
+ * Enable or disable debug tracing for handle operations (async version).
1744
+ * When enabled, logs handle cache hits, acquisitions, releases, and mode information.
1745
+ * @param enabled - Whether to enable debug tracing
1746
+ * @returns Debug state information including unsafeModeSupported and cache size
1747
+ */
1748
+ async setDebug(enabled) {
1749
+ return this.asyncCall("setDebug", "/", { enabled });
1750
+ }
1693
1751
  accessSync(filePath, _mode) {
1694
1752
  const exists = this.existsSync(filePath);
1695
1753
  if (!exists) {