@componentor/fs 1.2.7 → 2.0.0

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/src/index.ts DELETED
@@ -1,1416 +0,0 @@
1
- import type {
2
- OPFSOptions,
3
- ReadFileOptions,
4
- WriteFileOptions,
5
- BatchWriteEntry,
6
- BatchReadResult,
7
- ReaddirOptions,
8
- Dirent,
9
- Stats,
10
- StatFs,
11
- RmOptions,
12
- CpOptions,
13
- WatchOptions,
14
- FSWatcher,
15
- ReadStreamOptions,
16
- WriteStreamOptions,
17
- FileHandle,
18
- Dir,
19
- DiskUsage,
20
- SymlinkDefinition,
21
- WatchCallback,
22
- WatchRegistration
23
- } from './types.js'
24
- import { constants, flagsToString } from './constants.js'
25
- import { createENOENT, createEEXIST, createEACCES, createEISDIR, wrapError } from './errors.js'
26
- import { normalize, dirname, basename, join, isRoot, segments } from './path-utils.js'
27
- import { HandleManager } from './handle-manager.js'
28
- import { SymlinkManager } from './symlink-manager.js'
29
- import { PackedStorage } from './packed-storage.js'
30
- import { createFileHandle } from './file-handle.js'
31
- import { createReadStream, createWriteStream } from './streams.js'
32
- import { OPFSHybrid, type OPFSHybridOptions, type Backend } from './opfs-hybrid.js'
33
-
34
- export { constants }
35
- export * from './types.js'
36
- export { OPFSHybrid, type OPFSHybridOptions, type Backend }
37
-
38
- /** Extended options that include hybrid mode support */
39
- export interface OPFSExtendedOptions extends OPFSOptions {
40
- /** Worker script URL - when provided, enables hybrid mode (reads on main, writes on worker) */
41
- workerUrl?: URL | string
42
- /** Override read backend when using hybrid mode (default: 'main') */
43
- read?: Backend
44
- /** Override write backend when using hybrid mode (default: 'worker') */
45
- write?: Backend
46
- }
47
-
48
- /**
49
- * OPFS-based filesystem implementation compatible with Node.js fs/promises API
50
- *
51
- * When `workerUrl` is provided, automatically uses hybrid mode for optimal performance:
52
- * - Reads on main thread (no message passing overhead)
53
- * - Writes on worker (sync access handles are faster)
54
- */
55
- export default class OPFS {
56
- private useSync: boolean
57
- private verbose: boolean
58
- private handleManager: HandleManager
59
- private symlinkManager: SymlinkManager
60
- private packedStorage: PackedStorage
61
- private watchCallbacks: Map<symbol, WatchRegistration> = new Map()
62
- private tmpCounter = 0
63
-
64
- /** Hybrid instance when workerUrl is provided */
65
- private hybrid: OPFSHybrid | null = null
66
-
67
- /** File system constants */
68
- public readonly constants = constants
69
-
70
- constructor(options: OPFSExtendedOptions = {}) {
71
- const { useSync = true, verbose = false, useCompression = false, useChecksum = true, workerUrl, read, write } = options
72
- this.verbose = verbose
73
-
74
- // If workerUrl is provided, use hybrid mode
75
- if (workerUrl) {
76
- this.hybrid = new OPFSHybrid({
77
- workerUrl,
78
- read: read ?? 'main',
79
- write: write ?? 'worker',
80
- verbose
81
- })
82
- // These won't be used in hybrid mode but need to be initialized
83
- this.useSync = false
84
- this.handleManager = new HandleManager()
85
- this.symlinkManager = new SymlinkManager(this.handleManager, false)
86
- this.packedStorage = new PackedStorage(this.handleManager, false, useCompression, useChecksum)
87
- } else {
88
- this.useSync = useSync && typeof FileSystemFileHandle !== 'undefined' &&
89
- 'createSyncAccessHandle' in FileSystemFileHandle.prototype
90
- this.handleManager = new HandleManager()
91
- this.symlinkManager = new SymlinkManager(this.handleManager, this.useSync)
92
- this.packedStorage = new PackedStorage(this.handleManager, this.useSync, useCompression, useChecksum)
93
- }
94
- }
95
-
96
- /**
97
- * Wait for the filesystem to be ready (only needed for hybrid mode)
98
- */
99
- async ready(): Promise<void> {
100
- if (this.hybrid) {
101
- await this.hybrid.ready()
102
- }
103
- }
104
-
105
- /**
106
- * Terminate any background workers (only needed for hybrid mode)
107
- */
108
- terminate(): void {
109
- if (this.hybrid) {
110
- this.hybrid.terminate()
111
- }
112
- }
113
-
114
- private log(method: string, ...args: unknown[]): void {
115
- if (this.verbose) {
116
- console.log(`[OPFS] ${method}:`, ...args)
117
- }
118
- }
119
-
120
- private logError(method: string, err: unknown): void {
121
- if (this.verbose) {
122
- console.error(`[OPFS] ${method} error:`, err)
123
- }
124
- }
125
-
126
- /**
127
- * Execute tasks with limited concurrency to avoid overwhelming the system
128
- * @param items - Array of items to process
129
- * @param maxConcurrent - Maximum number of concurrent operations (default: 10)
130
- * @param taskFn - Function to execute for each item
131
- */
132
- private async limitConcurrency<T>(
133
- items: T[],
134
- maxConcurrent: number,
135
- taskFn: (item: T) => Promise<void>
136
- ): Promise<void> {
137
- if (items.length === 0) return
138
-
139
- // For very small batches, run sequentially (minimal overhead)
140
- if (items.length <= 2) {
141
- for (const item of items) {
142
- await taskFn(item)
143
- }
144
- return
145
- }
146
-
147
- // For medium batches up to maxConcurrent, use Promise.all for true parallelism
148
- // This is optimal for browser where I/O can truly run in parallel
149
- if (items.length <= maxConcurrent) {
150
- await Promise.all(items.map(taskFn))
151
- return
152
- }
153
-
154
- // For large batches, use worker pool pattern to limit concurrency
155
- const queue = [...items]
156
- const workers = Array.from({ length: maxConcurrent }).map(async () => {
157
- while (queue.length) {
158
- const item = queue.shift()
159
- if (item !== undefined) await taskFn(item)
160
- }
161
- })
162
- await Promise.all(workers)
163
- }
164
-
165
- /**
166
- * Read file contents
167
- */
168
- async readFile(path: string, options: ReadFileOptions = {}): Promise<string | Uint8Array> {
169
- if (this.hybrid) {
170
- return this.hybrid.readFile(path, options)
171
- }
172
-
173
- this.log('readFile', path, options)
174
- try {
175
- const normalizedPath = normalize(path)
176
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
177
-
178
- // Try individual file first (most common case)
179
- let fileHandle: FileSystemFileHandle | null = null
180
- try {
181
- fileHandle = await this.handleManager.getPooledFileHandle(resolvedPath)
182
- } catch {
183
- // File doesn't exist as individual file, will try packed storage
184
- }
185
-
186
- if (fileHandle) {
187
- let buffer: Uint8Array
188
-
189
- if (this.useSync) {
190
- const access = await fileHandle.createSyncAccessHandle()
191
- try {
192
- const size = access.getSize()
193
- buffer = new Uint8Array(size)
194
- access.read(buffer)
195
- } finally {
196
- access.close()
197
- }
198
- } else {
199
- const file = await fileHandle.getFile()
200
- buffer = new Uint8Array(await file.arrayBuffer())
201
- }
202
-
203
- return options.encoding
204
- ? new TextDecoder(options.encoding).decode(buffer)
205
- : buffer
206
- }
207
-
208
- // Fall back to packed storage (for batch-written files)
209
- const packedData = await this.packedStorage.read(resolvedPath)
210
- if (packedData) {
211
- return options.encoding
212
- ? new TextDecoder(options.encoding).decode(packedData)
213
- : packedData
214
- }
215
-
216
- throw createENOENT(path)
217
- } catch (err) {
218
- this.logError('readFile', err)
219
- throw wrapError(err)
220
- }
221
- }
222
-
223
- /**
224
- * Read multiple files efficiently in a batch operation
225
- * Uses packed storage batch read (single index load), falls back to individual files
226
- * Returns results in the same order as input paths
227
- */
228
- async readFileBatch(paths: string[]): Promise<BatchReadResult[]> {
229
- if (this.hybrid) {
230
- return this.hybrid.readFileBatch(paths)
231
- }
232
-
233
- this.log('readFileBatch', `${paths.length} files`)
234
- if (paths.length === 0) return []
235
-
236
- try {
237
- // Resolve all symlinks first
238
- const resolvedPaths = await Promise.all(
239
- paths.map(async (path) => {
240
- const normalizedPath = normalize(path)
241
- return this.symlinkManager.resolve(normalizedPath)
242
- })
243
- )
244
-
245
- // Try to read all from packed storage in one operation (single index load)
246
- const packedResults = await this.packedStorage.readBatch(resolvedPaths)
247
-
248
- // Pre-allocate results array
249
- const results: BatchReadResult[] = new Array(paths.length)
250
- const needsIndividualRead: Array<{ index: number; resolvedPath: string }> = []
251
-
252
- // Check which files were found in pack vs need individual read
253
- for (let i = 0; i < paths.length; i++) {
254
- const packedData = packedResults.get(resolvedPaths[i])
255
- if (packedData) {
256
- results[i] = { path: paths[i], data: packedData }
257
- } else {
258
- needsIndividualRead.push({ index: i, resolvedPath: resolvedPaths[i] })
259
- }
260
- }
261
-
262
- // Read remaining files individually
263
- if (needsIndividualRead.length > 0) {
264
- await Promise.all(
265
- needsIndividualRead.map(async ({ index, resolvedPath }) => {
266
- try {
267
- const fileHandle = await this.handleManager.getPooledFileHandle(resolvedPath)
268
- if (!fileHandle) {
269
- results[index] = { path: paths[index], data: null, error: createENOENT(paths[index]) }
270
- return
271
- }
272
-
273
- let buffer: Uint8Array
274
- if (this.useSync) {
275
- const access = await fileHandle.createSyncAccessHandle()
276
- try {
277
- const size = access.getSize()
278
- buffer = new Uint8Array(size)
279
- access.read(buffer)
280
- } finally {
281
- access.close()
282
- }
283
- } else {
284
- const file = await fileHandle.getFile()
285
- buffer = new Uint8Array(await file.arrayBuffer())
286
- }
287
- results[index] = { path: paths[index], data: buffer }
288
- } catch (err) {
289
- results[index] = { path: paths[index], data: null, error: err as Error }
290
- }
291
- })
292
- )
293
- }
294
-
295
- return results
296
- } catch (err) {
297
- this.logError('readFileBatch', err)
298
- throw wrapError(err)
299
- }
300
- }
301
-
302
- /**
303
- * Write data to a file
304
- */
305
- async writeFile(path: string, data: string | Uint8Array, options: WriteFileOptions = {}): Promise<void> {
306
- if (this.hybrid) {
307
- return this.hybrid.writeFile(path, data, options)
308
- }
309
-
310
- this.log('writeFile', path)
311
- try {
312
- const normalizedPath = normalize(path)
313
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
314
-
315
- const { fileHandle } = await this.handleManager.getHandle(resolvedPath, { create: true })
316
- const buffer = typeof data === 'string' ? new TextEncoder().encode(data) : data
317
-
318
- if (this.useSync) {
319
- const access = await fileHandle!.createSyncAccessHandle()
320
- try {
321
- // Set exact size (more efficient than truncate(0) + write)
322
- access.truncate(buffer.length)
323
- access.write(buffer, { at: 0 })
324
- } finally {
325
- access.close()
326
- }
327
- } else {
328
- const writable = await fileHandle!.createWritable()
329
- await writable.write(buffer)
330
- await writable.close()
331
- }
332
- } catch (err) {
333
- this.logError('writeFile', err)
334
- throw wrapError(err)
335
- }
336
- }
337
-
338
- /**
339
- * Write multiple files efficiently in a batch operation
340
- * Uses packed storage (single file) for maximum performance
341
- */
342
- async writeFileBatch(entries: BatchWriteEntry[]): Promise<void> {
343
- if (this.hybrid) {
344
- return this.hybrid.writeFileBatch(entries)
345
- }
346
-
347
- this.log('writeFileBatch', `${entries.length} files`)
348
- if (entries.length === 0) return
349
-
350
- try {
351
- // Reuse encoder for all string conversions
352
- const encoder = new TextEncoder()
353
-
354
- // Resolve all symlinks and convert data
355
- const packEntries = await Promise.all(
356
- entries.map(async ({ path, data }) => {
357
- const normalizedPath = normalize(path)
358
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
359
- return {
360
- path: resolvedPath,
361
- data: typeof data === 'string' ? encoder.encode(data) : data
362
- }
363
- })
364
- )
365
-
366
- // Write all files to packed storage (single OPFS write!)
367
- await this.packedStorage.writeBatch(packEntries)
368
- } catch (err) {
369
- this.logError('writeFileBatch', err)
370
- throw wrapError(err)
371
- }
372
- }
373
-
374
- /**
375
- * Create a directory
376
- */
377
- async mkdir(path: string): Promise<void> {
378
- if (this.hybrid) {
379
- return this.hybrid.mkdir(path)
380
- }
381
-
382
- this.log('mkdir', path)
383
- try {
384
- await this.handleManager.mkdir(path)
385
- } catch (err) {
386
- this.logError('mkdir', err)
387
- throw wrapError(err)
388
- }
389
- }
390
-
391
- /**
392
- * Remove a directory
393
- */
394
- async rmdir(path: string): Promise<void> {
395
- if (this.hybrid) {
396
- return this.hybrid.rmdir(path)
397
- }
398
-
399
- this.log('rmdir', path)
400
- try {
401
- const normalizedPath = normalize(path)
402
- this.handleManager.clearCache(normalizedPath)
403
-
404
- if (isRoot(normalizedPath)) {
405
- const root = await this.handleManager.getRoot()
406
- const entries: string[] = []
407
- for await (const [name] of root.entries()) {
408
- entries.push(name)
409
- }
410
- await this.limitConcurrency(entries, 10, (name) =>
411
- root.removeEntry(name, { recursive: true })
412
- )
413
- // Reset all storage state since all files including metadata are gone
414
- this.symlinkManager.reset()
415
- this.packedStorage.reset()
416
- return
417
- }
418
-
419
- const pathSegments = segments(normalizedPath)
420
- const name = pathSegments.pop()!
421
- let dir = await this.handleManager.getRoot()
422
-
423
- for (const part of pathSegments) {
424
- dir = await dir.getDirectoryHandle(part)
425
- }
426
-
427
- try {
428
- await dir.removeEntry(name, { recursive: true })
429
- } catch {
430
- throw createENOENT(path)
431
- }
432
- } catch (err) {
433
- this.logError('rmdir', err)
434
- throw wrapError(err)
435
- }
436
- }
437
-
438
- /**
439
- * Remove a file or symlink
440
- */
441
- async unlink(path: string): Promise<void> {
442
- if (this.hybrid) {
443
- return this.hybrid.unlink(path)
444
- }
445
-
446
- this.log('unlink', path)
447
- try {
448
- const normalizedPath = normalize(path)
449
- this.handleManager.clearCache(normalizedPath)
450
-
451
- // Check if it's a symlink
452
- const isSymlink = await this.symlinkManager.isSymlink(normalizedPath)
453
- if (isSymlink) {
454
- await this.symlinkManager.unlink(normalizedPath)
455
- return
456
- }
457
-
458
- // Check if it's in packed storage
459
- const inPack = await this.packedStorage.has(normalizedPath)
460
- if (inPack) {
461
- await this.packedStorage.remove(normalizedPath)
462
- return
463
- }
464
-
465
- // Otherwise it's a regular file
466
- const { dir, name, fileHandle } = await this.handleManager.getHandle(normalizedPath)
467
- if (!fileHandle) throw createENOENT(path)
468
-
469
- try {
470
- await dir!.removeEntry(name!)
471
- } catch {
472
- throw createENOENT(path)
473
- }
474
- } catch (err) {
475
- this.logError('unlink', err)
476
- throw wrapError(err)
477
- }
478
- }
479
-
480
- /**
481
- * Read directory contents
482
- */
483
- async readdir(path: string, options?: ReaddirOptions): Promise<string[] | Dirent[]> {
484
- if (this.hybrid) {
485
- return this.hybrid.readdir(path, options)
486
- }
487
-
488
- this.log('readdir', path, options)
489
- try {
490
- const normalizedPath = normalize(path)
491
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
492
-
493
- const dir = await this.handleManager.getDirectoryHandle(resolvedPath)
494
- const withFileTypes = options?.withFileTypes === true
495
-
496
- // Pre-fetch symlinks only once - skip if no symlinks exist (common case)
497
- const symlinksInDir = await this.symlinkManager.getSymlinksInDir(resolvedPath)
498
- const hasSymlinks = symlinksInDir.length > 0
499
- const symlinkSet = hasSymlinks ? new Set(symlinksInDir) : null
500
-
501
- // Collect entries from OPFS directory
502
- const entryNames = new Set<string>()
503
- const entries: (string | Dirent)[] = []
504
-
505
- for await (const [name, handle] of dir.entries()) {
506
- if (this.symlinkManager.isMetadataFile(name)) continue
507
-
508
- entryNames.add(name)
509
-
510
- if (withFileTypes) {
511
- // Only check symlink if there are symlinks
512
- const isSymlink = hasSymlinks && symlinkSet!.has(name)
513
- entries.push({
514
- name,
515
- isFile: () => !isSymlink && handle.kind === 'file',
516
- isDirectory: () => !isSymlink && handle.kind === 'directory',
517
- isSymbolicLink: () => isSymlink
518
- })
519
- } else {
520
- entries.push(name)
521
- }
522
- }
523
-
524
- // Add symlinks that don't have corresponding OPFS entries (only if there are symlinks)
525
- if (hasSymlinks) {
526
- for (const name of symlinksInDir) {
527
- if (!entryNames.has(name)) {
528
- if (withFileTypes) {
529
- entries.push({
530
- name,
531
- isFile: () => false,
532
- isDirectory: () => false,
533
- isSymbolicLink: () => true
534
- })
535
- } else {
536
- entries.push(name)
537
- }
538
- }
539
- }
540
- }
541
-
542
- return entries as string[] | Dirent[]
543
- } catch (err) {
544
- this.logError('readdir', err)
545
- throw wrapError(err)
546
- }
547
- }
548
-
549
- /**
550
- * Get file/directory statistics (follows symlinks)
551
- */
552
- async stat(path: string): Promise<Stats> {
553
- if (this.hybrid) {
554
- return this.hybrid.stat(path)
555
- }
556
-
557
- this.log('stat', path)
558
- try {
559
- const normalizedPath = normalize(path)
560
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
561
- const defaultDate = new Date(0)
562
-
563
- if (isRoot(resolvedPath)) {
564
- return {
565
- type: 'dir',
566
- size: 0,
567
- mode: 0o040755,
568
- ctime: defaultDate,
569
- ctimeMs: 0,
570
- mtime: defaultDate,
571
- mtimeMs: 0,
572
- isFile: () => false,
573
- isDirectory: () => true,
574
- isSymbolicLink: () => false
575
- }
576
- }
577
-
578
- const pathSegments = segments(resolvedPath)
579
- const name = pathSegments.pop()!
580
- let dir = await this.handleManager.getRoot()
581
-
582
- for (const part of pathSegments) {
583
- try {
584
- dir = await dir.getDirectoryHandle(part)
585
- } catch {
586
- throw createENOENT(path)
587
- }
588
- }
589
-
590
- // Check both file and directory in parallel for best performance
591
- const [fileResult, dirResult] = await Promise.allSettled([
592
- dir.getFileHandle(name),
593
- dir.getDirectoryHandle(name)
594
- ])
595
-
596
- if (fileResult.status === 'fulfilled') {
597
- const fileHandle = fileResult.value
598
- const file = await fileHandle.getFile()
599
- const mtime = file.lastModified ? new Date(file.lastModified) : defaultDate
600
-
601
- return {
602
- type: 'file',
603
- size: file.size,
604
- mode: 0o100644,
605
- ctime: mtime,
606
- ctimeMs: mtime.getTime(),
607
- mtime,
608
- mtimeMs: mtime.getTime(),
609
- isFile: () => true,
610
- isDirectory: () => false,
611
- isSymbolicLink: () => false
612
- }
613
- }
614
-
615
- if (dirResult.status === 'fulfilled') {
616
- return {
617
- type: 'dir',
618
- size: 0,
619
- mode: 0o040755,
620
- ctime: defaultDate,
621
- ctimeMs: 0,
622
- mtime: defaultDate,
623
- mtimeMs: 0,
624
- isFile: () => false,
625
- isDirectory: () => true,
626
- isSymbolicLink: () => false
627
- }
628
- }
629
-
630
- // Check packed storage as fallback
631
- const packedSize = await this.packedStorage.getSize(resolvedPath)
632
- if (packedSize !== null) {
633
- return {
634
- type: 'file',
635
- size: packedSize,
636
- mode: 0o100644,
637
- ctime: defaultDate,
638
- ctimeMs: 0,
639
- mtime: defaultDate,
640
- mtimeMs: 0,
641
- isFile: () => true,
642
- isDirectory: () => false,
643
- isSymbolicLink: () => false
644
- }
645
- }
646
-
647
- throw createENOENT(path)
648
- } catch (err) {
649
- this.logError('stat', err)
650
- throw wrapError(err)
651
- }
652
- }
653
-
654
- /**
655
- * Get file/directory statistics (does not follow symlinks)
656
- */
657
- async lstat(path: string): Promise<Stats> {
658
- if (this.hybrid) {
659
- return this.hybrid.lstat(path)
660
- }
661
-
662
- this.log('lstat', path)
663
- try {
664
- const normalizedPath = normalize(path)
665
- const isSymlink = await this.symlinkManager.isSymlink(normalizedPath)
666
-
667
- if (isSymlink) {
668
- const target = await this.symlinkManager.readlink(normalizedPath)
669
- return {
670
- type: 'symlink',
671
- target,
672
- size: target.length,
673
- mode: 0o120777,
674
- ctime: new Date(0),
675
- ctimeMs: 0,
676
- mtime: new Date(0),
677
- mtimeMs: 0,
678
- isFile: () => false,
679
- isDirectory: () => false,
680
- isSymbolicLink: () => true
681
- }
682
- }
683
-
684
- return this.stat(path)
685
- } catch (err) {
686
- this.logError('lstat', err)
687
- throw wrapError(err)
688
- }
689
- }
690
-
691
- /**
692
- * Rename a file or directory
693
- */
694
- async rename(oldPath: string, newPath: string): Promise<void> {
695
- if (this.hybrid) {
696
- return this.hybrid.rename(oldPath, newPath)
697
- }
698
-
699
- this.log('rename', oldPath, newPath)
700
- try {
701
- const normalizedOld = normalize(oldPath)
702
- const normalizedNew = normalize(newPath)
703
-
704
- this.handleManager.clearCache(normalizedOld)
705
- this.handleManager.clearCache(normalizedNew)
706
-
707
- // Handle symlink rename
708
- const renamed = await this.symlinkManager.rename(normalizedOld, normalizedNew)
709
- if (renamed) return
710
-
711
- const stat = await this.stat(normalizedOld)
712
-
713
- if (stat.isFile()) {
714
- // Run readFile and ensureParentDir in parallel (no dependency)
715
- const [data] = await Promise.all([
716
- this.readFile(normalizedOld),
717
- this.handleManager.ensureParentDir(normalizedNew)
718
- ])
719
- await this.writeFile(normalizedNew, data as Uint8Array)
720
- await this.unlink(normalizedOld)
721
- } else if (stat.isDirectory()) {
722
- await this.mkdir(normalizedNew)
723
- const entries = await this.readdir(normalizedOld) as string[]
724
- // Use concurrency limiter to avoid Promise overhead for small batches
725
- await this.limitConcurrency(entries, 10, entry =>
726
- this.rename(`${normalizedOld}/${entry}`, `${normalizedNew}/${entry}`)
727
- )
728
- await this.rmdir(normalizedOld)
729
- }
730
- } catch (err) {
731
- this.logError('rename', err)
732
- throw wrapError(err)
733
- }
734
- }
735
-
736
- /**
737
- * Create a symbolic link
738
- */
739
- async symlink(target: string, path: string): Promise<void> {
740
- if (this.hybrid) {
741
- return this.hybrid.symlink(target, path)
742
- }
743
-
744
- this.log('symlink', target, path)
745
- try {
746
- const normalizedPath = normalize(path)
747
- this.handleManager.clearCache(normalizedPath)
748
-
749
- // Fast existence check - just try to get handle, much faster than full stat()
750
- await this.symlinkManager.symlink(target, path, async () => {
751
- const { fileHandle, dirHandle } = await this.handleManager.getHandle(normalizedPath)
752
- if (fileHandle || dirHandle) {
753
- throw createEEXIST(path)
754
- }
755
- })
756
- } catch (err) {
757
- this.logError('symlink', err)
758
- throw wrapError(err)
759
- }
760
- }
761
-
762
- /**
763
- * Read symlink target
764
- */
765
- async readlink(path: string): Promise<string> {
766
- if (this.hybrid) {
767
- return this.hybrid.readlink(path)
768
- }
769
-
770
- this.log('readlink', path)
771
- try {
772
- return await this.symlinkManager.readlink(path)
773
- } catch (err) {
774
- this.logError('readlink', err)
775
- throw wrapError(err)
776
- }
777
- }
778
-
779
- /**
780
- * Create multiple symlinks efficiently
781
- */
782
- async symlinkBatch(links: SymlinkDefinition[]): Promise<void> {
783
- if (this.hybrid) {
784
- return this.hybrid.symlinkBatch(links)
785
- }
786
-
787
- this.log('symlinkBatch', links.length, 'links')
788
- try {
789
- // Clear cache once at the start for all paths
790
- for (const { path } of links) {
791
- this.handleManager.clearCache(normalize(path))
792
- }
793
-
794
- // Fast existence check - if parent doesn't exist, symlink path is available
795
- await this.symlinkManager.symlinkBatch(links, async (normalizedPath) => {
796
- try {
797
- const { fileHandle, dirHandle } = await this.handleManager.getHandle(normalizedPath)
798
- if (fileHandle || dirHandle) {
799
- throw createEEXIST(normalizedPath)
800
- }
801
- } catch (err) {
802
- // If ENOENT (parent doesn't exist), the path is available for symlink
803
- if ((err as { code?: string }).code === 'ENOENT') return
804
- throw err
805
- }
806
- })
807
- } catch (err) {
808
- this.logError('symlinkBatch', err)
809
- throw wrapError(err)
810
- }
811
- }
812
-
813
- /**
814
- * Check file accessibility
815
- */
816
- async access(path: string, mode = constants.F_OK): Promise<void> {
817
- if (this.hybrid) {
818
- return this.hybrid.access(path, mode)
819
- }
820
-
821
- this.log('access', path, mode)
822
- try {
823
- const normalizedPath = normalize(path)
824
- await this.stat(normalizedPath)
825
- // OPFS doesn't have permissions, existence check is enough
826
- } catch (err) {
827
- this.logError('access', err)
828
- throw createEACCES(path)
829
- }
830
- }
831
-
832
- /**
833
- * Append data to a file
834
- */
835
- async appendFile(path: string, data: string | Uint8Array, options: WriteFileOptions = {}): Promise<void> {
836
- if (this.hybrid) {
837
- return this.hybrid.appendFile(path, data, options)
838
- }
839
-
840
- this.log('appendFile', path)
841
- try {
842
- const normalizedPath = normalize(path)
843
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
844
-
845
- let existingData: Uint8Array = new Uint8Array(0)
846
- try {
847
- const result = await this.readFile(resolvedPath)
848
- existingData = result instanceof Uint8Array ? result : new TextEncoder().encode(result)
849
- } catch (err) {
850
- if ((err as { code?: string }).code !== 'ENOENT') throw err
851
- }
852
-
853
- const newData = typeof data === 'string'
854
- ? new TextEncoder().encode(data)
855
- : data
856
-
857
- const combined = new Uint8Array(existingData.length + newData.length)
858
- combined.set(existingData, 0)
859
- combined.set(newData, existingData.length)
860
-
861
- await this.writeFile(resolvedPath, combined, options)
862
- } catch (err) {
863
- this.logError('appendFile', err)
864
- throw wrapError(err)
865
- }
866
- }
867
-
868
- /**
869
- * Copy a file
870
- */
871
- async copyFile(src: string, dest: string, mode = 0): Promise<void> {
872
- if (this.hybrid) {
873
- return this.hybrid.copyFile(src, dest, mode)
874
- }
875
-
876
- this.log('copyFile', src, dest, mode)
877
- try {
878
- const normalizedSrc = normalize(src)
879
- const normalizedDest = normalize(dest)
880
- const resolvedSrc = await this.symlinkManager.resolve(normalizedSrc)
881
-
882
- // Check COPYFILE_EXCL flag
883
- if (mode & constants.COPYFILE_EXCL) {
884
- try {
885
- await this.stat(normalizedDest)
886
- throw createEEXIST(dest)
887
- } catch (err) {
888
- if ((err as { code?: string }).code !== 'ENOENT') throw err
889
- }
890
- }
891
-
892
- // Run readFile and ensureParentDir in parallel (no dependency)
893
- const [data] = await Promise.all([
894
- this.readFile(resolvedSrc),
895
- this.handleManager.ensureParentDir(normalizedDest)
896
- ])
897
- await this.writeFile(normalizedDest, data as Uint8Array)
898
- } catch (err) {
899
- this.logError('copyFile', err)
900
- throw wrapError(err)
901
- }
902
- }
903
-
904
- /**
905
- * Copy files/directories recursively
906
- */
907
- async cp(src: string, dest: string, options: CpOptions = {}): Promise<void> {
908
- if (this.hybrid) {
909
- return this.hybrid.cp(src, dest, options)
910
- }
911
-
912
- this.log('cp', src, dest, options)
913
- try {
914
- const normalizedSrc = normalize(src)
915
- const normalizedDest = normalize(dest)
916
- const { recursive = false, force = false, errorOnExist = false } = options
917
-
918
- const srcStat = await this.stat(normalizedSrc)
919
-
920
- if (srcStat.isDirectory()) {
921
- if (!recursive) {
922
- throw createEISDIR(src)
923
- }
924
-
925
- let destExists = false
926
- try {
927
- await this.stat(normalizedDest)
928
- destExists = true
929
- if (errorOnExist && !force) {
930
- throw createEEXIST(dest)
931
- }
932
- } catch (err) {
933
- if ((err as { code?: string }).code !== 'ENOENT') throw err
934
- }
935
-
936
- if (!destExists) {
937
- await this.mkdir(normalizedDest)
938
- }
939
-
940
- const entries = await this.readdir(normalizedSrc) as string[]
941
- // Use concurrency limiter to avoid Promise overhead for small batches
942
- await this.limitConcurrency(entries, 10, entry =>
943
- this.cp(`${normalizedSrc}/${entry}`, `${normalizedDest}/${entry}`, options)
944
- )
945
- } else {
946
- if (errorOnExist) {
947
- try {
948
- await this.stat(normalizedDest)
949
- throw createEEXIST(dest)
950
- } catch (err) {
951
- if ((err as { code?: string }).code !== 'ENOENT') throw err
952
- }
953
- }
954
- await this.copyFile(normalizedSrc, normalizedDest)
955
- }
956
- } catch (err) {
957
- this.logError('cp', err)
958
- throw wrapError(err)
959
- }
960
- }
961
-
962
- /**
963
- * Check if path exists
964
- */
965
- async exists(path: string): Promise<boolean> {
966
- if (this.hybrid) {
967
- return this.hybrid.exists(path)
968
- }
969
-
970
- this.log('exists', path)
971
- try {
972
- await this.stat(normalize(path))
973
- return true
974
- } catch {
975
- return false
976
- }
977
- }
978
-
979
- /**
980
- * Resolve symlinks to get real path
981
- */
982
- async realpath(path: string): Promise<string> {
983
- if (this.hybrid) {
984
- return this.hybrid.realpath(path)
985
- }
986
-
987
- this.log('realpath', path)
988
- const normalizedPath = normalize(path)
989
- return this.symlinkManager.resolve(normalizedPath)
990
- }
991
-
992
- /**
993
- * Remove files and directories
994
- */
995
- async rm(path: string, options: RmOptions = {}): Promise<void> {
996
- if (this.hybrid) {
997
- return this.hybrid.rm(path, options)
998
- }
999
-
1000
- this.log('rm', path, options)
1001
- try {
1002
- const normalizedPath = normalize(path)
1003
- const { recursive = false, force = false } = options
1004
-
1005
- try {
1006
- const stat = await this.lstat(normalizedPath)
1007
-
1008
- if (stat.isSymbolicLink()) {
1009
- await this.unlink(normalizedPath)
1010
- } else if (stat.isDirectory()) {
1011
- if (!recursive) {
1012
- throw createEISDIR(path)
1013
- }
1014
- await this.rmdir(normalizedPath)
1015
- } else {
1016
- await this.unlink(normalizedPath)
1017
- }
1018
- } catch (err) {
1019
- if ((err as { code?: string }).code === 'ENOENT' && force) {
1020
- return
1021
- }
1022
- throw err
1023
- }
1024
- } catch (err) {
1025
- this.logError('rm', err)
1026
- throw wrapError(err)
1027
- }
1028
- }
1029
-
1030
- /**
1031
- * Truncate file to specified length
1032
- */
1033
- async truncate(path: string, len = 0): Promise<void> {
1034
- if (this.hybrid) {
1035
- return this.hybrid.truncate(path, len)
1036
- }
1037
-
1038
- this.log('truncate', path, len)
1039
- try {
1040
- const normalizedPath = normalize(path)
1041
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
1042
- this.handleManager.clearCache(resolvedPath)
1043
-
1044
- const { fileHandle } = await this.handleManager.getHandle(resolvedPath)
1045
- if (!fileHandle) throw createENOENT(path)
1046
-
1047
- if (this.useSync) {
1048
- const access = await fileHandle.createSyncAccessHandle()
1049
- try {
1050
- access.truncate(len)
1051
- } finally {
1052
- access.close()
1053
- }
1054
- } else {
1055
- const file = await fileHandle.getFile()
1056
- const data = new Uint8Array(await file.arrayBuffer())
1057
-
1058
- // Create a new array with the truncated/padded size
1059
- const finalData = new Uint8Array(len)
1060
- // Copy up to len bytes from original data using set() for performance
1061
- const copyLen = Math.min(len, data.length)
1062
- if (copyLen > 0) {
1063
- finalData.set(data.subarray(0, copyLen), 0)
1064
- }
1065
- // Remaining bytes (if any) are already zero from Uint8Array initialization
1066
-
1067
- const writable = await fileHandle.createWritable()
1068
- await writable.write(finalData)
1069
- await writable.close()
1070
- }
1071
- } catch (err) {
1072
- this.logError('truncate', err)
1073
- throw wrapError(err)
1074
- }
1075
- }
1076
-
1077
- /**
1078
- * Create a unique temporary directory
1079
- */
1080
- async mkdtemp(prefix: string): Promise<string> {
1081
- if (this.hybrid) {
1082
- return this.hybrid.mkdtemp(prefix)
1083
- }
1084
-
1085
- this.log('mkdtemp', prefix)
1086
- try {
1087
- const normalizedPrefix = normalize(prefix)
1088
- const suffix = `${Date.now()}-${++this.tmpCounter}-${Math.random().toString(36).slice(2, 8)}`
1089
- const path = `${normalizedPrefix}${suffix}`
1090
- await this.mkdir(path)
1091
- return path
1092
- } catch (err) {
1093
- this.logError('mkdtemp', err)
1094
- throw wrapError(err)
1095
- }
1096
- }
1097
-
1098
- /**
1099
- * Change file mode (no-op for OPFS compatibility)
1100
- */
1101
- async chmod(path: string, mode: number): Promise<void> {
1102
- if (this.hybrid) {
1103
- return this.hybrid.chmod(path, mode)
1104
- }
1105
-
1106
- this.log('chmod', path, mode)
1107
- await this.stat(normalize(path))
1108
- // OPFS doesn't support file modes
1109
- }
1110
-
1111
- /**
1112
- * Change file owner (no-op for OPFS compatibility)
1113
- */
1114
- async chown(path: string, uid: number, gid: number): Promise<void> {
1115
- if (this.hybrid) {
1116
- return this.hybrid.chown(path, uid, gid)
1117
- }
1118
-
1119
- this.log('chown', path, uid, gid)
1120
- await this.stat(normalize(path))
1121
- // OPFS doesn't support file ownership
1122
- }
1123
-
1124
- /**
1125
- * Update file timestamps (no-op for OPFS compatibility)
1126
- */
1127
- async utimes(path: string, atime: Date | number, mtime: Date | number): Promise<void> {
1128
- if (this.hybrid) {
1129
- return this.hybrid.utimes(path, atime, mtime)
1130
- }
1131
-
1132
- this.log('utimes', path, atime, mtime)
1133
- await this.stat(normalize(path))
1134
- // OPFS doesn't support setting timestamps
1135
- }
1136
-
1137
- /**
1138
- * Update symlink timestamps (no-op)
1139
- */
1140
- async lutimes(path: string, atime: Date | number, mtime: Date | number): Promise<void> {
1141
- if (this.hybrid) {
1142
- return this.hybrid.lutimes(path, atime, mtime)
1143
- }
1144
-
1145
- this.log('lutimes', path, atime, mtime)
1146
- await this.lstat(normalize(path))
1147
- // OPFS doesn't support setting timestamps
1148
- }
1149
-
1150
- /**
1151
- * Open file and return FileHandle
1152
- */
1153
- async open(path: string, flags: string | number = 'r', mode = 0o666): Promise<FileHandle> {
1154
- this.log('open', path, flags, mode)
1155
- try {
1156
- const normalizedPath = normalize(path)
1157
- const flagStr = flagsToString(flags)
1158
- const shouldCreate = flagStr.includes('w') || flagStr.includes('a') || flagStr.includes('+')
1159
- const shouldTruncate = flagStr.includes('w')
1160
- const shouldAppend = flagStr.includes('a')
1161
-
1162
- if (shouldCreate) {
1163
- await this.handleManager.ensureParentDir(normalizedPath)
1164
- }
1165
-
1166
- const resolvedPath = await this.symlinkManager.resolve(normalizedPath)
1167
- const { fileHandle } = await this.handleManager.getHandle(resolvedPath, { create: shouldCreate })
1168
-
1169
- if (!fileHandle && !shouldCreate) {
1170
- throw createENOENT(path)
1171
- }
1172
-
1173
- if (shouldTruncate && fileHandle) {
1174
- await this.truncate(resolvedPath, 0)
1175
- }
1176
-
1177
- const initialPosition = shouldAppend ? (await this.stat(resolvedPath)).size : 0
1178
-
1179
- return createFileHandle(resolvedPath, initialPosition, {
1180
- readFile: (p, o) => this.readFile(p, o),
1181
- writeFile: (p, d) => this.writeFile(p, d),
1182
- stat: (p) => this.stat(p),
1183
- truncate: (p, l) => this.truncate(p, l),
1184
- appendFile: (p, d, o) => this.appendFile(p, d, o)
1185
- })
1186
- } catch (err) {
1187
- this.logError('open', err)
1188
- throw wrapError(err)
1189
- }
1190
- }
1191
-
1192
- /**
1193
- * Open directory for iteration
1194
- */
1195
- async opendir(path: string): Promise<Dir> {
1196
- this.log('opendir', path)
1197
- try {
1198
- const normalizedPath = normalize(path)
1199
- const entries = await this.readdir(normalizedPath, { withFileTypes: true }) as Dirent[]
1200
- let index = 0
1201
-
1202
- return {
1203
- path: normalizedPath,
1204
-
1205
- async read(): Promise<Dirent | null> {
1206
- if (index >= entries.length) return null
1207
- return entries[index++]
1208
- },
1209
-
1210
- async close(): Promise<void> {
1211
- index = entries.length
1212
- },
1213
-
1214
- async *[Symbol.asyncIterator](): AsyncIterableIterator<Dirent> {
1215
- for (const entry of entries) {
1216
- yield entry
1217
- }
1218
- }
1219
- }
1220
- } catch (err) {
1221
- this.logError('opendir', err)
1222
- throw wrapError(err)
1223
- }
1224
- }
1225
-
1226
- /**
1227
- * Watch for file changes
1228
- */
1229
- watch(path: string, options: WatchOptions = {}): FSWatcher {
1230
- this.log('watch', path, options)
1231
- const normalizedPath = normalize(path)
1232
- const { recursive = false, signal } = options
1233
-
1234
- const callbacks = new Set<WatchCallback>()
1235
- const id = Symbol('watcher')
1236
-
1237
- this.watchCallbacks.set(id, { path: normalizedPath, callbacks, recursive })
1238
-
1239
- if (signal) {
1240
- signal.addEventListener('abort', () => {
1241
- this.watchCallbacks.delete(id)
1242
- })
1243
- }
1244
-
1245
- const self = this
1246
-
1247
- return {
1248
- close(): void {
1249
- self.watchCallbacks.delete(id)
1250
- },
1251
-
1252
- ref(): FSWatcher {
1253
- return this
1254
- },
1255
-
1256
- unref(): FSWatcher {
1257
- return this
1258
- },
1259
-
1260
- [Symbol.asyncIterator](): AsyncIterator<{ eventType: 'rename' | 'change'; filename: string }> {
1261
- const queue: { eventType: 'rename' | 'change'; filename: string }[] = []
1262
- let resolver: ((value: IteratorResult<{ eventType: 'rename' | 'change'; filename: string }>) => void) | null = null
1263
-
1264
- callbacks.add((eventType, filename) => {
1265
- const event = { eventType: eventType as 'rename' | 'change', filename }
1266
- if (resolver) {
1267
- resolver({ value: event, done: false })
1268
- resolver = null
1269
- } else {
1270
- queue.push(event)
1271
- }
1272
- })
1273
-
1274
- return {
1275
- next(): Promise<IteratorResult<{ eventType: 'rename' | 'change'; filename: string }>> {
1276
- if (queue.length > 0) {
1277
- return Promise.resolve({ value: queue.shift()!, done: false })
1278
- }
1279
- return new Promise(resolve => {
1280
- resolver = resolve
1281
- })
1282
- },
1283
- return(): Promise<IteratorResult<{ eventType: 'rename' | 'change'; filename: string }>> {
1284
- return Promise.resolve({ done: true, value: undefined })
1285
- }
1286
- }
1287
- }
1288
- }
1289
- }
1290
-
1291
- /**
1292
- * Create read stream
1293
- */
1294
- createReadStream(path: string, options: ReadStreamOptions = {}): ReadableStream<Uint8Array> {
1295
- this.log('createReadStream', path, options)
1296
- const normalizedPath = normalize(path)
1297
- return createReadStream(normalizedPath, options, {
1298
- readFile: (p) => this.readFile(p) as Promise<Uint8Array>
1299
- })
1300
- }
1301
-
1302
- /**
1303
- * Create write stream
1304
- */
1305
- createWriteStream(path: string, options: WriteStreamOptions = {}): WritableStream<Uint8Array> {
1306
- this.log('createWriteStream', path, options)
1307
- const normalizedPath = normalize(path)
1308
- return createWriteStream(normalizedPath, options, {
1309
- readFile: (p) => this.readFile(p) as Promise<Uint8Array>,
1310
- writeFile: (p, d) => this.writeFile(p, d)
1311
- })
1312
- }
1313
-
1314
- /**
1315
- * Get file statistics (alias for stat)
1316
- */
1317
- async backFile(path: string): Promise<Stats> {
1318
- this.log('backFile', path)
1319
- try {
1320
- return await this.stat(normalize(path))
1321
- } catch (err) {
1322
- if ((err as { code?: string }).code === 'ENOENT') throw err
1323
- throw createENOENT(path)
1324
- }
1325
- }
1326
-
1327
- /**
1328
- * Get disk usage for a path
1329
- */
1330
- async du(path: string): Promise<DiskUsage> {
1331
- if (this.hybrid) {
1332
- return this.hybrid.du(path)
1333
- }
1334
-
1335
- this.log('du', path)
1336
- const normalizedPath = normalize(path)
1337
- const stat = await this.stat(normalizedPath)
1338
- return { path: normalizedPath, size: stat.size }
1339
- }
1340
-
1341
- /**
1342
- * Get filesystem statistics (similar to Node.js fs.statfs)
1343
- * Uses the Storage API to get quota and usage information
1344
- * Note: Values are estimates for the entire origin, not per-path
1345
- */
1346
- async statfs(path?: string): Promise<StatFs> {
1347
- if (this.hybrid) {
1348
- return this.hybrid.statfs(path)
1349
- }
1350
-
1351
- this.log('statfs', path)
1352
- try {
1353
- // Verify path exists if provided
1354
- if (path) {
1355
- await this.stat(normalize(path))
1356
- }
1357
-
1358
- if (typeof navigator === 'undefined' || !navigator.storage?.estimate) {
1359
- throw new Error('Storage API not available')
1360
- }
1361
-
1362
- const estimate = await navigator.storage.estimate()
1363
- const usage = estimate.usage ?? 0
1364
- const quota = estimate.quota ?? 0
1365
- const bsize = 4096 // Simulated block size
1366
-
1367
- return {
1368
- type: 0,
1369
- bsize,
1370
- blocks: Math.floor(quota / bsize),
1371
- bfree: Math.floor((quota - usage) / bsize),
1372
- bavail: Math.floor((quota - usage) / bsize),
1373
- files: 0,
1374
- ffree: 0,
1375
- usage,
1376
- quota
1377
- }
1378
- } catch (err) {
1379
- this.logError('statfs', err)
1380
- throw wrapError(err)
1381
- }
1382
- }
1383
-
1384
- /**
1385
- * Reset internal caches
1386
- * Useful when external processes modify the filesystem
1387
- */
1388
- resetCache(): void {
1389
- if (this.hybrid) {
1390
- // For hybrid, this is async but we provide a sync interface for compatibility
1391
- // Use gc() for guaranteed cleanup
1392
- this.hybrid.resetCache()
1393
- return
1394
- }
1395
-
1396
- this.symlinkManager.reset()
1397
- this.packedStorage.reset()
1398
- this.handleManager.clearCache()
1399
- }
1400
-
1401
- /**
1402
- * Force full garbage collection
1403
- * Releases all handles and caches, reinitializes the worker in hybrid mode
1404
- * Use this for long-running operations to prevent memory leaks
1405
- */
1406
- async gc(): Promise<void> {
1407
- if (this.hybrid) {
1408
- await this.hybrid.gc()
1409
- return
1410
- }
1411
-
1412
- this.symlinkManager.reset()
1413
- await this.packedStorage.clear()
1414
- this.handleManager.clearCache()
1415
- }
1416
- }