@componentor/fs 2.0.7 → 2.0.9

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
@@ -448,14 +448,12 @@ Another tab or operation has the file open. The library uses `navigator.locks` t
448
448
 
449
449
  ### v2.0.7 (2025)
450
450
 
451
- **Smart Handle Caching with `readwrite-unsafe`:**
451
+ **High-Performance Handle Caching with `readwrite-unsafe`:**
452
452
  - Uses `readwrite-unsafe` mode (Chrome 121+) - no exclusive locks
453
+ - Zero per-operation overhead: cache lookup is a single Map.get()
453
454
  - Browser extensions can access files while handles are cached
454
- - Access-time based cleanup: handles released after 30s of inactivity
455
- - Accessing a cached handle renews its timeout (stays cached during active use)
456
- - Active operation protection: handles in use are never released
457
455
  - LRU eviction when cache exceeds 100 handles
458
- - Falls back to 100ms debounced release on older browsers
456
+ - Falls back to 100ms debounced release on older browsers (handles block)
459
457
 
460
458
  ### v2.0.2 (2025)
461
459
 
package/dist/index.cjs CHANGED
@@ -434,111 +434,75 @@ const dirCache = new Map();
434
434
  // Uses readwrite-unsafe mode when available (no exclusive lock, allows external access)
435
435
  // Falls back to readwrite with debounced release for older browsers
436
436
  const syncHandleCache = new Map();
437
- const syncHandleLastAccess = new Map();
438
- const syncHandleActiveOps = new Map(); // Track active operations per handle
439
437
  const MAX_HANDLES = 100;
440
438
 
441
439
  // Track if readwrite-unsafe mode is supported (detected on first use)
442
440
  let unsafeModeSupported = null;
443
441
 
444
- // Handle release timing:
445
- // - readwrite-unsafe: 30s idle timeout (no blocking, just memory management)
446
- // - readwrite (fallback): 100ms debounce (need to release locks quickly)
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
+ // Handle release timing
449
+ // - Legacy mode (readwrite): 100ms delay (handles block, release ASAP)
450
+ // - Unsafe mode (readwrite-unsafe): 500ms delay (handles don't block each other,
451
+ // but DO block external tools using default mode like OPFS Explorer)
447
452
  let releaseTimer = null;
448
- const UNSAFE_IDLE_TIMEOUT = 30000;
449
453
  const LEGACY_RELEASE_DELAY = 100;
454
+ const UNSAFE_RELEASE_DELAY = 500;
450
455
 
451
- // Track active operations to prevent releasing handles in use
452
- function beginHandleOp(path) {
453
- syncHandleActiveOps.set(path, (syncHandleActiveOps.get(path) || 0) + 1);
454
- }
456
+ function scheduleHandleRelease() {
457
+ if (releaseTimer) return; // Already scheduled
455
458
 
456
- function endHandleOp(path) {
457
- const count = syncHandleActiveOps.get(path) || 0;
458
- if (count <= 1) {
459
- syncHandleActiveOps.delete(path);
460
- } else {
461
- syncHandleActiveOps.set(path, count - 1);
462
- }
463
- }
459
+ const delay = unsafeModeSupported ? UNSAFE_RELEASE_DELAY : LEGACY_RELEASE_DELAY;
464
460
 
465
- function isHandleInUse(path) {
466
- return (syncHandleActiveOps.get(path) || 0) > 0;
467
- }
468
-
469
- function scheduleHandleRelease() {
470
- if (releaseTimer) {
471
- clearTimeout(releaseTimer);
472
- }
461
+ releaseTimer = setTimeout(() => {
462
+ releaseTimer = null;
463
+ const count = syncHandleCache.size;
464
+ if (count === 0) return;
473
465
 
474
- if (unsafeModeSupported) {
475
- // readwrite-unsafe: clean up handles idle for 30s
476
- releaseTimer = setTimeout(() => {
477
- releaseTimer = null;
478
- const now = Date.now();
479
- for (const [path, lastAccess] of syncHandleLastAccess) {
480
- // Skip handles that are currently in use
481
- if (isHandleInUse(path)) continue;
482
- if (now - lastAccess >= UNSAFE_IDLE_TIMEOUT) {
483
- const h = syncHandleCache.get(path);
484
- if (h) { try { h.flush(); h.close(); } catch {} }
485
- syncHandleCache.delete(path);
486
- syncHandleLastAccess.delete(path);
487
- }
488
- }
489
- // Reschedule if there are still handles
490
- if (syncHandleCache.size > 0) {
491
- scheduleHandleRelease();
492
- }
493
- }, UNSAFE_IDLE_TIMEOUT);
494
- } else {
495
- // Legacy readwrite: release all handles after 100ms idle
496
- releaseTimer = setTimeout(() => {
497
- releaseTimer = null;
498
- if (syncHandleCache.size > 0) {
499
- for (const [path, h] of syncHandleCache) {
500
- // Skip handles that are currently in use
501
- if (isHandleInUse(path)) continue;
502
- try { h.flush(); h.close(); } catch {}
503
- syncHandleCache.delete(path);
504
- syncHandleLastAccess.delete(path);
505
- }
506
- }
507
- }, LEGACY_RELEASE_DELAY);
508
- }
466
+ for (const h of syncHandleCache.values()) {
467
+ try { h.flush(); h.close(); } catch {}
468
+ }
469
+ syncHandleCache.clear();
470
+ trace('Released ' + count + ' handles (' + (unsafeModeSupported ? 'unsafe' : 'legacy') + ' mode, ' + delay + 'ms delay)');
471
+ }, delay);
509
472
  }
510
473
 
511
474
  async function getSyncHandle(filePath, create) {
512
475
  const cached = syncHandleCache.get(filePath);
513
476
  if (cached) {
514
- // Renew last access time
515
- syncHandleLastAccess.set(filePath, Date.now());
477
+ trace('Handle cache HIT: ' + filePath);
516
478
  return cached;
517
479
  }
518
480
 
519
481
  // Evict oldest handles if cache is full
520
482
  if (syncHandleCache.size >= MAX_HANDLES) {
521
483
  const keys = Array.from(syncHandleCache.keys()).slice(0, 10);
484
+ trace('LRU evicting ' + keys.length + ' handles');
522
485
  for (const key of keys) {
523
486
  const h = syncHandleCache.get(key);
524
- if (h) { try { h.close(); } catch {} syncHandleCache.delete(key); syncHandleLastAccess.delete(key); }
487
+ if (h) { try { h.close(); } catch {} syncHandleCache.delete(key); }
525
488
  }
526
489
  }
527
490
 
528
491
  const fh = await getFileHandle(filePath, create);
529
492
 
530
493
  // Try readwrite-unsafe mode first (no exclusive lock, Chrome 121+)
531
- // Falls back to readwrite if not supported
532
494
  let access;
533
495
  if (unsafeModeSupported === null) {
534
496
  // First time - detect support
535
497
  try {
536
498
  access = await fh.createSyncAccessHandle({ mode: 'readwrite-unsafe' });
537
499
  unsafeModeSupported = true;
500
+ trace('readwrite-unsafe mode SUPPORTED - handles won\\'t block');
538
501
  } catch {
539
502
  // Not supported, use default mode
540
503
  access = await fh.createSyncAccessHandle();
541
504
  unsafeModeSupported = false;
505
+ trace('readwrite-unsafe mode NOT supported - using legacy mode');
542
506
  }
543
507
  } else if (unsafeModeSupported) {
544
508
  access = await fh.createSyncAccessHandle({ mode: 'readwrite-unsafe' });
@@ -547,13 +511,17 @@ async function getSyncHandle(filePath, create) {
547
511
  }
548
512
 
549
513
  syncHandleCache.set(filePath, access);
550
- syncHandleLastAccess.set(filePath, Date.now());
514
+ trace('Handle ACQUIRED: ' + filePath + ' (cache size: ' + syncHandleCache.size + ')');
551
515
  return access;
552
516
  }
553
517
 
554
518
  function closeSyncHandle(filePath) {
555
519
  const h = syncHandleCache.get(filePath);
556
- if (h) { try { h.close(); } catch {} syncHandleCache.delete(filePath); syncHandleLastAccess.delete(filePath); }
520
+ if (h) {
521
+ try { h.close(); } catch {}
522
+ syncHandleCache.delete(filePath);
523
+ trace('Handle RELEASED: ' + filePath);
524
+ }
557
525
  }
558
526
 
559
527
  function closeHandlesUnder(prefix) {
@@ -561,7 +529,6 @@ function closeHandlesUnder(prefix) {
561
529
  if (p === prefix || p.startsWith(prefix + '/')) {
562
530
  try { h.close(); } catch {}
563
531
  syncHandleCache.delete(p);
564
- syncHandleLastAccess.delete(p);
565
532
  }
566
533
  }
567
534
  }
@@ -636,17 +603,12 @@ async function getParentAndName(filePath) {
636
603
 
637
604
  async function handleRead(filePath, payload) {
638
605
  const access = await getSyncHandle(filePath, false);
639
- beginHandleOp(filePath);
640
- try {
641
- const size = access.getSize();
642
- const offset = payload?.offset || 0;
643
- const len = payload?.len || (size - offset);
644
- const buf = new Uint8Array(len);
645
- const bytesRead = access.read(buf, { at: offset });
646
- return { data: buf.slice(0, bytesRead) };
647
- } finally {
648
- endHandleOp(filePath);
649
- }
606
+ const size = access.getSize();
607
+ const offset = payload?.offset || 0;
608
+ const len = payload?.len || (size - offset);
609
+ const buf = new Uint8Array(len);
610
+ const bytesRead = access.read(buf, { at: offset });
611
+ return { data: buf.slice(0, bytesRead) };
650
612
  }
651
613
 
652
614
  // Non-blocking read using getFile() - does NOT lock the file
@@ -682,57 +644,39 @@ function handleReleaseHandle(filePath) {
682
644
 
683
645
  // Force release ALL file handles - use before HMR notifications
684
646
  function handleReleaseAllHandles() {
685
- for (const [p, h] of syncHandleCache) {
647
+ for (const h of syncHandleCache.values()) {
686
648
  try { h.close(); } catch {}
687
649
  }
688
650
  syncHandleCache.clear();
689
- syncHandleLastAccess.clear();
690
- syncHandleActiveOps.clear();
691
651
  return { success: true };
692
652
  }
693
653
 
694
654
  async function handleWrite(filePath, payload) {
695
655
  const access = await getSyncHandle(filePath, true);
696
- beginHandleOp(filePath);
697
- try {
698
- if (payload?.data) {
699
- const offset = payload.offset ?? 0;
700
- if (offset === 0) access.truncate(0);
701
- access.write(payload.data, { at: offset });
702
- // Only flush if explicitly requested (default: true for safety)
703
- if (payload?.flush !== false) access.flush();
704
- }
705
- return { success: true };
706
- } finally {
707
- endHandleOp(filePath);
656
+ if (payload?.data) {
657
+ const offset = payload.offset ?? 0;
658
+ if (offset === 0) access.truncate(0);
659
+ access.write(payload.data, { at: offset });
660
+ if (payload?.flush !== false) access.flush();
708
661
  }
662
+ return { success: true };
709
663
  }
710
664
 
711
665
  async function handleAppend(filePath, payload) {
712
666
  const access = await getSyncHandle(filePath, true);
713
- beginHandleOp(filePath);
714
- try {
715
- if (payload?.data) {
716
- const size = access.getSize();
717
- access.write(payload.data, { at: size });
718
- if (payload?.flush !== false) access.flush();
719
- }
720
- return { success: true };
721
- } finally {
722
- endHandleOp(filePath);
667
+ if (payload?.data) {
668
+ const size = access.getSize();
669
+ access.write(payload.data, { at: size });
670
+ if (payload?.flush !== false) access.flush();
723
671
  }
672
+ return { success: true };
724
673
  }
725
674
 
726
675
  async function handleTruncate(filePath, payload) {
727
676
  const access = await getSyncHandle(filePath, false);
728
- beginHandleOp(filePath);
729
- try {
730
- access.truncate(payload?.len ?? 0);
731
- access.flush();
732
- return { success: true };
733
- } finally {
734
- endHandleOp(filePath);
735
- }
677
+ access.truncate(payload?.len ?? 0);
678
+ access.flush();
679
+ return { success: true };
736
680
  }
737
681
 
738
682
  async function handleStat(filePath) {
@@ -901,18 +845,14 @@ function handleFlush() {
901
845
  }
902
846
 
903
847
  function handlePurge() {
904
- // Cancel any pending release timer
905
848
  if (releaseTimer) {
906
849
  clearTimeout(releaseTimer);
907
850
  releaseTimer = null;
908
851
  }
909
- // Flush and close all cached sync handles
910
- for (const [, handle] of syncHandleCache) {
852
+ for (const handle of syncHandleCache.values()) {
911
853
  try { handle.flush(); handle.close(); } catch {}
912
854
  }
913
855
  syncHandleCache.clear();
914
- syncHandleLastAccess.clear();
915
- syncHandleActiveOps.clear();
916
856
  dirCache.clear();
917
857
  cachedRoot = null;
918
858
  return { success: true };
@@ -938,6 +878,10 @@ async function processMessage(msg) {
938
878
  case 'purge': return handlePurge();
939
879
  case 'releaseHandle': return handleReleaseHandle(path);
940
880
  case 'releaseAllHandles': return handleReleaseAllHandles();
881
+ case 'setDebug':
882
+ debugTrace = !!payload?.enabled;
883
+ trace('Debug tracing ' + (debugTrace ? 'ENABLED' : 'DISABLED') + ', unsafeMode: ' + unsafeModeSupported);
884
+ return { success: true, debugTrace, unsafeModeSupported, cacheSize: syncHandleCache.size };
941
885
  default: throw new Error('Unknown operation: ' + type);
942
886
  }
943
887
  }
@@ -1006,22 +950,27 @@ async function handleMessage(msg) {
1006
950
  }
1007
951
  }
1008
952
 
1009
- // Process queued messages after ready
1010
- function processQueue() {
1011
- while (messageQueue.length > 0) {
953
+ // Process queued messages with concurrency limit
954
+ // Allows multiple operations to run in parallel but prevents overwhelming the worker
955
+ const MAX_CONCURRENT = 8;
956
+ let activeOperations = 0;
957
+
958
+ async function processQueue() {
959
+ while (messageQueue.length > 0 && activeOperations < MAX_CONCURRENT) {
1012
960
  const msg = messageQueue.shift();
1013
- handleMessage(msg);
961
+ activeOperations++;
962
+ handleMessage(msg).finally(() => {
963
+ activeOperations--;
964
+ processQueue(); // Process next queued message
965
+ });
1014
966
  }
1015
967
  }
1016
968
 
1017
- // Handle messages directly - no serialization needed because:
1018
- // - Tier 2: Client awaits response before sending next message
1019
- // - Each OPFSFileSystem instance has its own worker
969
+ // Queue messages and process with controlled concurrency
1020
970
  self.onmessage = (event) => {
971
+ messageQueue.push(event.data);
1021
972
  if (isReady) {
1022
- handleMessage(event.data);
1023
- } else {
1024
- messageQueue.push(event.data);
973
+ processQueue();
1025
974
  }
1026
975
  };
1027
976
 
@@ -1794,6 +1743,24 @@ var OPFSFileSystem = class _OPFSFileSystem {
1794
1743
  this.syncCall("purge", "/");
1795
1744
  this.statCache.clear();
1796
1745
  }
1746
+ /**
1747
+ * Enable or disable debug tracing for handle operations.
1748
+ * When enabled, logs handle cache hits, acquisitions, releases, and mode information.
1749
+ * @param enabled - Whether to enable debug tracing
1750
+ * @returns Debug state information including unsafeModeSupported and cache size
1751
+ */
1752
+ setDebugSync(enabled) {
1753
+ return this.syncCall("setDebug", "/", { enabled });
1754
+ }
1755
+ /**
1756
+ * Enable or disable debug tracing for handle operations (async version).
1757
+ * When enabled, logs handle cache hits, acquisitions, releases, and mode information.
1758
+ * @param enabled - Whether to enable debug tracing
1759
+ * @returns Debug state information including unsafeModeSupported and cache size
1760
+ */
1761
+ async setDebug(enabled) {
1762
+ return this.asyncCall("setDebug", "/", { enabled });
1763
+ }
1797
1764
  accessSync(filePath, _mode) {
1798
1765
  const exists = this.existsSync(filePath);
1799
1766
  if (!exists) {