sandlot 0.1.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.
Files changed (47) hide show
  1. package/README.md +616 -0
  2. package/dist/bundler.d.ts +148 -0
  3. package/dist/bundler.d.ts.map +1 -0
  4. package/dist/commands.d.ts +179 -0
  5. package/dist/commands.d.ts.map +1 -0
  6. package/dist/fs.d.ts +125 -0
  7. package/dist/fs.d.ts.map +1 -0
  8. package/dist/index.d.ts +16 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +2920 -0
  11. package/dist/internal.d.ts +74 -0
  12. package/dist/internal.d.ts.map +1 -0
  13. package/dist/internal.js +1897 -0
  14. package/dist/loader.d.ts +164 -0
  15. package/dist/loader.d.ts.map +1 -0
  16. package/dist/packages.d.ts +199 -0
  17. package/dist/packages.d.ts.map +1 -0
  18. package/dist/react.d.ts +159 -0
  19. package/dist/react.d.ts.map +1 -0
  20. package/dist/react.js +149 -0
  21. package/dist/sandbox-manager.d.ts +249 -0
  22. package/dist/sandbox-manager.d.ts.map +1 -0
  23. package/dist/sandbox.d.ts +193 -0
  24. package/dist/sandbox.d.ts.map +1 -0
  25. package/dist/shared-modules.d.ts +129 -0
  26. package/dist/shared-modules.d.ts.map +1 -0
  27. package/dist/shared-resources.d.ts +105 -0
  28. package/dist/shared-resources.d.ts.map +1 -0
  29. package/dist/ts-libs.d.ts +98 -0
  30. package/dist/ts-libs.d.ts.map +1 -0
  31. package/dist/typechecker.d.ts +127 -0
  32. package/dist/typechecker.d.ts.map +1 -0
  33. package/package.json +64 -0
  34. package/src/bundler.ts +513 -0
  35. package/src/commands.ts +733 -0
  36. package/src/fs.ts +935 -0
  37. package/src/index.ts +149 -0
  38. package/src/internal.ts +116 -0
  39. package/src/loader.ts +229 -0
  40. package/src/packages.ts +936 -0
  41. package/src/react.tsx +331 -0
  42. package/src/sandbox-manager.ts +490 -0
  43. package/src/sandbox.ts +402 -0
  44. package/src/shared-modules.ts +210 -0
  45. package/src/shared-resources.ts +169 -0
  46. package/src/ts-libs.ts +320 -0
  47. package/src/typechecker.ts +635 -0
package/src/fs.ts ADDED
@@ -0,0 +1,935 @@
1
+ import type {
2
+ IFileSystem,
3
+ FsEntry,
4
+ FsStat,
5
+ MkdirOptions,
6
+ RmOptions,
7
+ CpOptions,
8
+ FileContent,
9
+ InitialFiles,
10
+ FileInit,
11
+ } from "just-bash/browser";
12
+
13
+ /**
14
+ * Supported buffer encodings
15
+ */
16
+ type BufferEncoding = "utf8" | "utf-8" | "ascii" | "binary" | "base64" | "hex" | "latin1";
17
+
18
+ /**
19
+ * Options for reading files
20
+ */
21
+ interface ReadFileOptions {
22
+ encoding?: BufferEncoding | null;
23
+ }
24
+
25
+ /**
26
+ * Options for writing files
27
+ */
28
+ interface WriteFileOptions {
29
+ encoding?: BufferEncoding;
30
+ }
31
+
32
+ /**
33
+ * Directory entry with type information (similar to Node's Dirent)
34
+ */
35
+ interface DirentEntry {
36
+ name: string;
37
+ isFile: boolean;
38
+ isDirectory: boolean;
39
+ isSymbolicLink: boolean;
40
+ }
41
+
42
+ const DEFAULT_FILE_MODE = 0o644;
43
+ const DEFAULT_DIR_MODE = 0o755;
44
+ const DEFAULT_SYMLINK_MODE = 0o777;
45
+ const DEFAULT_MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB default limit
46
+
47
+ /**
48
+ * Options for creating an IndexedDbFs instance
49
+ */
50
+ export interface IndexedDbFsOptions {
51
+ /** Database name in IndexedDB */
52
+ dbName?: string;
53
+ /** Maximum total size in bytes (default: 50MB) */
54
+ maxSizeBytes?: number;
55
+ /** Initial files to populate (only used if DB is empty) */
56
+ initialFiles?: InitialFiles;
57
+ }
58
+
59
+ /**
60
+ * In-memory filesystem with IndexedDB persistence.
61
+ * All operations are fast (in-memory), persistence is manual via save().
62
+ */
63
+ export class IndexedDbFs implements IFileSystem {
64
+ private entries: Map<string, FsEntry>;
65
+ private db: IDBDatabase | null = null;
66
+ private dbName: string;
67
+ private maxSizeBytes: number;
68
+ private dirty = false;
69
+
70
+ private constructor(
71
+ entries: Map<string, FsEntry>,
72
+ db: IDBDatabase | null,
73
+ dbName: string,
74
+ maxSizeBytes: number
75
+ ) {
76
+ this.entries = entries;
77
+ this.db = db;
78
+ this.dbName = dbName;
79
+ this.maxSizeBytes = maxSizeBytes;
80
+ }
81
+
82
+ /**
83
+ * Create and initialize a new IndexedDbFs instance
84
+ */
85
+ static async create(options: IndexedDbFsOptions = {}): Promise<IndexedDbFs> {
86
+ const dbName = options.dbName ?? "sandlot-fs";
87
+ const maxSizeBytes = options.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
88
+
89
+ const db = await IndexedDbFs.openDatabase(dbName);
90
+ const entries = await IndexedDbFs.loadEntries(db);
91
+
92
+ // If empty and initialFiles provided, populate
93
+ if (entries.size === 0) {
94
+ // Always ensure root exists
95
+ entries.set("/", {
96
+ type: "directory",
97
+ mode: DEFAULT_DIR_MODE,
98
+ mtime: new Date(),
99
+ });
100
+
101
+ if (options.initialFiles) {
102
+ for (const [path, value] of Object.entries(options.initialFiles)) {
103
+ const normalizedPath = IndexedDbFs.normalizePath(path);
104
+ const init = IndexedDbFs.parseFileInit(value);
105
+
106
+ // Ensure parent directories exist
107
+ IndexedDbFs.ensureParentDirs(entries, normalizedPath);
108
+
109
+ entries.set(normalizedPath, {
110
+ type: "file",
111
+ content: init.content,
112
+ mode: init.mode ?? DEFAULT_FILE_MODE,
113
+ mtime: init.mtime ?? new Date(),
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ const fs = new IndexedDbFs(entries, db, dbName, maxSizeBytes);
120
+ return fs;
121
+ }
122
+
123
+ /**
124
+ * Create an in-memory only instance (no IndexedDB)
125
+ */
126
+ static createInMemory(options: Omit<IndexedDbFsOptions, "dbName"> = {}): IndexedDbFs {
127
+ const maxSizeBytes = options.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
128
+ const entries = new Map<string, FsEntry>();
129
+
130
+ // Always ensure root exists
131
+ entries.set("/", {
132
+ type: "directory",
133
+ mode: DEFAULT_DIR_MODE,
134
+ mtime: new Date(),
135
+ });
136
+
137
+ if (options.initialFiles) {
138
+ for (const [path, value] of Object.entries(options.initialFiles)) {
139
+ const normalizedPath = IndexedDbFs.normalizePath(path);
140
+ const init = IndexedDbFs.parseFileInit(value);
141
+
142
+ // Ensure parent directories exist
143
+ IndexedDbFs.ensureParentDirs(entries, normalizedPath);
144
+
145
+ entries.set(normalizedPath, {
146
+ type: "file",
147
+ content: init.content,
148
+ mode: init.mode ?? DEFAULT_FILE_MODE,
149
+ mtime: init.mtime ?? new Date(),
150
+ });
151
+ }
152
+ }
153
+
154
+ return new IndexedDbFs(entries, null, "", maxSizeBytes);
155
+ }
156
+
157
+ // ============ Persistence Methods ============
158
+
159
+ /**
160
+ * Save all entries to IndexedDB
161
+ * @returns true if saved, false if no db or not dirty
162
+ */
163
+ async save(): Promise<boolean> {
164
+ if (!this.db || !this.dirty) {
165
+ return false;
166
+ }
167
+
168
+ const tx = this.db.transaction("entries", "readwrite");
169
+ const store = tx.objectStore("entries");
170
+
171
+ // Clear and rewrite all entries
172
+ await this.promisifyRequest(store.clear());
173
+
174
+ for (const [path, entry] of this.entries) {
175
+ store.put({ path, entry: this.serializeEntry(entry) });
176
+ }
177
+
178
+ await this.promisifyTransaction(tx);
179
+ this.dirty = false;
180
+ return true;
181
+ }
182
+
183
+ /**
184
+ * Reload entries from IndexedDB, discarding unsaved changes
185
+ */
186
+ async reload(): Promise<void> {
187
+ if (!this.db) {
188
+ return;
189
+ }
190
+
191
+ this.entries = await IndexedDbFs.loadEntries(this.db);
192
+ this.dirty = false;
193
+ }
194
+
195
+ /**
196
+ * Check if there are unsaved changes
197
+ */
198
+ isDirty(): boolean {
199
+ return this.dirty;
200
+ }
201
+
202
+ /**
203
+ * Get approximate size of all stored data in bytes
204
+ */
205
+ getSize(): number {
206
+ let size = 0;
207
+ for (const [path, entry] of this.entries) {
208
+ size += path.length * 2; // UTF-16
209
+ if (entry.type === "file") {
210
+ if (typeof entry.content === "string") {
211
+ size += entry.content.length * 2;
212
+ } else {
213
+ size += entry.content.byteLength;
214
+ }
215
+ }
216
+ }
217
+ return size;
218
+ }
219
+
220
+ /**
221
+ * Close the database connection
222
+ */
223
+ close(): void {
224
+ if (this.db) {
225
+ this.db.close();
226
+ this.db = null;
227
+ }
228
+ }
229
+
230
+ // ============ IFileSystem Implementation ============
231
+
232
+ async readFile(
233
+ path: string,
234
+ options?: ReadFileOptions | BufferEncoding
235
+ ): Promise<string> {
236
+ const normalizedPath = this.normalizePath(path);
237
+ const entry = this.resolveSymlinks(normalizedPath);
238
+
239
+ if (!entry) {
240
+ throw new Error(`ENOENT: no such file or directory, open '${path}'`);
241
+ }
242
+ if (entry.type !== "file") {
243
+ throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
244
+ }
245
+
246
+ const content = entry.content;
247
+ if (typeof content === "string") {
248
+ return content;
249
+ }
250
+
251
+ // Convert Uint8Array to string
252
+ const encoding = this.getEncoding(options) ?? "utf8";
253
+ return this.decodeBuffer(content, encoding);
254
+ }
255
+
256
+ async readFileBuffer(path: string): Promise<Uint8Array> {
257
+ const normalizedPath = this.normalizePath(path);
258
+ const entry = this.resolveSymlinks(normalizedPath);
259
+
260
+ if (!entry) {
261
+ throw new Error(`ENOENT: no such file or directory, open '${path}'`);
262
+ }
263
+ if (entry.type !== "file") {
264
+ throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
265
+ }
266
+
267
+ const content = entry.content;
268
+ if (content instanceof Uint8Array) {
269
+ return content;
270
+ }
271
+
272
+ // Convert string to Uint8Array
273
+ return new TextEncoder().encode(content);
274
+ }
275
+
276
+ async writeFile(
277
+ path: string,
278
+ content: FileContent,
279
+ options?: WriteFileOptions | BufferEncoding
280
+ ): Promise<void> {
281
+ const normalizedPath = this.normalizePath(path);
282
+ this.checkSizeLimit(content);
283
+ this.ensureParentDirs(normalizedPath);
284
+
285
+ const existing = this.entries.get(normalizedPath);
286
+ if (existing && existing.type === "directory") {
287
+ throw new Error(`EISDIR: illegal operation on a directory, open '${path}'`);
288
+ }
289
+
290
+ this.entries.set(normalizedPath, {
291
+ type: "file",
292
+ content,
293
+ mode: existing?.mode ?? DEFAULT_FILE_MODE,
294
+ mtime: new Date(),
295
+ });
296
+ this.dirty = true;
297
+ }
298
+
299
+ async appendFile(
300
+ path: string,
301
+ content: FileContent,
302
+ options?: WriteFileOptions | BufferEncoding
303
+ ): Promise<void> {
304
+ const normalizedPath = this.normalizePath(path);
305
+ let existing: string | Uint8Array = "";
306
+
307
+ try {
308
+ existing = await this.readFile(normalizedPath);
309
+ } catch {
310
+ // File doesn't exist, will be created
311
+ }
312
+
313
+ const newContent =
314
+ typeof existing === "string" && typeof content === "string"
315
+ ? existing + content
316
+ : this.concatBuffers(
317
+ typeof existing === "string" ? new TextEncoder().encode(existing) : existing,
318
+ typeof content === "string" ? new TextEncoder().encode(content) : content
319
+ );
320
+
321
+ await this.writeFile(normalizedPath, newContent, options);
322
+ }
323
+
324
+ async exists(path: string): Promise<boolean> {
325
+ const normalizedPath = this.normalizePath(path);
326
+ return this.entries.has(normalizedPath);
327
+ }
328
+
329
+ async stat(path: string): Promise<FsStat> {
330
+ const normalizedPath = this.normalizePath(path);
331
+ const entry = this.resolveSymlinks(normalizedPath);
332
+
333
+ if (!entry) {
334
+ throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
335
+ }
336
+
337
+ return this.entryToStat(entry);
338
+ }
339
+
340
+ async lstat(path: string): Promise<FsStat> {
341
+ const normalizedPath = this.normalizePath(path);
342
+ const entry = this.entries.get(normalizedPath);
343
+
344
+ if (!entry) {
345
+ throw new Error(`ENOENT: no such file or directory, lstat '${path}'`);
346
+ }
347
+
348
+ return this.entryToStat(entry);
349
+ }
350
+
351
+ async mkdir(path: string, options?: MkdirOptions): Promise<void> {
352
+ const normalizedPath = this.normalizePath(path);
353
+
354
+ if (this.entries.has(normalizedPath)) {
355
+ if (options?.recursive) {
356
+ return; // Already exists, ok with recursive
357
+ }
358
+ throw new Error(`EEXIST: file already exists, mkdir '${path}'`);
359
+ }
360
+
361
+ if (options?.recursive) {
362
+ this.ensureParentDirs(normalizedPath);
363
+ } else {
364
+ const parent = this.getParentPath(normalizedPath);
365
+ if (parent && !this.entries.has(parent)) {
366
+ throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
367
+ }
368
+ }
369
+
370
+ this.entries.set(normalizedPath, {
371
+ type: "directory",
372
+ mode: DEFAULT_DIR_MODE,
373
+ mtime: new Date(),
374
+ });
375
+ this.dirty = true;
376
+ }
377
+
378
+ async readdir(path: string): Promise<string[]> {
379
+ const normalizedPath = this.normalizePath(path);
380
+ const entry = this.resolveSymlinks(normalizedPath);
381
+
382
+ if (!entry) {
383
+ throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
384
+ }
385
+ if (entry.type !== "directory") {
386
+ throw new Error(`ENOTDIR: not a directory, scandir '${path}'`);
387
+ }
388
+
389
+ const prefix = normalizedPath === "/" ? "/" : normalizedPath + "/";
390
+ const names: string[] = [];
391
+
392
+ for (const entryPath of this.entries.keys()) {
393
+ if (entryPath === normalizedPath) continue;
394
+ if (!entryPath.startsWith(prefix)) continue;
395
+
396
+ const relative = entryPath.slice(prefix.length);
397
+ if (!relative.includes("/")) {
398
+ names.push(relative);
399
+ }
400
+ }
401
+
402
+ return names.sort();
403
+ }
404
+
405
+ async readdirWithFileTypes(path: string): Promise<DirentEntry[]> {
406
+ const normalizedPath = this.normalizePath(path);
407
+ const entry = this.resolveSymlinks(normalizedPath);
408
+
409
+ if (!entry) {
410
+ throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
411
+ }
412
+ if (entry.type !== "directory") {
413
+ throw new Error(`ENOTDIR: not a directory, scandir '${path}'`);
414
+ }
415
+
416
+ const prefix = normalizedPath === "/" ? "/" : normalizedPath + "/";
417
+ const dirents: DirentEntry[] = [];
418
+
419
+ for (const [entryPath, e] of this.entries) {
420
+ if (entryPath === normalizedPath) continue;
421
+ if (!entryPath.startsWith(prefix)) continue;
422
+
423
+ const relative = entryPath.slice(prefix.length);
424
+ if (!relative.includes("/")) {
425
+ dirents.push({
426
+ name: relative,
427
+ isFile: e.type === "file",
428
+ isDirectory: e.type === "directory",
429
+ isSymbolicLink: e.type === "symlink",
430
+ });
431
+ }
432
+ }
433
+
434
+ return dirents.sort((a, b) => a.name.localeCompare(b.name));
435
+ }
436
+
437
+ async rm(path: string, options?: RmOptions): Promise<void> {
438
+ const normalizedPath = this.normalizePath(path);
439
+ const entry = this.entries.get(normalizedPath);
440
+
441
+ if (!entry) {
442
+ if (options?.force) return;
443
+ throw new Error(`ENOENT: no such file or directory, rm '${path}'`);
444
+ }
445
+
446
+ if (entry.type === "directory") {
447
+ const children = await this.readdir(normalizedPath);
448
+ if (children.length > 0 && !options?.recursive) {
449
+ throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`);
450
+ }
451
+
452
+ if (options?.recursive) {
453
+ // Delete all children
454
+ const prefix = normalizedPath === "/" ? "/" : normalizedPath + "/";
455
+ for (const entryPath of [...this.entries.keys()]) {
456
+ if (entryPath.startsWith(prefix)) {
457
+ this.entries.delete(entryPath);
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ this.entries.delete(normalizedPath);
464
+ this.dirty = true;
465
+ }
466
+
467
+ async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
468
+ const srcPath = this.normalizePath(src);
469
+ const destPath = this.normalizePath(dest);
470
+ const entry = this.entries.get(srcPath);
471
+
472
+ if (!entry) {
473
+ throw new Error(`ENOENT: no such file or directory, cp '${src}'`);
474
+ }
475
+
476
+ if (entry.type === "directory") {
477
+ if (!options?.recursive) {
478
+ throw new Error(`EISDIR: cp called on directory without recursive '${src}'`);
479
+ }
480
+
481
+ // Copy directory and all children
482
+ this.ensureParentDirs(destPath);
483
+ this.entries.set(destPath, { ...entry, mtime: new Date() });
484
+
485
+ const prefix = srcPath === "/" ? "/" : srcPath + "/";
486
+ for (const [entryPath, e] of this.entries) {
487
+ if (entryPath.startsWith(prefix)) {
488
+ const relative = entryPath.slice(srcPath.length);
489
+ const newPath = destPath + relative;
490
+ this.entries.set(newPath, this.cloneEntry(e));
491
+ }
492
+ }
493
+ } else {
494
+ this.ensureParentDirs(destPath);
495
+ this.entries.set(destPath, this.cloneEntry(entry));
496
+ }
497
+
498
+ this.dirty = true;
499
+ }
500
+
501
+ async mv(src: string, dest: string): Promise<void> {
502
+ const srcPath = this.normalizePath(src);
503
+ const destPath = this.normalizePath(dest);
504
+ const entry = this.entries.get(srcPath);
505
+
506
+ if (!entry) {
507
+ throw new Error(`ENOENT: no such file or directory, mv '${src}'`);
508
+ }
509
+
510
+ this.ensureParentDirs(destPath);
511
+
512
+ if (entry.type === "directory") {
513
+ // Move directory and all children
514
+ const prefix = srcPath === "/" ? "/" : srcPath + "/";
515
+ const toMove: [string, FsEntry][] = [];
516
+
517
+ for (const [entryPath, e] of this.entries) {
518
+ if (entryPath === srcPath || entryPath.startsWith(prefix)) {
519
+ const relative = entryPath.slice(srcPath.length);
520
+ toMove.push([destPath + relative, e]);
521
+ this.entries.delete(entryPath);
522
+ }
523
+ }
524
+
525
+ for (const [newPath, e] of toMove) {
526
+ this.entries.set(newPath, e);
527
+ }
528
+ } else {
529
+ this.entries.delete(srcPath);
530
+ this.entries.set(destPath, entry);
531
+ }
532
+
533
+ this.dirty = true;
534
+ }
535
+
536
+ resolvePath(base: string, path: string): string {
537
+ if (path.startsWith("/")) {
538
+ return this.normalizePath(path);
539
+ }
540
+
541
+ const baseParts = base.split("/").filter(Boolean);
542
+ const pathParts = path.split("/").filter(Boolean);
543
+
544
+ for (const part of pathParts) {
545
+ if (part === ".") {
546
+ continue;
547
+ } else if (part === "..") {
548
+ baseParts.pop();
549
+ } else {
550
+ baseParts.push(part);
551
+ }
552
+ }
553
+
554
+ return "/" + baseParts.join("/");
555
+ }
556
+
557
+ getAllPaths(): string[] {
558
+ return [...this.entries.keys()].sort();
559
+ }
560
+
561
+ async chmod(path: string, mode: number): Promise<void> {
562
+ const normalizedPath = this.normalizePath(path);
563
+ const entry = this.entries.get(normalizedPath);
564
+
565
+ if (!entry) {
566
+ throw new Error(`ENOENT: no such file or directory, chmod '${path}'`);
567
+ }
568
+
569
+ entry.mode = mode;
570
+ entry.mtime = new Date();
571
+ this.dirty = true;
572
+ }
573
+
574
+ async symlink(target: string, linkPath: string): Promise<void> {
575
+ const normalizedLinkPath = this.normalizePath(linkPath);
576
+
577
+ if (this.entries.has(normalizedLinkPath)) {
578
+ throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`);
579
+ }
580
+
581
+ this.ensureParentDirs(normalizedLinkPath);
582
+
583
+ this.entries.set(normalizedLinkPath, {
584
+ type: "symlink",
585
+ target,
586
+ mode: DEFAULT_SYMLINK_MODE,
587
+ mtime: new Date(),
588
+ });
589
+ this.dirty = true;
590
+ }
591
+
592
+ async link(existingPath: string, newPath: string): Promise<void> {
593
+ const srcPath = this.normalizePath(existingPath);
594
+ const destPath = this.normalizePath(newPath);
595
+
596
+ const entry = this.entries.get(srcPath);
597
+ if (!entry) {
598
+ throw new Error(`ENOENT: no such file or directory, link '${existingPath}'`);
599
+ }
600
+ if (entry.type !== "file") {
601
+ throw new Error(`EPERM: operation not permitted, link '${existingPath}'`);
602
+ }
603
+ if (this.entries.has(destPath)) {
604
+ throw new Error(`EEXIST: file already exists, link '${newPath}'`);
605
+ }
606
+
607
+ this.ensureParentDirs(destPath);
608
+
609
+ // Hard links share the same content reference
610
+ this.entries.set(destPath, {
611
+ type: "file",
612
+ content: entry.content,
613
+ mode: entry.mode,
614
+ mtime: new Date(),
615
+ });
616
+ this.dirty = true;
617
+ }
618
+
619
+ async readlink(path: string): Promise<string> {
620
+ const normalizedPath = this.normalizePath(path);
621
+ const entry = this.entries.get(normalizedPath);
622
+
623
+ if (!entry) {
624
+ throw new Error(`ENOENT: no such file or directory, readlink '${path}'`);
625
+ }
626
+ if (entry.type !== "symlink") {
627
+ throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
628
+ }
629
+
630
+ return entry.target;
631
+ }
632
+
633
+ async realpath(path: string): Promise<string> {
634
+ const normalizedPath = this.normalizePath(path);
635
+ const parts = normalizedPath.split("/").filter(Boolean);
636
+ let resolved = "/";
637
+
638
+ for (const part of parts) {
639
+ // Build the next path component
640
+ resolved = resolved === "/" ? `/${part}` : `${resolved}/${part}`;
641
+
642
+ // Check if this path component exists
643
+ const entry = this.entries.get(resolved);
644
+ if (!entry) {
645
+ throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
646
+ }
647
+
648
+ // If it's a symlink, resolve it
649
+ if (entry.type === "symlink") {
650
+ const target = entry.target;
651
+ // If target is absolute, use it; otherwise resolve relative to parent
652
+ if (target.startsWith("/")) {
653
+ resolved = this.normalizePath(target);
654
+ } else {
655
+ const parent = this.getParentPath(resolved) ?? "/";
656
+ resolved = this.resolvePath(parent, target);
657
+ }
658
+
659
+ // Verify the resolved target exists
660
+ const targetEntry = this.entries.get(resolved);
661
+ if (!targetEntry) {
662
+ throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
663
+ }
664
+ }
665
+ }
666
+
667
+ return resolved;
668
+ }
669
+
670
+ async utimes(path: string, atime: Date, mtime: Date): Promise<void> {
671
+ const normalizedPath = this.normalizePath(path);
672
+ const entry = this.entries.get(normalizedPath);
673
+
674
+ if (!entry) {
675
+ throw new Error(`ENOENT: no such file or directory, utimes '${path}'`);
676
+ }
677
+
678
+ // Update mtime (atime is ignored as per interface docs, kept for API compatibility)
679
+ entry.mtime = mtime;
680
+ this.dirty = true;
681
+ }
682
+
683
+ // ============ Private Helpers ============
684
+
685
+ private normalizePath(path: string): string {
686
+ return IndexedDbFs.normalizePath(path);
687
+ }
688
+
689
+ private static normalizePath(path: string): string {
690
+ // Handle empty or relative paths
691
+ if (!path || path === ".") return "/";
692
+ if (!path.startsWith("/")) {
693
+ path = "/" + path;
694
+ }
695
+
696
+ const parts = path.split("/").filter(Boolean);
697
+ const normalized: string[] = [];
698
+
699
+ for (const part of parts) {
700
+ if (part === ".") continue;
701
+ if (part === "..") {
702
+ normalized.pop();
703
+ } else {
704
+ normalized.push(part);
705
+ }
706
+ }
707
+
708
+ return "/" + normalized.join("/");
709
+ }
710
+
711
+ private getParentPath(path: string): string | null {
712
+ if (path === "/") return null;
713
+ const lastSlash = path.lastIndexOf("/");
714
+ return lastSlash === 0 ? "/" : path.slice(0, lastSlash);
715
+ }
716
+
717
+ private ensureParentDirs(path: string): void {
718
+ IndexedDbFs.ensureParentDirs(this.entries, path);
719
+ this.dirty = true;
720
+ }
721
+
722
+ private static ensureParentDirs(entries: Map<string, FsEntry>, path: string): void {
723
+ const parts = path.split("/").filter(Boolean);
724
+ let current = "";
725
+
726
+ for (let i = 0; i < parts.length - 1; i++) {
727
+ current += "/" + parts[i];
728
+ if (!entries.has(current)) {
729
+ entries.set(current, {
730
+ type: "directory",
731
+ mode: DEFAULT_DIR_MODE,
732
+ mtime: new Date(),
733
+ });
734
+ }
735
+ }
736
+ }
737
+
738
+ private resolveSymlinks(path: string, maxDepth = 10): FsEntry | null {
739
+ let current = path;
740
+ let depth = 0;
741
+
742
+ while (depth < maxDepth) {
743
+ const entry = this.entries.get(current);
744
+ if (!entry) return null;
745
+ if (entry.type !== "symlink") return entry;
746
+
747
+ // Resolve symlink
748
+ const target = entry.target;
749
+ current = target.startsWith("/")
750
+ ? this.normalizePath(target)
751
+ : this.resolvePath(this.getParentPath(current) ?? "/", target);
752
+ depth++;
753
+ }
754
+
755
+ throw new Error(`ELOOP: too many levels of symbolic links, stat '${path}'`);
756
+ }
757
+
758
+ private entryToStat(entry: FsEntry): FsStat {
759
+ return {
760
+ isFile: entry.type === "file",
761
+ isDirectory: entry.type === "directory",
762
+ isSymbolicLink: entry.type === "symlink",
763
+ mode: entry.mode,
764
+ size: entry.type === "file" ? this.getContentSize(entry.content) : 0,
765
+ mtime: entry.mtime,
766
+ };
767
+ }
768
+
769
+ private getContentSize(content: string | Uint8Array): number {
770
+ if (typeof content === "string") {
771
+ return new TextEncoder().encode(content).byteLength;
772
+ }
773
+ return content.byteLength;
774
+ }
775
+
776
+ private cloneEntry(entry: FsEntry): FsEntry {
777
+ if (entry.type === "file") {
778
+ return {
779
+ type: "file",
780
+ content:
781
+ entry.content instanceof Uint8Array
782
+ ? new Uint8Array(entry.content)
783
+ : entry.content,
784
+ mode: entry.mode,
785
+ mtime: new Date(),
786
+ };
787
+ }
788
+ return { ...entry, mtime: new Date() };
789
+ }
790
+
791
+ private checkSizeLimit(content: FileContent): void {
792
+ const currentSize = this.getSize();
793
+ const newSize =
794
+ typeof content === "string"
795
+ ? content.length * 2
796
+ : content.byteLength;
797
+
798
+ if (currentSize + newSize > this.maxSizeBytes) {
799
+ throw new Error(
800
+ `ENOSPC: filesystem size limit exceeded (${this.maxSizeBytes} bytes)`
801
+ );
802
+ }
803
+ }
804
+
805
+ private getEncoding(
806
+ options?: ReadFileOptions | BufferEncoding
807
+ ): BufferEncoding | null {
808
+ if (!options) return null;
809
+ if (typeof options === "string") return options as BufferEncoding;
810
+ return options.encoding ?? null;
811
+ }
812
+
813
+ private decodeBuffer(buffer: Uint8Array, encoding: BufferEncoding): string {
814
+ if (encoding === "utf8" || encoding === "utf-8") {
815
+ return new TextDecoder("utf-8").decode(buffer);
816
+ }
817
+ if (encoding === "base64") {
818
+ let binary = "";
819
+ for (let i = 0; i < buffer.byteLength; i++) {
820
+ binary += String.fromCharCode(buffer[i]!);
821
+ }
822
+ return btoa(binary);
823
+ }
824
+ if (encoding === "hex") {
825
+ return Array.from(buffer)
826
+ .map((b) => b.toString(16).padStart(2, "0"))
827
+ .join("");
828
+ }
829
+ // Default to utf8 for other encodings
830
+ return new TextDecoder("utf-8").decode(buffer);
831
+ }
832
+
833
+ private concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
834
+ const result = new Uint8Array(a.byteLength + b.byteLength);
835
+ result.set(a, 0);
836
+ result.set(b, a.byteLength);
837
+ return result;
838
+ }
839
+
840
+ private static parseFileInit(value: FileContent | FileInit): FileInit {
841
+ if (typeof value === "string" || value instanceof Uint8Array) {
842
+ return { content: value };
843
+ }
844
+ return value;
845
+ }
846
+
847
+ // ============ IndexedDB Helpers ============
848
+
849
+ private static openDatabase(dbName: string): Promise<IDBDatabase> {
850
+ return new Promise((resolve, reject) => {
851
+ const request = indexedDB.open(dbName, 1);
852
+
853
+ request.onerror = () => reject(request.error);
854
+ request.onsuccess = () => resolve(request.result);
855
+
856
+ request.onupgradeneeded = (event) => {
857
+ const db = (event.target as IDBOpenDBRequest).result;
858
+ if (!db.objectStoreNames.contains("entries")) {
859
+ db.createObjectStore("entries", { keyPath: "path" });
860
+ }
861
+ };
862
+ });
863
+ }
864
+
865
+ private static async loadEntries(db: IDBDatabase): Promise<Map<string, FsEntry>> {
866
+ const tx = db.transaction("entries", "readonly");
867
+ const store = tx.objectStore("entries");
868
+
869
+ return new Promise((resolve, reject) => {
870
+ const request = store.getAll();
871
+ request.onerror = () => reject(request.error);
872
+ request.onsuccess = () => {
873
+ const entries = new Map<string, FsEntry>();
874
+ for (const record of request.result) {
875
+ entries.set(record.path, IndexedDbFs.deserializeEntry(record.entry));
876
+ }
877
+ resolve(entries);
878
+ };
879
+ });
880
+ }
881
+
882
+ private serializeEntry(entry: FsEntry): object {
883
+ if (entry.type === "file" && entry.content instanceof Uint8Array) {
884
+ return {
885
+ ...entry,
886
+ content: Array.from(entry.content),
887
+ contentType: "uint8array",
888
+ mtime: entry.mtime.toISOString(),
889
+ };
890
+ }
891
+ return {
892
+ ...entry,
893
+ mtime: entry.mtime.toISOString(),
894
+ };
895
+ }
896
+
897
+ private static deserializeEntry(data: any): FsEntry {
898
+ const mtime = new Date(data.mtime);
899
+
900
+ if (data.type === "file") {
901
+ let content = data.content;
902
+ if (data.contentType === "uint8array" && Array.isArray(content)) {
903
+ content = new Uint8Array(content);
904
+ }
905
+ return { type: "file", content, mode: data.mode, mtime };
906
+ }
907
+ if (data.type === "symlink") {
908
+ return { type: "symlink", target: data.target, mode: data.mode, mtime };
909
+ }
910
+ return { type: "directory", mode: data.mode, mtime };
911
+ }
912
+
913
+ private promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
914
+ return new Promise((resolve, reject) => {
915
+ request.onerror = () => reject(request.error);
916
+ request.onsuccess = () => resolve(request.result);
917
+ });
918
+ }
919
+
920
+ private promisifyTransaction(tx: IDBTransaction): Promise<void> {
921
+ return new Promise((resolve, reject) => {
922
+ tx.onerror = () => reject(tx.error);
923
+ tx.oncomplete = () => resolve();
924
+ });
925
+ }
926
+ }
927
+
928
+ /**
929
+ * Factory function matching the FileSystemFactory type
930
+ */
931
+ export function createIndexedDbFs(initialFiles?: InitialFiles): IFileSystem {
932
+ // For sync factory usage, return in-memory version
933
+ // Use IndexedDbFs.create() for async with persistence
934
+ return IndexedDbFs.createInMemory({ initialFiles });
935
+ }