@componentor/fs 1.2.3 → 1.2.5

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.3",
3
+ "version": "1.2.5",
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,70 @@ export interface GetHandleOptions {
16
16
  const FILE_HANDLE_POOL_SIZE = 50
17
17
  const DIR_CACHE_MAX_SIZE = 200
18
18
 
19
+ /**
20
+ * Manages file-level locks to prevent concurrent sync access handle creation.
21
+ * OPFS only allows one sync access handle per file at a time.
22
+ */
23
+ export class FileLockManager {
24
+ private locks: Map<string, Promise<void>> = new Map()
25
+ private lockResolvers: Map<string, () => void> = new Map()
26
+ private waitQueues: Map<string, Array<() => void>> = new Map()
27
+
28
+ /**
29
+ * Acquire an exclusive lock on a file path.
30
+ * If the file is already locked, waits until it's released.
31
+ * Returns a release function that MUST be called when done.
32
+ */
33
+ async acquire(path: string): Promise<() => void> {
34
+ const normalizedPath = normalize(path)
35
+
36
+ // If file is currently locked, wait for it
37
+ while (this.locks.has(normalizedPath)) {
38
+ await this.locks.get(normalizedPath)
39
+ }
40
+
41
+ // Create new lock
42
+ let resolve: () => void
43
+ const lockPromise = new Promise<void>(r => {
44
+ resolve = r
45
+ })
46
+
47
+ this.locks.set(normalizedPath, lockPromise)
48
+ this.lockResolvers.set(normalizedPath, resolve!)
49
+
50
+ // Return release function
51
+ return () => {
52
+ const resolver = this.lockResolvers.get(normalizedPath)
53
+ this.locks.delete(normalizedPath)
54
+ this.lockResolvers.delete(normalizedPath)
55
+ if (resolver) resolver()
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Check if a file is currently locked
61
+ */
62
+ isLocked(path: string): boolean {
63
+ return this.locks.has(normalize(path))
64
+ }
65
+
66
+ /**
67
+ * Clear all locks (use with caution, mainly for cleanup)
68
+ */
69
+ clearAll(): void {
70
+ // Resolve all pending locks
71
+ for (const resolver of this.lockResolvers.values()) {
72
+ resolver()
73
+ }
74
+ this.locks.clear()
75
+ this.lockResolvers.clear()
76
+ this.waitQueues.clear()
77
+ }
78
+ }
79
+
80
+ // Global file lock manager instance
81
+ export const fileLockManager = new FileLockManager()
82
+
19
83
  /**
20
84
  * Manages OPFS handles with caching for improved performance
21
85
  */
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import type {
24
24
  import { constants, flagsToString } from './constants.js'
25
25
  import { createENOENT, createEEXIST, createEACCES, createEISDIR, wrapError } from './errors.js'
26
26
  import { normalize, dirname, basename, join, isRoot, segments } from './path-utils.js'
27
- import { HandleManager } from './handle-manager.js'
27
+ import { HandleManager, fileLockManager } from './handle-manager.js'
28
28
  import { SymlinkManager } from './symlink-manager.js'
29
29
  import { PackedStorage } from './packed-storage.js'
30
30
  import { createFileHandle } from './file-handle.js'
@@ -187,11 +187,19 @@ export default class OPFS {
187
187
  let buffer: Uint8Array
188
188
 
189
189
  if (this.useSync) {
190
- const access = await fileHandle.createSyncAccessHandle()
191
- const size = access.getSize()
192
- buffer = new Uint8Array(size)
193
- access.read(buffer)
194
- access.close()
190
+ const releaseLock = await fileLockManager.acquire(resolvedPath)
191
+ try {
192
+ const access = await fileHandle.createSyncAccessHandle()
193
+ try {
194
+ const size = access.getSize()
195
+ buffer = new Uint8Array(size)
196
+ access.read(buffer)
197
+ } finally {
198
+ access.close()
199
+ }
200
+ } finally {
201
+ releaseLock()
202
+ }
195
203
  } else {
196
204
  const file = await fileHandle.getFile()
197
205
  buffer = new Uint8Array(await file.arrayBuffer())
@@ -269,11 +277,19 @@ export default class OPFS {
269
277
 
270
278
  let buffer: Uint8Array
271
279
  if (this.useSync) {
272
- const access = await fileHandle.createSyncAccessHandle()
273
- const size = access.getSize()
274
- buffer = new Uint8Array(size)
275
- access.read(buffer)
276
- access.close()
280
+ const releaseLock = await fileLockManager.acquire(resolvedPath)
281
+ try {
282
+ const access = await fileHandle.createSyncAccessHandle()
283
+ try {
284
+ const size = access.getSize()
285
+ buffer = new Uint8Array(size)
286
+ access.read(buffer)
287
+ } finally {
288
+ access.close()
289
+ }
290
+ } finally {
291
+ releaseLock()
292
+ }
277
293
  } else {
278
294
  const file = await fileHandle.getFile()
279
295
  buffer = new Uint8Array(await file.arrayBuffer())
@@ -310,11 +326,19 @@ export default class OPFS {
310
326
  const buffer = typeof data === 'string' ? new TextEncoder().encode(data) : data
311
327
 
312
328
  if (this.useSync) {
313
- const access = await fileHandle!.createSyncAccessHandle()
314
- // Set exact size (more efficient than truncate(0) + write)
315
- access.truncate(buffer.length)
316
- access.write(buffer, { at: 0 })
317
- access.close()
329
+ const releaseLock = await fileLockManager.acquire(resolvedPath)
330
+ try {
331
+ const access = await fileHandle!.createSyncAccessHandle()
332
+ try {
333
+ // Set exact size (more efficient than truncate(0) + write)
334
+ access.truncate(buffer.length)
335
+ access.write(buffer, { at: 0 })
336
+ } finally {
337
+ access.close()
338
+ }
339
+ } finally {
340
+ releaseLock()
341
+ }
318
342
  } else {
319
343
  const writable = await fileHandle!.createWritable()
320
344
  await writable.write(buffer)
@@ -1036,9 +1060,17 @@ export default class OPFS {
1036
1060
  if (!fileHandle) throw createENOENT(path)
1037
1061
 
1038
1062
  if (this.useSync) {
1039
- const access = await fileHandle.createSyncAccessHandle()
1040
- access.truncate(len)
1041
- access.close()
1063
+ const releaseLock = await fileLockManager.acquire(resolvedPath)
1064
+ try {
1065
+ const access = await fileHandle.createSyncAccessHandle()
1066
+ try {
1067
+ access.truncate(len)
1068
+ } finally {
1069
+ access.close()
1070
+ }
1071
+ } finally {
1072
+ releaseLock()
1073
+ }
1042
1074
  } else {
1043
1075
  const file = await fileHandle.getFile()
1044
1076
  const data = new Uint8Array(await file.arrayBuffer())
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { HandleManager } from './handle-manager.js'
18
+ import { fileLockManager } from './handle-manager.js'
18
19
  import { createECORRUPTED } from './errors.js'
19
20
 
20
21
  // ============ Compression ============
@@ -163,9 +164,9 @@ export class PackedStorage {
163
164
  /**
164
165
  * Load pack index from disk (always reloads to support hybrid mode)
165
166
  * Verifies CRC32 checksum for integrity
167
+ * Note: Caller must hold the lock
166
168
  */
167
169
  private async loadIndex(): Promise<PackIndex> {
168
- // Always reload from disk to ensure we see writes from other threads/workers
169
170
  try {
170
171
  const { fileHandle } = await this.handleManager.getHandle(PACK_FILE)
171
172
  if (!fileHandle) {
@@ -173,37 +174,44 @@ export class PackedStorage {
173
174
  }
174
175
 
175
176
  if (this.useSync) {
176
- const access = await fileHandle.createSyncAccessHandle()
177
- const size = access.getSize()
178
- if (size < 8) {
179
- access.close()
180
- return {}
181
- }
182
-
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
- access.close()
195
-
196
- // Verify CRC32 if enabled
197
- if (this.useChecksum && storedCrc !== 0) {
198
- const calculatedCrc = crc32(content)
199
- if (calculatedCrc !== storedCrc) {
200
- throw createECORRUPTED(PACK_FILE)
177
+ const releaseLock = await fileLockManager.acquire(PACK_FILE)
178
+ try {
179
+ const access = await fileHandle.createSyncAccessHandle()
180
+ try {
181
+ const size = access.getSize()
182
+ if (size < 8) {
183
+ return {}
184
+ }
185
+
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
+ }
204
+ }
205
+
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()
201
211
  }
212
+ } finally {
213
+ releaseLock()
202
214
  }
203
-
204
- // Parse index from content
205
- const indexJson = new TextDecoder().decode(content.subarray(0, indexLen))
206
- return JSON.parse(indexJson)
207
215
  } else {
208
216
  const file = await fileHandle.getFile()
209
217
  const data = new Uint8Array(await file.arrayBuffer())
@@ -266,10 +274,18 @@ export class PackedStorage {
266
274
  let buffer: Uint8Array
267
275
 
268
276
  if (this.useSync) {
269
- const access = await fileHandle.createSyncAccessHandle()
270
- buffer = new Uint8Array(entry.size)
271
- access.read(buffer, { at: entry.offset })
272
- access.close()
277
+ const releaseLock = await fileLockManager.acquire(PACK_FILE)
278
+ try {
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
+ }
286
+ } finally {
287
+ releaseLock()
288
+ }
273
289
  } else {
274
290
  const file = await fileHandle.getFile()
275
291
  const data = new Uint8Array(await file.arrayBuffer())
@@ -320,19 +336,27 @@ export class PackedStorage {
320
336
  const decompressPromises: Array<{ path: string; promise: Promise<Uint8Array> }> = []
321
337
 
322
338
  if (this.useSync) {
323
- const access = await fileHandle.createSyncAccessHandle()
324
- for (const { path, offset, size, originalSize } of toRead) {
325
- const buffer = new Uint8Array(size)
326
- access.read(buffer, { at: offset })
327
-
328
- if (originalSize !== undefined) {
329
- // Queue for decompression
330
- decompressPromises.push({ path, promise: decompress(buffer) })
331
- } else {
332
- results.set(path, buffer)
339
+ const releaseLock = await fileLockManager.acquire(PACK_FILE)
340
+ try {
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
+ }
353
+ }
354
+ } finally {
355
+ access.close()
333
356
  }
357
+ } finally {
358
+ releaseLock()
334
359
  }
335
- access.close()
336
360
  } else {
337
361
  const file = await fileHandle.getFile()
338
362
  const data = new Uint8Array(await file.arrayBuffer())
@@ -444,16 +468,25 @@ export class PackedStorage {
444
468
 
445
469
  /**
446
470
  * Write the pack file to OPFS
471
+ * Note: Caller must hold the lock
447
472
  */
448
473
  private async writePackFile(data: Uint8Array): Promise<void> {
449
474
  const { fileHandle } = await this.handleManager.getHandle(PACK_FILE, { create: true })
450
475
  if (!fileHandle) return
451
476
 
452
477
  if (this.useSync) {
453
- const access = await fileHandle.createSyncAccessHandle()
454
- access.truncate(data.length)
455
- access.write(data, { at: 0 })
456
- access.close()
478
+ const releaseLock = await fileLockManager.acquire(PACK_FILE)
479
+ try {
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
+ }
487
+ } finally {
488
+ releaseLock()
489
+ }
457
490
  } else {
458
491
  const writable = await fileHandle.createWritable()
459
492
  await writable.write(data)
@@ -479,46 +512,54 @@ export class PackedStorage {
479
512
  const newIndexBuf = encoder.encode(JSON.stringify(index))
480
513
 
481
514
  if (this.useSync) {
482
- const access = await fileHandle.createSyncAccessHandle()
483
- const size = access.getSize()
484
-
485
- // Read old header to get old index length
486
- const oldHeader = new Uint8Array(8)
487
- access.read(oldHeader, { at: 0 })
488
- const oldIndexLen = new DataView(oldHeader.buffer).getUint32(0, true)
489
-
490
- // Read data portion (after old index)
491
- const dataStart = 8 + oldIndexLen
492
- const dataSize = size - dataStart
493
- const dataPortion = new Uint8Array(dataSize)
494
- if (dataSize > 0) {
495
- access.read(dataPortion, { at: dataStart })
496
- }
515
+ const releaseLock = await fileLockManager.acquire(PACK_FILE)
516
+ try {
517
+ const access = await fileHandle.createSyncAccessHandle()
518
+ try {
519
+ const size = access.getSize()
520
+
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
+ }
497
533
 
498
- // Build new content (new index + data)
499
- const newContent = new Uint8Array(newIndexBuf.length + dataSize)
500
- newContent.set(newIndexBuf, 0)
501
- if (dataSize > 0) {
502
- newContent.set(dataPortion, newIndexBuf.length)
503
- }
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
+ }
504
540
 
505
- // Calculate new CRC32 if enabled
506
- const checksum = this.useChecksum ? crc32(newContent) : 0
541
+ // Calculate new CRC32 if enabled
542
+ const checksum = this.useChecksum ? crc32(newContent) : 0
507
543
 
508
- // Build new header
509
- const newHeader = new Uint8Array(8)
510
- const view = new DataView(newHeader.buffer)
511
- view.setUint32(0, newIndexBuf.length, true)
512
- 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)
513
549
 
514
- // Write new file
515
- const newFile = new Uint8Array(8 + newContent.length)
516
- newFile.set(newHeader, 0)
517
- 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)
518
554
 
519
- access.truncate(newFile.length)
520
- access.write(newFile, { at: 0 })
521
- access.close()
555
+ access.truncate(newFile.length)
556
+ access.write(newFile, { at: 0 })
557
+ } finally {
558
+ access.close()
559
+ }
560
+ } finally {
561
+ releaseLock()
562
+ }
522
563
  } else {
523
564
  // For non-sync, rewrite the whole file
524
565
  const file = await fileHandle.getFile()
@@ -1,5 +1,6 @@
1
1
  import type { SymlinkCache, SymlinkDefinition } from './types.js'
2
2
  import type { HandleManager } from './handle-manager.js'
3
+ import { fileLockManager } from './handle-manager.js'
3
4
  import { normalize } from './path-utils.js'
4
5
  import { createELOOP, createEINVAL, createEEXIST } from './errors.js'
5
6
 
@@ -101,13 +102,21 @@ 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
- access.truncate(0)
106
- let written = 0
107
- while (written < buffer.length) {
108
- written += access.write(buffer.subarray(written), { at: written })
105
+ const releaseLock = await fileLockManager.acquire(SYMLINK_FILE)
106
+ try {
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()
116
+ }
117
+ } finally {
118
+ releaseLock()
109
119
  }
110
- access.close()
111
120
  } else {
112
121
  const writable = await fileHandle.createWritable()
113
122
  await writable.write(buffer)