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