@componentor/fs 1.2.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/streams.ts DELETED
@@ -1,109 +0,0 @@
1
- import type { ReadStreamOptions, WriteStreamOptions } from './types.js'
2
-
3
- export interface ReadStreamContext {
4
- readFile(path: string): Promise<Uint8Array>
5
- }
6
-
7
- export interface WriteStreamContext {
8
- readFile(path: string): Promise<Uint8Array>
9
- writeFile(path: string, data: Uint8Array): Promise<void>
10
- }
11
-
12
- /**
13
- * Create a ReadableStream for reading file contents
14
- */
15
- export function createReadStream(
16
- path: string,
17
- options: ReadStreamOptions,
18
- context: ReadStreamContext
19
- ): ReadableStream<Uint8Array> {
20
- const { start = 0, end = Infinity, highWaterMark = 64 * 1024 } = options
21
- let position = start
22
- let closed = false
23
- let cachedData: Uint8Array | null = null
24
-
25
- return new ReadableStream({
26
- async pull(controller) {
27
- if (closed) {
28
- controller.close()
29
- return
30
- }
31
-
32
- try {
33
- // Cache file data on first read - avoid re-reading on every pull
34
- if (cachedData === null) {
35
- cachedData = await context.readFile(path)
36
- }
37
-
38
- const endPos = Math.min(end, cachedData.length)
39
- const chunk = cachedData.subarray(position, Math.min(position + highWaterMark, endPos))
40
-
41
- if (chunk.length === 0 || position >= endPos) {
42
- controller.close()
43
- closed = true
44
- cachedData = null // Release memory
45
- return
46
- }
47
-
48
- position += chunk.length
49
- controller.enqueue(chunk)
50
- } catch (err) {
51
- controller.error(err)
52
- }
53
- },
54
- cancel() {
55
- closed = true
56
- cachedData = null // Release memory
57
- }
58
- })
59
- }
60
-
61
- /**
62
- * Create a WritableStream for writing file contents
63
- */
64
- export function createWriteStream(
65
- path: string,
66
- options: WriteStreamOptions,
67
- context: WriteStreamContext
68
- ): WritableStream<Uint8Array> {
69
- const { flags = 'w', start = 0 } = options
70
- const chunks: Array<{ data: Uint8Array; position: number }> = []
71
- let position = start
72
-
73
- return new WritableStream({
74
- async write(chunk) {
75
- chunks.push({ data: chunk, position })
76
- position += chunk.length
77
- },
78
-
79
- async close() {
80
- // Combine all chunks
81
- let existingData = new Uint8Array(0)
82
-
83
- if (!flags.includes('w')) {
84
- try {
85
- existingData = await context.readFile(path)
86
- } catch (e) {
87
- if ((e as { code?: string }).code !== 'ENOENT') throw e
88
- }
89
- }
90
-
91
- let maxSize = existingData.length
92
- for (const { data, position } of chunks) {
93
- maxSize = Math.max(maxSize, position + data.length)
94
- }
95
-
96
- const finalData = new Uint8Array(maxSize)
97
-
98
- if (!flags.includes('w')) {
99
- finalData.set(existingData, 0)
100
- }
101
-
102
- for (const { data, position } of chunks) {
103
- finalData.set(data, position)
104
- }
105
-
106
- await context.writeFile(path, finalData)
107
- }
108
- })
109
- }
@@ -1,338 +0,0 @@
1
- import type { SymlinkCache, SymlinkDefinition } from './types.js'
2
- import type { HandleManager } from './handle-manager.js'
3
- import { fileLock } from './handle-manager.js'
4
- import { normalize } from './path-utils.js'
5
- import { createELOOP, createEINVAL, createEEXIST } from './errors.js'
6
-
7
- const SYMLINK_FILE = '/.opfs-symlinks.json'
8
- const MAX_SYMLINK_DEPTH = 10
9
-
10
- /**
11
- * Manages symbolic link emulation using a JSON metadata file
12
- */
13
- export class SymlinkManager {
14
- private cache: SymlinkCache | null = null
15
- private cacheCount = 0 // Track count to avoid Object.keys() calls
16
- private resolvedCache: Map<string, string> = new Map() // Cache resolved paths
17
- private dirty = false
18
- private handleManager: HandleManager
19
- private useSync: boolean
20
- private loadPromise: Promise<SymlinkCache> | null = null // Avoid multiple concurrent loads
21
- private diskLoaded = false // Track if we've loaded from disk
22
-
23
- constructor(handleManager: HandleManager, useSync: boolean) {
24
- this.handleManager = handleManager
25
- this.useSync = useSync
26
- // Initialize with empty cache - most operations won't need symlinks
27
- this.cache = {}
28
- this.cacheCount = 0
29
- }
30
-
31
- /**
32
- * Reset all symlink state (called when root directory is cleared)
33
- */
34
- reset(): void {
35
- this.cache = {}
36
- this.cacheCount = 0
37
- this.resolvedCache.clear()
38
- this.dirty = false
39
- this.loadPromise = null
40
- this.diskLoaded = false
41
- }
42
-
43
- /**
44
- * Load symlinks from metadata file
45
- * Uses loadPromise to avoid multiple concurrent disk reads
46
- */
47
- async load(): Promise<SymlinkCache> {
48
- // Fast path: if we've already loaded from disk, use cached data
49
- if (this.diskLoaded) return this.cache!
50
-
51
- // If there's already a load in progress, wait for it
52
- if (this.loadPromise) return this.loadPromise
53
-
54
- // Load from disk
55
- this.loadPromise = this.loadFromDisk()
56
- const result = await this.loadPromise
57
- this.loadPromise = null
58
- return result
59
- }
60
-
61
- /**
62
- * Actually read from disk
63
- */
64
- private async loadFromDisk(): Promise<SymlinkCache> {
65
- try {
66
- const { fileHandle } = await this.handleManager.getHandle(SYMLINK_FILE)
67
- if (!fileHandle) {
68
- // No symlink file exists - keep empty cache
69
- this.diskLoaded = true
70
- return this.cache!
71
- }
72
-
73
- const file = await fileHandle.getFile()
74
- const text = await file.text()
75
- this.cache = JSON.parse(text)
76
- this.cacheCount = Object.keys(this.cache).length
77
- this.diskLoaded = true
78
- } catch {
79
- // Error reading - keep empty cache
80
- if (!this.cache) {
81
- this.cache = {}
82
- this.cacheCount = 0
83
- }
84
- this.diskLoaded = true
85
- }
86
-
87
- return this.cache!
88
- }
89
-
90
- /**
91
- * Save symlinks to metadata file
92
- */
93
- async save(): Promise<void> {
94
- if (!this.cache) return
95
-
96
- // Use compact JSON (no formatting) for better performance
97
- const data = JSON.stringify(this.cache)
98
- const { fileHandle } = await this.handleManager.getHandle(SYMLINK_FILE, { create: true })
99
-
100
- if (!fileHandle) return
101
-
102
- const buffer = new TextEncoder().encode(data)
103
-
104
- if (this.useSync) {
105
- const release = await fileLock.acquire(SYMLINK_FILE)
106
- try {
107
- const access = await fileHandle.createSyncAccessHandle()
108
- try {
109
- access.truncate(0)
110
- let written = 0
111
- while (written < buffer.length) {
112
- written += access.write(buffer.subarray(written), { at: written })
113
- }
114
- } finally {
115
- access.close()
116
- }
117
- } finally {
118
- release()
119
- }
120
- } else {
121
- const writable = await fileHandle.createWritable()
122
- await writable.write(buffer)
123
- await writable.close()
124
- }
125
-
126
- this.dirty = false
127
- }
128
-
129
- /**
130
- * Flush pending changes if dirty
131
- */
132
- async flush(): Promise<void> {
133
- if (this.dirty) {
134
- await this.save()
135
- }
136
- }
137
-
138
- /**
139
- * Resolve a path through symlinks
140
- * Fast synchronous path when cache is already loaded
141
- * Uses resolved cache for O(1) repeated lookups
142
- *
143
- * OPTIMIZATION: If we haven't loaded from disk yet AND no symlinks have been
144
- * created in this session, we skip the disk check entirely. This makes pure
145
- * file operations (no symlinks) very fast.
146
- */
147
- async resolve(path: string, maxDepth = MAX_SYMLINK_DEPTH): Promise<string> {
148
- // Ultra-fast path: if no symlinks exist in memory, return immediately
149
- // This covers both: (1) fresh session with no symlinks, (2) loaded from disk with no symlinks
150
- if (this.cacheCount === 0) {
151
- // If we've loaded from disk and it's empty, we're done
152
- // If we haven't loaded from disk, assume no symlinks until a symlink op is called
153
- return path
154
- }
155
-
156
- // We have symlinks in memory - resolve them
157
- // Check resolved cache first for instant lookup
158
- const cached = this.resolvedCache.get(path)
159
- if (cached !== undefined) {
160
- return cached
161
- }
162
- return this.resolveSync(path, this.cache!, maxDepth)
163
- }
164
-
165
- /**
166
- * Synchronous resolution helper - caches the result
167
- */
168
- private resolveSync(path: string, symlinks: SymlinkCache, maxDepth: number): string {
169
- let currentPath = path
170
- let depth = 0
171
-
172
- while (symlinks[currentPath] && depth < maxDepth) {
173
- currentPath = symlinks[currentPath]
174
- depth++
175
- }
176
-
177
- if (depth >= maxDepth) {
178
- throw createELOOP(path)
179
- }
180
-
181
- // Cache the resolved path if it was actually a symlink
182
- if (currentPath !== path) {
183
- this.resolvedCache.set(path, currentPath)
184
- }
185
-
186
- return currentPath
187
- }
188
-
189
- /**
190
- * Clear the resolved path cache (called when symlinks change)
191
- */
192
- private clearResolvedCache(): void {
193
- this.resolvedCache.clear()
194
- }
195
-
196
- /**
197
- * Check if a path is a symlink
198
- */
199
- async isSymlink(path: string): Promise<boolean> {
200
- const symlinks = await this.load()
201
- return !!symlinks[path]
202
- }
203
-
204
- /**
205
- * Get symlink target
206
- */
207
- async readlink(path: string): Promise<string> {
208
- const normalizedPath = normalize(path)
209
- const symlinks = await this.load()
210
-
211
- if (!symlinks[normalizedPath]) {
212
- throw createEINVAL(path)
213
- }
214
-
215
- return symlinks[normalizedPath]
216
- }
217
-
218
- /**
219
- * Create a symlink
220
- */
221
- async symlink(target: string, path: string, checkExists: () => Promise<void>): Promise<void> {
222
- const normalizedPath = normalize(path)
223
- const normalizedTarget = normalize(target)
224
-
225
- const symlinks = await this.load()
226
-
227
- if (symlinks[normalizedPath]) {
228
- throw createEEXIST(normalizedPath)
229
- }
230
-
231
- await checkExists()
232
-
233
- symlinks[normalizedPath] = normalizedTarget
234
- this.cacheCount++
235
- this.clearResolvedCache() // Invalidate resolved cache
236
- this.dirty = true
237
- await this.flush()
238
- }
239
-
240
- /**
241
- * Create multiple symlinks efficiently
242
- */
243
- async symlinkBatch(
244
- links: SymlinkDefinition[],
245
- checkExists: (path: string) => Promise<void>
246
- ): Promise<void> {
247
- const symlinks = await this.load()
248
-
249
- // Prepare all normalized paths first
250
- const normalizedLinks = links.map(({ target, path }) => ({
251
- normalizedPath: normalize(path),
252
- normalizedTarget: normalize(target)
253
- }))
254
-
255
- // Check for existing symlinks in memory (fast)
256
- for (const { normalizedPath } of normalizedLinks) {
257
- if (symlinks[normalizedPath]) {
258
- throw createEEXIST(normalizedPath)
259
- }
260
- }
261
-
262
- // Check filesystem existence in parallel (I/O bound)
263
- await Promise.all(normalizedLinks.map(({ normalizedPath }) => checkExists(normalizedPath)))
264
-
265
- // Add all symlinks at once
266
- for (const { normalizedPath, normalizedTarget } of normalizedLinks) {
267
- symlinks[normalizedPath] = normalizedTarget
268
- }
269
-
270
- this.cacheCount += links.length
271
- this.clearResolvedCache() // Invalidate resolved cache
272
- this.dirty = true
273
- await this.flush()
274
- }
275
-
276
- /**
277
- * Remove a symlink
278
- */
279
- async unlink(path: string): Promise<boolean> {
280
- const symlinks = await this.load()
281
-
282
- if (symlinks[path]) {
283
- delete symlinks[path]
284
- this.cacheCount--
285
- this.clearResolvedCache() // Invalidate resolved cache
286
- this.dirty = true
287
- await this.flush()
288
- return true
289
- }
290
-
291
- return false
292
- }
293
-
294
- /**
295
- * Rename/move a symlink
296
- */
297
- async rename(oldPath: string, newPath: string): Promise<boolean> {
298
- const symlinks = await this.load()
299
-
300
- if (symlinks[oldPath]) {
301
- const target = symlinks[oldPath]
302
- delete symlinks[oldPath]
303
- symlinks[newPath] = target
304
- this.clearResolvedCache() // Invalidate resolved cache
305
- this.dirty = true
306
- await this.flush()
307
- return true
308
- }
309
-
310
- return false
311
- }
312
-
313
- /**
314
- * Get all symlinks in a directory
315
- */
316
- async getSymlinksInDir(dirPath: string): Promise<string[]> {
317
- const symlinks = await this.load()
318
- const result: string[] = []
319
-
320
- for (const symlinkPath of Object.keys(symlinks)) {
321
- const parts = symlinkPath.split('/').filter(Boolean)
322
- const parentPath = '/' + parts.slice(0, -1).join('/')
323
-
324
- if (parentPath === dirPath || (dirPath === '/' && parts.length === 1)) {
325
- result.push(parts[parts.length - 1])
326
- }
327
- }
328
-
329
- return result
330
- }
331
-
332
- /**
333
- * Check if path is the symlink metadata file
334
- */
335
- isMetadataFile(name: string): boolean {
336
- return name === SYMLINK_FILE.replace(/^\/+/, '')
337
- }
338
- }