@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/LICENSE +21 -0
- package/README.md +742 -0
- package/dist/index.d.ts +544 -0
- package/dist/index.js +2551 -0
- package/dist/index.js.map +1 -0
- package/dist/opfs-hybrid.d.ts +198 -0
- package/dist/opfs-hybrid.js +2552 -0
- package/dist/opfs-hybrid.js.map +1 -0
- package/dist/opfs-worker-proxy.d.ts +224 -0
- package/dist/opfs-worker-proxy.js +274 -0
- package/dist/opfs-worker-proxy.js.map +1 -0
- package/dist/opfs-worker.js +2732 -0
- package/dist/opfs-worker.js.map +1 -0
- package/package.json +66 -0
- package/src/constants.ts +52 -0
- package/src/errors.ts +88 -0
- package/src/file-handle.ts +100 -0
- package/src/global.d.ts +57 -0
- package/src/handle-manager.ts +250 -0
- package/src/index.ts +1404 -0
- package/src/opfs-hybrid.ts +265 -0
- package/src/opfs-worker-proxy.ts +374 -0
- package/src/opfs-worker.ts +253 -0
- package/src/packed-storage.ts +426 -0
- package/src/path-utils.ts +97 -0
- package/src/streams.ts +109 -0
- package/src/symlink-manager.ts +329 -0
- package/src/types.ts +285 -0
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
|
+
}
|