@componentor/fs 2.0.5 → 2.0.7

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,15 +446,16 @@ 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
449
+ ### v2.0.7 (2025)
450
+
451
+ **Smart Handle Caching with `readwrite-unsafe`:**
452
+ - Uses `readwrite-unsafe` mode (Chrome 121+) - no exclusive locks
453
+ - 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
+ - LRU eviction when cache exceeds 100 handles
458
+ - Falls back to 100ms debounced release on older browsers
458
459
 
459
460
  ### v2.0.2 (2025)
460
461
 
package/dist/index.cjs CHANGED
@@ -430,33 +430,88 @@ 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
437
  const syncHandleLastAccess = new Map();
438
+ const syncHandleActiveOps = new Map(); // Track active operations per handle
437
439
  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);
440
+
441
+ // Track if readwrite-unsafe mode is supported (detected on first use)
442
+ let unsafeModeSupported = null;
443
+
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)
447
+ let releaseTimer = null;
448
+ const UNSAFE_IDLE_TIMEOUT = 30000;
449
+ const LEGACY_RELEASE_DELAY = 100;
450
+
451
+ // Track active operations to prevent releasing handles in use
452
+ function beginHandleOp(path) {
453
+ syncHandleActiveOps.set(path, (syncHandleActiveOps.get(path) || 0) + 1);
454
+ }
455
+
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
+ }
464
+
465
+ function isHandleInUse(path) {
466
+ return (syncHandleActiveOps.get(path) || 0) > 0;
467
+ }
468
+
469
+ function scheduleHandleRelease() {
470
+ if (releaseTimer) {
471
+ clearTimeout(releaseTimer);
472
+ }
473
+
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
+ }
451
488
  }
452
- }
453
- if (syncHandleCache.size > 0) scheduleIdleCleanup();
454
- }, HANDLE_IDLE_TIMEOUT);
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
+ }
455
509
  }
456
510
 
457
511
  async function getSyncHandle(filePath, create) {
458
512
  const cached = syncHandleCache.get(filePath);
459
513
  if (cached) {
514
+ // Renew last access time
460
515
  syncHandleLastAccess.set(filePath, Date.now());
461
516
  return cached;
462
517
  }
@@ -471,10 +526,28 @@ async function getSyncHandle(filePath, create) {
471
526
  }
472
527
 
473
528
  const fh = await getFileHandle(filePath, create);
474
- const access = await fh.createSyncAccessHandle();
529
+
530
+ // Try readwrite-unsafe mode first (no exclusive lock, Chrome 121+)
531
+ // Falls back to readwrite if not supported
532
+ let access;
533
+ if (unsafeModeSupported === null) {
534
+ // First time - detect support
535
+ try {
536
+ access = await fh.createSyncAccessHandle({ mode: 'readwrite-unsafe' });
537
+ unsafeModeSupported = true;
538
+ } catch {
539
+ // Not supported, use default mode
540
+ access = await fh.createSyncAccessHandle();
541
+ unsafeModeSupported = false;
542
+ }
543
+ } else if (unsafeModeSupported) {
544
+ access = await fh.createSyncAccessHandle({ mode: 'readwrite-unsafe' });
545
+ } else {
546
+ access = await fh.createSyncAccessHandle();
547
+ }
548
+
475
549
  syncHandleCache.set(filePath, access);
476
550
  syncHandleLastAccess.set(filePath, Date.now());
477
- scheduleIdleCleanup();
478
551
  return access;
479
552
  }
480
553
 
@@ -563,12 +636,17 @@ async function getParentAndName(filePath) {
563
636
 
564
637
  async function handleRead(filePath, payload) {
565
638
  const access = await getSyncHandle(filePath, false);
566
- const size = access.getSize();
567
- const offset = payload?.offset || 0;
568
- const len = payload?.len || (size - offset);
569
- const buf = new Uint8Array(len);
570
- const bytesRead = access.read(buf, { at: offset });
571
- return { data: buf.slice(0, bytesRead) };
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
+ }
572
650
  }
573
651
 
574
652
  // Non-blocking read using getFile() - does NOT lock the file
@@ -609,36 +687,52 @@ function handleReleaseAllHandles() {
609
687
  }
610
688
  syncHandleCache.clear();
611
689
  syncHandleLastAccess.clear();
690
+ syncHandleActiveOps.clear();
612
691
  return { success: true };
613
692
  }
614
693
 
615
694
  async function handleWrite(filePath, payload) {
616
695
  const access = await getSyncHandle(filePath, true);
617
- if (payload?.data) {
618
- const offset = payload.offset ?? 0;
619
- if (offset === 0) access.truncate(0);
620
- access.write(payload.data, { at: offset });
621
- // Only flush if explicitly requested (default: true for safety)
622
- if (payload?.flush !== false) access.flush();
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);
623
708
  }
624
- return { success: true };
625
709
  }
626
710
 
627
711
  async function handleAppend(filePath, payload) {
628
712
  const access = await getSyncHandle(filePath, true);
629
- if (payload?.data) {
630
- const size = access.getSize();
631
- access.write(payload.data, { at: size });
632
- if (payload?.flush !== false) access.flush();
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);
633
723
  }
634
- return { success: true };
635
724
  }
636
725
 
637
726
  async function handleTruncate(filePath, payload) {
638
727
  const access = await getSyncHandle(filePath, false);
639
- access.truncate(payload?.len ?? 0);
640
- access.flush();
641
- return { success: true };
728
+ beginHandleOp(filePath);
729
+ try {
730
+ access.truncate(payload?.len ?? 0);
731
+ access.flush();
732
+ return { success: true };
733
+ } finally {
734
+ endHandleOp(filePath);
735
+ }
642
736
  }
643
737
 
644
738
  async function handleStat(filePath) {
@@ -807,11 +901,18 @@ function handleFlush() {
807
901
  }
808
902
 
809
903
  function handlePurge() {
904
+ // Cancel any pending release timer
905
+ if (releaseTimer) {
906
+ clearTimeout(releaseTimer);
907
+ releaseTimer = null;
908
+ }
810
909
  // Flush and close all cached sync handles
811
910
  for (const [, handle] of syncHandleCache) {
812
911
  try { handle.flush(); handle.close(); } catch {}
813
912
  }
814
913
  syncHandleCache.clear();
914
+ syncHandleLastAccess.clear();
915
+ syncHandleActiveOps.clear();
815
916
  dirCache.clear();
816
917
  cachedRoot = null;
817
918
  return { success: true };
@@ -899,6 +1000,9 @@ async function handleMessage(msg) {
899
1000
  } else {
900
1001
  self.postMessage({ id, error: errorCode, code: errorCode });
901
1002
  }
1003
+ } finally {
1004
+ // Schedule handle release (debounced - waits 100ms after last operation)
1005
+ scheduleHandleRelease();
902
1006
  }
903
1007
  }
904
1008