@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.
@@ -0,0 +1,265 @@
1
+ /**
2
+ * OPFS Hybrid - Routes read/write operations to different backends
3
+ *
4
+ * Allows optimal performance by using:
5
+ * - Main thread for reads (no message passing overhead)
6
+ * - Worker for writes (sync access handles are faster)
7
+ */
8
+
9
+ import OPFS from './index.js'
10
+ import { OPFSWorker } from './opfs-worker-proxy.js'
11
+ import type {
12
+ ReadFileOptions,
13
+ WriteFileOptions,
14
+ BatchWriteEntry,
15
+ BatchReadResult,
16
+ ReaddirOptions,
17
+ Stats,
18
+ StatFs,
19
+ RmOptions,
20
+ CpOptions,
21
+ SymlinkDefinition,
22
+ DiskUsage
23
+ } from './types.js'
24
+
25
+ export type Backend = 'main' | 'worker'
26
+
27
+ export interface OPFSHybridOptions {
28
+ /** Backend for read operations (default: 'main') */
29
+ read?: Backend
30
+ /** Backend for write operations (default: 'worker') */
31
+ write?: Backend
32
+ /** Worker URL (required if using worker backend) */
33
+ workerUrl?: URL | string
34
+ /** Enable verbose logging */
35
+ verbose?: boolean
36
+ }
37
+
38
+ /**
39
+ * Hybrid OPFS implementation that routes operations to optimal backends
40
+ */
41
+ export class OPFSHybrid {
42
+ private mainFs: OPFS
43
+ private workerFs: OPFSWorker | null = null
44
+ private readBackend: Backend
45
+ private writeBackend: Backend
46
+ private workerUrl?: URL | string
47
+ private workerReady: Promise<void> | null = null
48
+ private verbose: boolean
49
+
50
+ constructor(options: OPFSHybridOptions = {}) {
51
+ this.readBackend = options.read ?? 'main'
52
+ this.writeBackend = options.write ?? 'worker'
53
+ this.workerUrl = options.workerUrl
54
+ this.verbose = options.verbose ?? false
55
+
56
+ // Always create main fs (needed for main backend or as fallback)
57
+ this.mainFs = new OPFS({ useSync: false, verbose: this.verbose })
58
+
59
+ // Create worker if needed
60
+ if (this.readBackend === 'worker' || this.writeBackend === 'worker') {
61
+ if (!this.workerUrl) {
62
+ throw new Error('workerUrl is required when using worker backend')
63
+ }
64
+ this.workerFs = new OPFSWorker({ workerUrl: this.workerUrl })
65
+ this.workerReady = this.workerFs.ready()
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Wait for all backends to be ready
71
+ */
72
+ async ready(): Promise<void> {
73
+ if (this.workerReady) {
74
+ await this.workerReady
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Terminate worker if active
80
+ */
81
+ terminate(): void {
82
+ if (this.workerFs) {
83
+ this.workerFs.terminate()
84
+ this.workerFs = null
85
+ }
86
+ }
87
+
88
+ private getReadFs(): OPFS | OPFSWorker {
89
+ if (this.readBackend === 'worker' && this.workerFs) {
90
+ return this.workerFs
91
+ }
92
+ return this.mainFs
93
+ }
94
+
95
+ private getWriteFs(): OPFS | OPFSWorker {
96
+ if (this.writeBackend === 'worker' && this.workerFs) {
97
+ return this.workerFs
98
+ }
99
+ return this.mainFs
100
+ }
101
+
102
+ // ============ Read Operations ============
103
+
104
+ async readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array | string> {
105
+ return this.getReadFs().readFile(path, options)
106
+ }
107
+
108
+ async readFileBatch(paths: string[]): Promise<BatchReadResult[]> {
109
+ return this.getReadFs().readFileBatch(paths)
110
+ }
111
+
112
+ async readdir(path: string, options?: ReaddirOptions): Promise<string[] | import('./types.js').Dirent[]> {
113
+ return this.getReadFs().readdir(path, options)
114
+ }
115
+
116
+ async stat(path: string): Promise<Stats> {
117
+ return this.getReadFs().stat(path)
118
+ }
119
+
120
+ async lstat(path: string): Promise<Stats> {
121
+ return this.getReadFs().lstat(path)
122
+ }
123
+
124
+ async exists(path: string): Promise<boolean> {
125
+ return this.getReadFs().exists(path)
126
+ }
127
+
128
+ async access(path: string, mode?: number): Promise<void> {
129
+ return this.getReadFs().access(path, mode)
130
+ }
131
+
132
+ async readlink(path: string): Promise<string> {
133
+ return this.getReadFs().readlink(path)
134
+ }
135
+
136
+ async realpath(path: string): Promise<string> {
137
+ return this.getReadFs().realpath(path)
138
+ }
139
+
140
+ async statfs(path?: string): Promise<StatFs> {
141
+ return this.getReadFs().statfs(path)
142
+ }
143
+
144
+ async du(path: string): Promise<DiskUsage> {
145
+ return this.getReadFs().du(path)
146
+ }
147
+
148
+ // ============ Write Operations ============
149
+
150
+ async writeFile(path: string, data: string | Uint8Array, options?: WriteFileOptions): Promise<void> {
151
+ return this.getWriteFs().writeFile(path, data, options)
152
+ }
153
+
154
+ async writeFileBatch(entries: BatchWriteEntry[]): Promise<void> {
155
+ return this.getWriteFs().writeFileBatch(entries)
156
+ }
157
+
158
+ async appendFile(path: string, data: string | Uint8Array, options?: WriteFileOptions): Promise<void> {
159
+ return this.getWriteFs().appendFile(path, data, options)
160
+ }
161
+
162
+ async mkdir(path: string): Promise<void> {
163
+ return this.getWriteFs().mkdir(path)
164
+ }
165
+
166
+ async rmdir(path: string): Promise<void> {
167
+ // rmdir affects both backends' state
168
+ if (this.readBackend !== this.writeBackend && this.workerFs) {
169
+ // Clear via worker (does actual deletion and resets worker's symlink cache)
170
+ await this.workerFs.rmdir(path)
171
+ // Reset main thread's cache (no actual file operations, just cache invalidation)
172
+ this.mainFs.resetCache()
173
+ } else {
174
+ return this.getWriteFs().rmdir(path)
175
+ }
176
+ }
177
+
178
+ async unlink(path: string): Promise<void> {
179
+ return this.getWriteFs().unlink(path)
180
+ }
181
+
182
+ async truncate(path: string, len?: number): Promise<void> {
183
+ return this.getWriteFs().truncate(path, len)
184
+ }
185
+
186
+ async symlink(target: string, path: string): Promise<void> {
187
+ // Symlinks affect both backends' symlink cache
188
+ if (this.readBackend !== this.writeBackend && this.workerFs) {
189
+ await this.workerFs.symlink(target, path)
190
+ // Reset main thread's symlink cache so it reloads from disk
191
+ this.mainFs.resetCache()
192
+ } else {
193
+ return this.getWriteFs().symlink(target, path)
194
+ }
195
+ }
196
+
197
+ async symlinkBatch(symlinks: SymlinkDefinition[]): Promise<void> {
198
+ if (this.readBackend !== this.writeBackend && this.workerFs) {
199
+ await this.workerFs.symlinkBatch(symlinks)
200
+ // Reset main thread's symlink cache so it reloads from disk
201
+ this.mainFs.resetCache()
202
+ } else {
203
+ return this.getWriteFs().symlinkBatch(symlinks)
204
+ }
205
+ }
206
+
207
+ async rename(oldPath: string, newPath: string): Promise<void> {
208
+ return this.getWriteFs().rename(oldPath, newPath)
209
+ }
210
+
211
+ async copyFile(src: string, dest: string, mode?: number): Promise<void> {
212
+ return this.getWriteFs().copyFile(src, dest, mode)
213
+ }
214
+
215
+ async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
216
+ return this.getWriteFs().cp(src, dest, options)
217
+ }
218
+
219
+ async rm(path: string, options?: RmOptions): Promise<void> {
220
+ return this.getWriteFs().rm(path, options)
221
+ }
222
+
223
+ async chmod(path: string, mode: number): Promise<void> {
224
+ return this.getWriteFs().chmod(path, mode)
225
+ }
226
+
227
+ async chown(path: string, uid: number, gid: number): Promise<void> {
228
+ return this.getWriteFs().chown(path, uid, gid)
229
+ }
230
+
231
+ async utimes(path: string, atime: Date | number, mtime: Date | number): Promise<void> {
232
+ return this.getWriteFs().utimes(path, atime, mtime)
233
+ }
234
+
235
+ async lutimes(path: string, atime: Date | number, mtime: Date | number): Promise<void> {
236
+ return this.getWriteFs().lutimes(path, atime, mtime)
237
+ }
238
+
239
+ async mkdtemp(prefix: string): Promise<string> {
240
+ return this.getWriteFs().mkdtemp(prefix)
241
+ }
242
+
243
+ /**
244
+ * Reset internal caches on both backends
245
+ */
246
+ async resetCache(): Promise<void> {
247
+ this.mainFs.resetCache()
248
+ if (this.workerFs) {
249
+ await this.workerFs.resetCache()
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Force full garbage collection on both backends
255
+ * More aggressive than resetCache() - reinitializes the worker's OPFS instance
256
+ */
257
+ async gc(): Promise<void> {
258
+ this.mainFs.resetCache()
259
+ if (this.workerFs) {
260
+ await this.workerFs.gc()
261
+ }
262
+ }
263
+ }
264
+
265
+ export default OPFSHybrid
@@ -0,0 +1,374 @@
1
+ /**
2
+ * OPFS Worker Proxy
3
+ * Main thread class that communicates with an OPFS worker
4
+ *
5
+ * This allows non-blocking OPFS operations on the main thread
6
+ * while the actual work happens in a dedicated Web Worker
7
+ */
8
+
9
+ import type {
10
+ ReadFileOptions,
11
+ WriteFileOptions,
12
+ BatchWriteEntry,
13
+ BatchReadResult,
14
+ ReaddirOptions,
15
+ Dirent,
16
+ Stats,
17
+ StatFs,
18
+ RmOptions,
19
+ CpOptions,
20
+ DiskUsage,
21
+ SymlinkDefinition
22
+ } from './types.js'
23
+ import { constants } from './constants.js'
24
+ import { FSError } from './errors.js'
25
+
26
+ interface PendingRequest {
27
+ resolve: (value: unknown) => void
28
+ reject: (error: Error) => void
29
+ }
30
+
31
+ interface WorkerResponse {
32
+ id?: number
33
+ type?: string
34
+ result?: unknown
35
+ error?: { message: string; code?: string }
36
+ }
37
+
38
+ export interface OPFSWorkerOptions {
39
+ /** URL to the worker script (default: auto-detect) */
40
+ workerUrl?: string | URL
41
+ /** Worker initialization options */
42
+ workerOptions?: WorkerOptions
43
+ }
44
+
45
+ /**
46
+ * OPFS Worker Proxy - runs OPFS operations in a Web Worker
47
+ *
48
+ * Benefits:
49
+ * - Non-blocking main thread
50
+ * - Uses sync access handles (faster) in the worker
51
+ * - Compatible with libraries that reuse buffers (e.g., isomorphic-git)
52
+ */
53
+ export class OPFSWorker {
54
+ private worker: Worker | null = null
55
+ private pendingRequests = new Map<number, PendingRequest>()
56
+ private nextId = 1
57
+ private readyPromise: Promise<void>
58
+ private readyResolve!: () => void
59
+
60
+ /** File system constants */
61
+ public readonly constants = constants
62
+
63
+ constructor(options: OPFSWorkerOptions = {}) {
64
+ this.readyPromise = new Promise((resolve) => {
65
+ this.readyResolve = resolve
66
+ })
67
+
68
+ this.initWorker(options)
69
+ }
70
+
71
+ private initWorker(options: OPFSWorkerOptions): void {
72
+ const { workerUrl, workerOptions = { type: 'module' } } = options
73
+
74
+ if (workerUrl) {
75
+ this.worker = new Worker(workerUrl, workerOptions)
76
+ } else {
77
+ // Try to create worker from the bundled script
78
+ // Users should provide workerUrl in production
79
+ throw new Error(
80
+ 'OPFSWorker requires a workerUrl option pointing to the worker script. ' +
81
+ 'Example: new OPFSWorker({ workerUrl: new URL("./opfs-worker.js", import.meta.url) })'
82
+ )
83
+ }
84
+
85
+ this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
86
+ const { id, type, result, error } = event.data
87
+
88
+ // Handle ready signal
89
+ if (type === 'ready') {
90
+ this.readyResolve()
91
+ return
92
+ }
93
+
94
+ // Handle response to a request
95
+ if (id !== undefined) {
96
+ const pending = this.pendingRequests.get(id)
97
+ if (pending) {
98
+ this.pendingRequests.delete(id)
99
+ if (error) {
100
+ const fsError = new FSError(error.message, error.code || 'UNKNOWN')
101
+ pending.reject(fsError)
102
+ } else {
103
+ pending.resolve(result)
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ this.worker.onerror = (event) => {
110
+ console.error('[OPFSWorker] Worker error:', event)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Wait for the worker to be ready
116
+ */
117
+ async ready(): Promise<void> {
118
+ return this.readyPromise
119
+ }
120
+
121
+ /**
122
+ * Terminate the worker
123
+ */
124
+ terminate(): void {
125
+ if (this.worker) {
126
+ this.worker.terminate()
127
+ this.worker = null
128
+
129
+ // Reject all pending requests
130
+ for (const [, pending] of this.pendingRequests) {
131
+ pending.reject(new Error('Worker terminated'))
132
+ }
133
+ this.pendingRequests.clear()
134
+ }
135
+ }
136
+
137
+ private call<T>(method: string, args: unknown[], transfer?: Transferable[]): Promise<T> {
138
+ return new Promise((resolve, reject) => {
139
+ if (!this.worker) {
140
+ reject(new Error('Worker not initialized or terminated'))
141
+ return
142
+ }
143
+
144
+ const id = this.nextId++
145
+ this.pendingRequests.set(id, {
146
+ resolve: resolve as (value: unknown) => void,
147
+ reject
148
+ })
149
+
150
+ const message = { id, method, args }
151
+ if (transfer && transfer.length > 0) {
152
+ this.worker.postMessage(message, transfer)
153
+ } else {
154
+ this.worker.postMessage(message)
155
+ }
156
+ })
157
+ }
158
+
159
+ // File operations
160
+
161
+ async readFile(path: string, options?: ReadFileOptions): Promise<string | Uint8Array> {
162
+ const result = await this.call<string | Uint8Array>('readFile', [path, options])
163
+ return result
164
+ }
165
+
166
+ async writeFile(path: string, data: string | Uint8Array, options?: WriteFileOptions): Promise<void> {
167
+ // Note: We don't use Transferables here because the caller may reuse the buffer
168
+ // (e.g., isomorphic-git reuses buffers). Structured cloning copies the data.
169
+ await this.call<void>('writeFile', [path, data, options])
170
+ }
171
+
172
+ async readFileBatch(paths: string[]): Promise<BatchReadResult[]> {
173
+ return this.call<BatchReadResult[]>('readFileBatch', [paths])
174
+ }
175
+
176
+ async writeFileBatch(entries: BatchWriteEntry[]): Promise<void> {
177
+ // Note: We don't use Transferables here because the caller may reuse the buffers
178
+ await this.call<void>('writeFileBatch', [entries])
179
+ }
180
+
181
+ async appendFile(path: string, data: string | Uint8Array, options?: WriteFileOptions): Promise<void> {
182
+ // Note: We don't use Transferables here because the caller may reuse the buffer
183
+ await this.call<void>('appendFile', [path, data, options])
184
+ }
185
+
186
+ async copyFile(src: string, dest: string, mode?: number): Promise<void> {
187
+ await this.call<void>('copyFile', [src, dest, mode])
188
+ }
189
+
190
+ async unlink(path: string): Promise<void> {
191
+ await this.call<void>('unlink', [path])
192
+ }
193
+
194
+ async truncate(path: string, len?: number): Promise<void> {
195
+ await this.call<void>('truncate', [path, len])
196
+ }
197
+
198
+ // Directory operations
199
+
200
+ async mkdir(path: string): Promise<void> {
201
+ await this.call<void>('mkdir', [path])
202
+ }
203
+
204
+ async rmdir(path: string): Promise<void> {
205
+ await this.call<void>('rmdir', [path])
206
+ }
207
+
208
+ async readdir(path: string, options?: ReaddirOptions): Promise<string[] | Dirent[]> {
209
+ const result = await this.call<string[] | { name: string }[]>('readdir', [path, options])
210
+
211
+ // Reconstruct Dirent objects with methods
212
+ if (options?.withFileTypes && Array.isArray(result)) {
213
+ return result.map((item) => {
214
+ if (typeof item === 'object' && 'name' in item) {
215
+ const entry = item as { name: string; _isFile?: boolean; _isDir?: boolean; _isSymlink?: boolean }
216
+ return {
217
+ name: entry.name,
218
+ isFile: () => entry._isFile ?? false,
219
+ isDirectory: () => entry._isDir ?? false,
220
+ isSymbolicLink: () => entry._isSymlink ?? false
221
+ }
222
+ }
223
+ return item as unknown as Dirent
224
+ })
225
+ }
226
+
227
+ return result as string[]
228
+ }
229
+
230
+ async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
231
+ await this.call<void>('cp', [src, dest, options])
232
+ }
233
+
234
+ async rm(path: string, options?: RmOptions): Promise<void> {
235
+ await this.call<void>('rm', [path, options])
236
+ }
237
+
238
+ // Stat operations
239
+
240
+ async stat(path: string): Promise<Stats> {
241
+ const result = await this.call<{
242
+ type: string
243
+ size: number
244
+ mode: number
245
+ ctime: string
246
+ ctimeMs: number
247
+ mtime: string
248
+ mtimeMs: number
249
+ target?: string
250
+ }>('stat', [path])
251
+
252
+ return this.deserializeStats(result)
253
+ }
254
+
255
+ async lstat(path: string): Promise<Stats> {
256
+ const result = await this.call<{
257
+ type: string
258
+ size: number
259
+ mode: number
260
+ ctime: string
261
+ ctimeMs: number
262
+ mtime: string
263
+ mtimeMs: number
264
+ target?: string
265
+ }>('lstat', [path])
266
+
267
+ return this.deserializeStats(result)
268
+ }
269
+
270
+ private deserializeStats(data: {
271
+ type: string
272
+ size: number
273
+ mode: number
274
+ ctime: string
275
+ ctimeMs: number
276
+ mtime: string
277
+ mtimeMs: number
278
+ target?: string
279
+ }): Stats {
280
+ const ctime = new Date(data.ctime)
281
+ const mtime = new Date(data.mtime)
282
+
283
+ return {
284
+ type: data.type as 'file' | 'dir' | 'symlink',
285
+ size: data.size,
286
+ mode: data.mode,
287
+ ctime,
288
+ ctimeMs: data.ctimeMs,
289
+ mtime,
290
+ mtimeMs: data.mtimeMs,
291
+ target: data.target,
292
+ isFile: () => data.type === 'file',
293
+ isDirectory: () => data.type === 'dir',
294
+ isSymbolicLink: () => data.type === 'symlink'
295
+ }
296
+ }
297
+
298
+ async exists(path: string): Promise<boolean> {
299
+ return this.call<boolean>('exists', [path])
300
+ }
301
+
302
+ async access(path: string, mode?: number): Promise<void> {
303
+ await this.call<void>('access', [path, mode])
304
+ }
305
+
306
+ async statfs(path?: string): Promise<StatFs> {
307
+ return this.call<StatFs>('statfs', [path])
308
+ }
309
+
310
+ async du(path: string): Promise<DiskUsage> {
311
+ return this.call<DiskUsage>('du', [path])
312
+ }
313
+
314
+ // Symlink operations
315
+
316
+ async symlink(target: string, path: string): Promise<void> {
317
+ await this.call<void>('symlink', [target, path])
318
+ }
319
+
320
+ async readlink(path: string): Promise<string> {
321
+ return this.call<string>('readlink', [path])
322
+ }
323
+
324
+ async symlinkBatch(links: SymlinkDefinition[]): Promise<void> {
325
+ await this.call<void>('symlinkBatch', [links])
326
+ }
327
+
328
+ async realpath(path: string): Promise<string> {
329
+ return this.call<string>('realpath', [path])
330
+ }
331
+
332
+ // Other operations
333
+
334
+ async rename(oldPath: string, newPath: string): Promise<void> {
335
+ await this.call<void>('rename', [oldPath, newPath])
336
+ }
337
+
338
+ async mkdtemp(prefix: string): Promise<string> {
339
+ return this.call<string>('mkdtemp', [prefix])
340
+ }
341
+
342
+ async chmod(path: string, mode: number): Promise<void> {
343
+ await this.call<void>('chmod', [path, mode])
344
+ }
345
+
346
+ async chown(path: string, uid: number, gid: number): Promise<void> {
347
+ await this.call<void>('chown', [path, uid, gid])
348
+ }
349
+
350
+ async utimes(path: string, atime: Date | number, mtime: Date | number): Promise<void> {
351
+ await this.call<void>('utimes', [path, atime, mtime])
352
+ }
353
+
354
+ async lutimes(path: string, atime: Date | number, mtime: Date | number): Promise<void> {
355
+ await this.call<void>('lutimes', [path, atime, mtime])
356
+ }
357
+
358
+ /**
359
+ * Reset internal caches to free memory
360
+ * Useful for long-running benchmarks or after bulk operations
361
+ */
362
+ async resetCache(): Promise<void> {
363
+ await this.call<void>('resetCache', [])
364
+ }
365
+
366
+ /**
367
+ * Force full garbage collection by reinitializing the OPFS instance in the worker
368
+ * This completely releases all handles and caches, preventing memory leaks in long-running operations
369
+ * More aggressive than resetCache() - use when resetCache() isn't sufficient
370
+ */
371
+ async gc(): Promise<void> {
372
+ await this.call<void>('gc', [])
373
+ }
374
+ }