@componentor/fs 1.2.6 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@componentor/fs",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "A blazing-fast, Node.js-compatible filesystem for the browser using OPFS",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,58 @@ export interface GetHandleOptions {
16
16
  const FILE_HANDLE_POOL_SIZE = 50
17
17
  const DIR_CACHE_MAX_SIZE = 200
18
18
 
19
+ /**
20
+ * Simple in-memory lock for preventing concurrent sync access handle creation
21
+ * within the same JavaScript context. This is needed because sync access handles
22
+ * are exclusive per file - only one can exist at a time.
23
+ *
24
+ * Optimized for the uncontended case (no Promise creation when lock is free).
25
+ */
26
+ class FileLock {
27
+ private active = new Set<string>()
28
+ private queues = new Map<string, Array<() => void>>()
29
+
30
+ async acquire(path: string): Promise<() => void> {
31
+ if (!this.active.has(path)) {
32
+ // Fast path: no contention, just mark as active
33
+ this.active.add(path)
34
+ return this.createRelease(path)
35
+ }
36
+
37
+ // Slow path: wait in queue
38
+ await new Promise<void>(resolve => {
39
+ let queue = this.queues.get(path)
40
+ if (!queue) {
41
+ queue = []
42
+ this.queues.set(path, queue)
43
+ }
44
+ queue.push(resolve)
45
+ })
46
+
47
+ return this.createRelease(path)
48
+ }
49
+
50
+ private createRelease(path: string): () => void {
51
+ return () => {
52
+ const queue = this.queues.get(path)
53
+ if (queue && queue.length > 0) {
54
+ // Pass ownership to next waiter
55
+ const next = queue.shift()!
56
+ if (queue.length === 0) {
57
+ this.queues.delete(path)
58
+ }
59
+ next()
60
+ } else {
61
+ // No waiters, release the lock
62
+ this.active.delete(path)
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Global file lock instance for sync access handle serialization */
69
+ export const fileLock = new FileLock()
70
+
19
71
  /**
20
72
  * Manages OPFS handles with caching for improved performance
21
73
  */
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { HandleManager } from './handle-manager.js'
18
+ import { fileLock } from './handle-manager.js'
18
19
  import { createECORRUPTED } from './errors.js'
19
20
 
20
21
  // ============ Compression ============
@@ -173,38 +174,43 @@ export class PackedStorage {
173
174
  }
174
175
 
175
176
  if (this.useSync) {
176
- const access = await fileHandle.createSyncAccessHandle()
177
+ const release = await fileLock.acquire(PACK_FILE)
177
178
  try {
178
- const size = access.getSize()
179
- if (size < 8) {
180
- return {}
181
- }
179
+ const access = await fileHandle.createSyncAccessHandle()
180
+ try {
181
+ const size = access.getSize()
182
+ if (size < 8) {
183
+ return {}
184
+ }
182
185
 
183
- // Read header: index length + CRC32
184
- const header = new Uint8Array(8)
185
- access.read(header, { at: 0 })
186
- const view = new DataView(header.buffer)
187
- const indexLen = view.getUint32(0, true)
188
- const storedCrc = view.getUint32(4, true)
189
-
190
- // Read everything after header (index + data) for CRC verification
191
- const contentSize = size - 8
192
- const content = new Uint8Array(contentSize)
193
- access.read(content, { at: 8 })
194
-
195
- // Verify CRC32 if enabled
196
- if (this.useChecksum && storedCrc !== 0) {
197
- const calculatedCrc = crc32(content)
198
- if (calculatedCrc !== storedCrc) {
199
- throw createECORRUPTED(PACK_FILE)
186
+ // Read header: index length + CRC32
187
+ const header = new Uint8Array(8)
188
+ access.read(header, { at: 0 })
189
+ const view = new DataView(header.buffer)
190
+ const indexLen = view.getUint32(0, true)
191
+ const storedCrc = view.getUint32(4, true)
192
+
193
+ // Read everything after header (index + data) for CRC verification
194
+ const contentSize = size - 8
195
+ const content = new Uint8Array(contentSize)
196
+ access.read(content, { at: 8 })
197
+
198
+ // Verify CRC32 if enabled
199
+ if (this.useChecksum && storedCrc !== 0) {
200
+ const calculatedCrc = crc32(content)
201
+ if (calculatedCrc !== storedCrc) {
202
+ throw createECORRUPTED(PACK_FILE)
203
+ }
200
204
  }
201
- }
202
205
 
203
- // Parse index from content
204
- const indexJson = new TextDecoder().decode(content.subarray(0, indexLen))
205
- return JSON.parse(indexJson)
206
+ // Parse index from content
207
+ const indexJson = new TextDecoder().decode(content.subarray(0, indexLen))
208
+ return JSON.parse(indexJson)
209
+ } finally {
210
+ access.close()
211
+ }
206
212
  } finally {
207
- access.close()
213
+ release()
208
214
  }
209
215
  } else {
210
216
  const file = await fileHandle.getFile()
@@ -268,12 +274,17 @@ export class PackedStorage {
268
274
  let buffer: Uint8Array
269
275
 
270
276
  if (this.useSync) {
271
- const access = await fileHandle.createSyncAccessHandle()
277
+ const release = await fileLock.acquire(PACK_FILE)
272
278
  try {
273
- buffer = new Uint8Array(entry.size)
274
- access.read(buffer, { at: entry.offset })
279
+ const access = await fileHandle.createSyncAccessHandle()
280
+ try {
281
+ buffer = new Uint8Array(entry.size)
282
+ access.read(buffer, { at: entry.offset })
283
+ } finally {
284
+ access.close()
285
+ }
275
286
  } finally {
276
- access.close()
287
+ release()
277
288
  }
278
289
  } else {
279
290
  const file = await fileHandle.getFile()
@@ -325,21 +336,26 @@ export class PackedStorage {
325
336
  const decompressPromises: Array<{ path: string; promise: Promise<Uint8Array> }> = []
326
337
 
327
338
  if (this.useSync) {
328
- const access = await fileHandle.createSyncAccessHandle()
339
+ const release = await fileLock.acquire(PACK_FILE)
329
340
  try {
330
- for (const { path, offset, size, originalSize } of toRead) {
331
- const buffer = new Uint8Array(size)
332
- access.read(buffer, { at: offset })
333
-
334
- if (originalSize !== undefined) {
335
- // Queue for decompression
336
- decompressPromises.push({ path, promise: decompress(buffer) })
337
- } else {
338
- results.set(path, buffer)
341
+ const access = await fileHandle.createSyncAccessHandle()
342
+ try {
343
+ for (const { path, offset, size, originalSize } of toRead) {
344
+ const buffer = new Uint8Array(size)
345
+ access.read(buffer, { at: offset })
346
+
347
+ if (originalSize !== undefined) {
348
+ // Queue for decompression
349
+ decompressPromises.push({ path, promise: decompress(buffer) })
350
+ } else {
351
+ results.set(path, buffer)
352
+ }
339
353
  }
354
+ } finally {
355
+ access.close()
340
356
  }
341
357
  } finally {
342
- access.close()
358
+ release()
343
359
  }
344
360
  } else {
345
361
  const file = await fileHandle.getFile()
@@ -459,12 +475,17 @@ export class PackedStorage {
459
475
  if (!fileHandle) return
460
476
 
461
477
  if (this.useSync) {
462
- const access = await fileHandle.createSyncAccessHandle()
478
+ const release = await fileLock.acquire(PACK_FILE)
463
479
  try {
464
- access.truncate(data.length)
465
- access.write(data, { at: 0 })
480
+ const access = await fileHandle.createSyncAccessHandle()
481
+ try {
482
+ access.truncate(data.length)
483
+ access.write(data, { at: 0 })
484
+ } finally {
485
+ access.close()
486
+ }
466
487
  } finally {
467
- access.close()
488
+ release()
468
489
  }
469
490
  } else {
470
491
  const writable = await fileHandle.createWritable()
@@ -491,48 +512,53 @@ export class PackedStorage {
491
512
  const newIndexBuf = encoder.encode(JSON.stringify(index))
492
513
 
493
514
  if (this.useSync) {
494
- const access = await fileHandle.createSyncAccessHandle()
515
+ const release = await fileLock.acquire(PACK_FILE)
495
516
  try {
496
- const size = access.getSize()
497
-
498
- // Read old header to get old index length
499
- const oldHeader = new Uint8Array(8)
500
- access.read(oldHeader, { at: 0 })
501
- const oldIndexLen = new DataView(oldHeader.buffer).getUint32(0, true)
502
-
503
- // Read data portion (after old index)
504
- const dataStart = 8 + oldIndexLen
505
- const dataSize = size - dataStart
506
- const dataPortion = new Uint8Array(dataSize)
507
- if (dataSize > 0) {
508
- access.read(dataPortion, { at: dataStart })
509
- }
517
+ const access = await fileHandle.createSyncAccessHandle()
518
+ try {
519
+ const size = access.getSize()
510
520
 
511
- // Build new content (new index + data)
512
- const newContent = new Uint8Array(newIndexBuf.length + dataSize)
513
- newContent.set(newIndexBuf, 0)
514
- if (dataSize > 0) {
515
- newContent.set(dataPortion, newIndexBuf.length)
516
- }
521
+ // Read old header to get old index length
522
+ const oldHeader = new Uint8Array(8)
523
+ access.read(oldHeader, { at: 0 })
524
+ const oldIndexLen = new DataView(oldHeader.buffer).getUint32(0, true)
525
+
526
+ // Read data portion (after old index)
527
+ const dataStart = 8 + oldIndexLen
528
+ const dataSize = size - dataStart
529
+ const dataPortion = new Uint8Array(dataSize)
530
+ if (dataSize > 0) {
531
+ access.read(dataPortion, { at: dataStart })
532
+ }
533
+
534
+ // Build new content (new index + data)
535
+ const newContent = new Uint8Array(newIndexBuf.length + dataSize)
536
+ newContent.set(newIndexBuf, 0)
537
+ if (dataSize > 0) {
538
+ newContent.set(dataPortion, newIndexBuf.length)
539
+ }
517
540
 
518
- // Calculate new CRC32 if enabled
519
- const checksum = this.useChecksum ? crc32(newContent) : 0
541
+ // Calculate new CRC32 if enabled
542
+ const checksum = this.useChecksum ? crc32(newContent) : 0
520
543
 
521
- // Build new header
522
- const newHeader = new Uint8Array(8)
523
- const view = new DataView(newHeader.buffer)
524
- view.setUint32(0, newIndexBuf.length, true)
525
- view.setUint32(4, checksum, true)
544
+ // Build new header
545
+ const newHeader = new Uint8Array(8)
546
+ const view = new DataView(newHeader.buffer)
547
+ view.setUint32(0, newIndexBuf.length, true)
548
+ view.setUint32(4, checksum, true)
526
549
 
527
- // Write new file
528
- const newFile = new Uint8Array(8 + newContent.length)
529
- newFile.set(newHeader, 0)
530
- newFile.set(newContent, 8)
550
+ // Write new file
551
+ const newFile = new Uint8Array(8 + newContent.length)
552
+ newFile.set(newHeader, 0)
553
+ newFile.set(newContent, 8)
531
554
 
532
- access.truncate(newFile.length)
533
- access.write(newFile, { at: 0 })
555
+ access.truncate(newFile.length)
556
+ access.write(newFile, { at: 0 })
557
+ } finally {
558
+ access.close()
559
+ }
534
560
  } finally {
535
- access.close()
561
+ release()
536
562
  }
537
563
  } else {
538
564
  // For non-sync, rewrite the whole file
@@ -1,5 +1,6 @@
1
1
  import type { SymlinkCache, SymlinkDefinition } from './types.js'
2
2
  import type { HandleManager } from './handle-manager.js'
3
+ import { fileLock } from './handle-manager.js'
3
4
  import { normalize } from './path-utils.js'
4
5
  import { createELOOP, createEINVAL, createEEXIST } from './errors.js'
5
6
 
@@ -101,15 +102,20 @@ export class SymlinkManager {
101
102
  const buffer = new TextEncoder().encode(data)
102
103
 
103
104
  if (this.useSync) {
104
- const access = await fileHandle.createSyncAccessHandle()
105
+ const release = await fileLock.acquire(SYMLINK_FILE)
105
106
  try {
106
- access.truncate(0)
107
- let written = 0
108
- while (written < buffer.length) {
109
- written += access.write(buffer.subarray(written), { at: written })
107
+ const access = await fileHandle.createSyncAccessHandle()
108
+ try {
109
+ access.truncate(0)
110
+ let written = 0
111
+ while (written < buffer.length) {
112
+ written += access.write(buffer.subarray(written), { at: written })
113
+ }
114
+ } finally {
115
+ access.close()
110
116
  }
111
117
  } finally {
112
- access.close()
118
+ release()
113
119
  }
114
120
  } else {
115
121
  const writable = await fileHandle.createWritable()