@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 +3 -5
- package/dist/index.cjs +94 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +94 -127
- package/dist/index.js.map +1 -1
- package/dist/kernel.js +69 -111
- package/dist/kernel.js.map +1 -1
- package/package.json +1 -1
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
|
-
**
|
|
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
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
syncHandleActiveOps.set(path, (syncHandleActiveOps.get(path) || 0) + 1);
|
|
454
|
-
}
|
|
456
|
+
function scheduleHandleRelease() {
|
|
457
|
+
if (releaseTimer) return; // Already scheduled
|
|
455
458
|
|
|
456
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
if (
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
961
|
+
activeOperations++;
|
|
962
|
+
handleMessage(msg).finally(() => {
|
|
963
|
+
activeOperations--;
|
|
964
|
+
processQueue(); // Process next queued message
|
|
965
|
+
});
|
|
1014
966
|
}
|
|
1015
967
|
}
|
|
1016
968
|
|
|
1017
|
-
//
|
|
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
|
-
|
|
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) {
|