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