typescript-virtual-container 1.4.6 → 1.4.8

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 (139) hide show
  1. package/.vscode/settings.json +1 -0
  2. package/README.md +0 -1
  3. package/benchmark-results.txt +21 -21
  4. package/builds/self-standalone.js +1111 -299
  5. package/builds/self-standalone.js.map +4 -4
  6. package/builds/standalone-wo-sftp.js +993 -183
  7. package/builds/standalone-wo-sftp.js.map +4 -4
  8. package/builds/standalone.cjs +984 -173
  9. package/builds/standalone.cjs.map +4 -4
  10. package/dist/SSHMimic/exec.d.ts.map +1 -1
  11. package/dist/SSHMimic/exec.js +0 -1
  12. package/dist/SSHMimic/exec.js.map +1 -1
  13. package/dist/SSHMimic/index.d.ts.map +1 -1
  14. package/dist/SSHMimic/index.js +3 -1
  15. package/dist/SSHMimic/index.js.map +1 -1
  16. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  17. package/dist/SSHMimic/sftp.js +0 -6
  18. package/dist/SSHMimic/sftp.js.map +1 -1
  19. package/dist/VirtualFileSystem/binaryPack.d.ts.map +1 -1
  20. package/dist/VirtualFileSystem/binaryPack.js +21 -9
  21. package/dist/VirtualFileSystem/binaryPack.js.map +1 -1
  22. package/dist/VirtualFileSystem/index.d.ts +93 -0
  23. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  24. package/dist/VirtualFileSystem/index.js +361 -46
  25. package/dist/VirtualFileSystem/index.js.map +1 -1
  26. package/dist/VirtualFileSystem/internalTypes.d.ts +23 -4
  27. package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -1
  28. package/dist/VirtualFileSystem/journal.d.ts +47 -0
  29. package/dist/VirtualFileSystem/journal.d.ts.map +1 -0
  30. package/dist/VirtualFileSystem/journal.js +178 -0
  31. package/dist/VirtualFileSystem/journal.js.map +1 -0
  32. package/dist/VirtualFileSystem/path.js +1 -1
  33. package/dist/VirtualFileSystem/path.js.map +1 -1
  34. package/dist/VirtualShell/idleManager.d.ts +65 -0
  35. package/dist/VirtualShell/idleManager.d.ts.map +1 -0
  36. package/dist/VirtualShell/idleManager.js +106 -0
  37. package/dist/VirtualShell/idleManager.js.map +1 -0
  38. package/dist/VirtualShell/index.d.ts +28 -0
  39. package/dist/VirtualShell/index.d.ts.map +1 -1
  40. package/dist/VirtualShell/index.js +48 -0
  41. package/dist/VirtualShell/index.js.map +1 -1
  42. package/dist/VirtualShell/shell.js +4 -4
  43. package/dist/VirtualShell/shell.js.map +1 -1
  44. package/dist/commands/man.d.ts.map +1 -1
  45. package/dist/commands/man.js +5 -27
  46. package/dist/commands/man.js.map +1 -1
  47. package/dist/commands/manuals-bundle.d.ts +11 -0
  48. package/dist/commands/manuals-bundle.d.ts.map +1 -0
  49. package/dist/commands/manuals-bundle.js +898 -0
  50. package/dist/commands/manuals-bundle.js.map +1 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/modules/linuxRootfs.d.ts +8 -1
  56. package/dist/modules/linuxRootfs.d.ts.map +1 -1
  57. package/dist/modules/linuxRootfs.js +47 -14
  58. package/dist/modules/linuxRootfs.js.map +1 -1
  59. package/dist/self-standalone.js +16 -1
  60. package/dist/self-standalone.js.map +1 -1
  61. package/dist/standalone.js +22 -0
  62. package/dist/standalone.js.map +1 -1
  63. package/docs/assets/hierarchy.js +1 -1
  64. package/docs/assets/navigation.js +1 -1
  65. package/docs/assets/search.js +1 -1
  66. package/docs/classes/HoneyPot.html +8 -8
  67. package/docs/classes/IdleManager.html +159 -0
  68. package/docs/classes/SshClient.html +18 -18
  69. package/docs/classes/VirtualFileSystem.html +57 -32
  70. package/docs/classes/VirtualPackageManager.html +12 -12
  71. package/docs/classes/VirtualSftpServer.html +3 -3
  72. package/docs/classes/VirtualShell.html +40 -25
  73. package/docs/classes/VirtualSshServer.html +5 -5
  74. package/docs/classes/VirtualUserManager.html +26 -26
  75. package/docs/functions/assertDiff.html +1 -1
  76. package/docs/functions/diffSnapshots.html +1 -1
  77. package/docs/functions/formatDiff.html +1 -1
  78. package/docs/functions/getArg.html +1 -1
  79. package/docs/functions/getFlag.html +1 -1
  80. package/docs/functions/ifFlag.html +1 -1
  81. package/docs/hierarchy.html +1 -1
  82. package/docs/index.html +1 -2
  83. package/docs/interfaces/AuditLogEntry.html +2 -2
  84. package/docs/interfaces/CommandContext.html +11 -11
  85. package/docs/interfaces/CommandResult.html +12 -12
  86. package/docs/interfaces/ExecStream.html +5 -5
  87. package/docs/interfaces/HoneyPotStats.html +2 -2
  88. package/docs/interfaces/IdleManagerOptions.html +7 -0
  89. package/docs/interfaces/InstalledPackage.html +10 -10
  90. package/docs/interfaces/NanoEditorSession.html +4 -4
  91. package/docs/interfaces/PackageDefinition.html +13 -13
  92. package/docs/interfaces/PackageFile.html +4 -4
  93. package/docs/interfaces/RemoveOptions.html +2 -2
  94. package/docs/interfaces/ShellEnv.html +3 -3
  95. package/docs/interfaces/ShellModule.html +7 -7
  96. package/docs/interfaces/ShellProperties.html +4 -4
  97. package/docs/interfaces/ShellStream.html +6 -6
  98. package/docs/interfaces/SudoChallenge.html +8 -8
  99. package/docs/interfaces/VfsBaseNode.html +6 -6
  100. package/docs/interfaces/VfsDiff.html +5 -5
  101. package/docs/interfaces/VfsDiffEntry.html +3 -3
  102. package/docs/interfaces/VfsDiffModified.html +5 -5
  103. package/docs/interfaces/VfsDirectoryNode.html +7 -7
  104. package/docs/interfaces/VfsFileNode.html +8 -8
  105. package/docs/interfaces/VfsOptions.html +18 -4
  106. package/docs/interfaces/VfsSnapshot.html +2 -2
  107. package/docs/interfaces/VfsSnapshotBaseNode.html +3 -3
  108. package/docs/interfaces/VfsSnapshotDirectoryNode.html +4 -4
  109. package/docs/interfaces/VfsSnapshotFileNode.html +5 -5
  110. package/docs/interfaces/WriteFileOptions.html +3 -3
  111. package/docs/modules.html +1 -1
  112. package/docs/types/CommandMode.html +1 -1
  113. package/docs/types/CommandOutcome.html +1 -1
  114. package/docs/types/IdleState.html +1 -0
  115. package/docs/types/VfsNodeStats.html +1 -1
  116. package/docs/types/VfsNodeType.html +1 -1
  117. package/docs/types/VfsPersistenceMode.html +1 -1
  118. package/docs/types/VfsSnapshotNode.html +1 -1
  119. package/package.json +5 -4
  120. package/scripts/generate-manuals-bundle.mjs +49 -0
  121. package/src/SSHMimic/exec.ts +0 -1
  122. package/src/SSHMimic/index.ts +3 -1
  123. package/src/SSHMimic/sftp.ts +0 -6
  124. package/src/VirtualFileSystem/binaryPack.ts +21 -9
  125. package/src/VirtualFileSystem/index.ts +369 -52
  126. package/src/VirtualFileSystem/internalTypes.ts +24 -4
  127. package/src/VirtualFileSystem/journal.ts +163 -0
  128. package/src/VirtualFileSystem/path.ts +1 -1
  129. package/src/VirtualShell/idleManager.ts +133 -0
  130. package/src/VirtualShell/index.ts +48 -0
  131. package/src/VirtualShell/shell.ts +4 -4
  132. package/src/commands/man.ts +5 -35
  133. package/src/commands/manuals-bundle.ts +898 -0
  134. package/src/index.ts +2 -0
  135. package/src/modules/linuxRootfs.ts +58 -14
  136. package/src/self-standalone.ts +14 -1
  137. package/src/standalone.ts +23 -0
  138. package/builds/standalone.js +0 -491
  139. package/builds/standalone.js.map +0 -7
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/style/useNamingConvention: NW ? */
1
2
  import { EventEmitter } from "node:events";
2
3
  import * as fsSync from "node:fs";
3
4
  import * as path from "node:path";
@@ -18,7 +19,9 @@ import type {
18
19
  InternalDirectoryNode,
19
20
  InternalFileNode,
20
21
  InternalNode,
22
+ InternalStubNode,
21
23
  } from "./internalTypes";
24
+ import { appendJournalEntry, JournalOp, readJournal, truncateJournal } from "./journal";
22
25
  import { getNode, getParentDirectory, normalizePath } from "./path";
23
26
 
24
27
  // ── Persistence options ───────────────────────────────────────────────────────
@@ -46,6 +49,26 @@ export interface VfsOptions {
46
49
  * Required when `mode` is `"fs"`.
47
50
  */
48
51
  snapshotPath?: string;
52
+ /**
53
+ * Interval in milliseconds between automatic checkpoints in `"fs"` mode.
54
+ * Set to `0` to disable automatic flushing (manual `flushMirror()` only).
55
+ * Default: 30_000 (30 seconds).
56
+ */
57
+ flushIntervalMs?: number;
58
+ /**
59
+ * Trigger a checkpoint after this many write operations, regardless of the
60
+ * timer interval. Prevents unbounded journal growth during bulk operations
61
+ * (e.g. a 15 000-file SFTP transfer). Default: 500.
62
+ * Set to `0` to disable write-count flushing.
63
+ */
64
+ flushAfterNWrites?: number;
65
+ /**
66
+ * Files larger than this threshold (bytes) are evicted from RAM after each
67
+ * `flushMirror()` and reloaded on demand from the snapshot.
68
+ * Default: 65536 (64 KB). Set to `0` to disable eviction.
69
+ * Only applies to `"fs"` mode.
70
+ */
71
+ evictionThresholdBytes?: number;
49
72
  }
50
73
 
51
74
  // ── VirtualFileSystem ─────────────────────────────────────────────────────────
@@ -77,6 +100,18 @@ class VirtualFileSystem extends EventEmitter {
77
100
  private root: InternalDirectoryNode;
78
101
  private readonly mode: VfsPersistenceMode;
79
102
  private readonly snapshotFile: string | null;
103
+ /** Path to the WAL journal file (null in memory mode). */
104
+ private readonly journalFile: string | null;
105
+ /** Eviction threshold in bytes (0 = disabled). Files above this are purged after flush. */
106
+ private readonly evictionThreshold: number;
107
+ /** Max writes between forced flushes (0 = disabled). */
108
+ private readonly flushAfterNWrites: number;
109
+ /** Pending write counter since last checkpoint. */
110
+ private _writesSinceFlush = 0;
111
+ /** NodeJS timer handle for periodic auto-flush (null = disabled or stopped). */
112
+ private _flushTimer: ReturnType<typeof setInterval> | null = null;
113
+ /** True if the VFS has unflushed changes. */
114
+ private _dirty = false;
80
115
  /** Active host-directory mounts: vPath → { hostPath, readOnly } */
81
116
  private readonly mounts = new Map<string, { hostPath: string; readOnly: boolean }>();
82
117
  /** True when running in a browser environment (no host FS access). */
@@ -96,8 +131,24 @@ class VirtualFileSystem extends EventEmitter {
96
131
  options.snapshotPath,
97
132
  "vfs-snapshot.vfsb",
98
133
  );
134
+ this.journalFile = path.resolve(options.snapshotPath, "vfs-journal.bin");
135
+ this.evictionThreshold = options.evictionThresholdBytes ?? 64 * 1024; // 64 KB default
136
+ this.flushAfterNWrites = options.flushAfterNWrites ?? 500;
137
+ const intervalMs = options.flushIntervalMs ?? 30_000;
138
+ if (intervalMs > 0) {
139
+ this._flushTimer = setInterval(() => {
140
+ if (this._dirty) void this._autoFlush();
141
+ }, intervalMs);
142
+ // Don't block process exit on this timer
143
+ if (typeof this._flushTimer === "object" && this._flushTimer !== null && "unref" in this._flushTimer) {
144
+ (this._flushTimer as NodeJS.Timeout).unref();
145
+ }
146
+ }
99
147
  } else {
100
148
  this.snapshotFile = null;
149
+ this.journalFile = null;
150
+ this.evictionThreshold = 0; // disabled in memory mode
151
+ this.flushAfterNWrites = 0;
101
152
  }
102
153
  this.root = this.makeDir("", 0o755);
103
154
  }
@@ -105,14 +156,15 @@ class VirtualFileSystem extends EventEmitter {
105
156
  // ── Internal helpers ──────────────────────────────────────────────────────
106
157
 
107
158
  private makeDir(name: string, mode: number): InternalDirectoryNode {
108
- const now = new Date();
159
+ const now = Date.now();
109
160
  return {
110
161
  type: "directory",
111
162
  name,
112
163
  mode,
113
164
  createdAt: now,
114
165
  updatedAt: now,
115
- children: new Map(),
166
+ children: Object.create(null) as Record<string, InternalNode>,
167
+ _childCount: 0,
116
168
  };
117
169
  }
118
170
 
@@ -122,7 +174,7 @@ class VirtualFileSystem extends EventEmitter {
122
174
  mode: number,
123
175
  compressed: boolean,
124
176
  ): InternalFileNode {
125
- const now = new Date();
177
+ const now = Date.now();
126
178
  return {
127
179
  type: "file",
128
180
  name,
@@ -134,6 +186,35 @@ class VirtualFileSystem extends EventEmitter {
134
186
  };
135
187
  }
136
188
 
189
+ private makeStub(name: string, content: string, mode: number): InternalStubNode {
190
+ const now = Date.now();
191
+ return { type: "stub", name, stubContent: content, mode, createdAt: now, updatedAt: now };
192
+ }
193
+
194
+ /**
195
+ * Write a lazy stub — stores content as a plain string with no Buffer allocation.
196
+ * Use for static rootfs files that may never be read. On first `writeFile()`,
197
+ * the stub is promoted to a real `InternalFileNode`.
198
+ * Parent directories are created when missing.
199
+ */
200
+ public writeStub(targetPath: string, content: string, mode = 0o644): void {
201
+ const normalized = normalizePath(targetPath);
202
+ const { parent, name } = getParentDirectory(
203
+ this.root,
204
+ normalized,
205
+ true,
206
+ (p) => this.mkdirRecursive(p, 0o755),
207
+ );
208
+ const existing = parent.children[name];
209
+ if (existing?.type === "directory") {
210
+ throw new Error(`Cannot write stub '${normalized}': path is a directory.`);
211
+ }
212
+ // Don't overwrite a real file or an already-promoted node
213
+ if (existing?.type === "file") return;
214
+ if (!existing) parent._childCount++;
215
+ parent.children[name] = this.makeStub(name, content, mode);
216
+ }
217
+
137
218
  private mkdirRecursive(targetPath: string, mode: number): void {
138
219
  const normalized = normalizePath(targetPath);
139
220
  if (normalized === "/") return;
@@ -142,11 +223,13 @@ class VirtualFileSystem extends EventEmitter {
142
223
  let builtPath = "";
143
224
  for (const part of parts) {
144
225
  builtPath += `/${part}`;
145
- let child = current.children.get(part);
226
+ let child = current.children[part];
146
227
  if (!child) {
147
228
  child = this.makeDir(part, mode);
148
- current.children.set(part, child);
229
+ current.children[part] = child;
230
+ current._childCount++;
149
231
  this.emit("dir:create", { path: builtPath, mode });
232
+ this._journal({ op: JournalOp.MKDIR, path: builtPath, mode });
150
233
  } else if (child.type !== "directory") {
151
234
  throw new Error(
152
235
  `Cannot create directory '${builtPath}': path is a file.`,
@@ -168,7 +251,14 @@ class VirtualFileSystem extends EventEmitter {
168
251
  public async restoreMirror(): Promise<void> {
169
252
  if (this.mode !== "fs" || !this.snapshotFile) return;
170
253
 
171
- if (!fsSync.existsSync(this.snapshotFile)) return;
254
+ if (!fsSync.existsSync(this.snapshotFile)) {
255
+ // No snapshot yet — but replay journal if it exists (crash after writes, before first flush)
256
+ if (this.journalFile) {
257
+ const entries = readJournal(this.journalFile);
258
+ if (entries.length > 0) this._replayJournal(entries);
259
+ }
260
+ return;
261
+ }
172
262
 
173
263
  try {
174
264
  const raw = fsSync.readFileSync(this.snapshotFile);
@@ -184,6 +274,11 @@ class VirtualFileSystem extends EventEmitter {
184
274
  );
185
275
  }
186
276
  this.emit("snapshot:restore", { path: this.snapshotFile });
277
+ // Replay WAL journal on top of the loaded snapshot
278
+ if (this.journalFile) {
279
+ const entries = readJournal(this.journalFile);
280
+ if (entries.length > 0) this._replayJournal(entries);
281
+ }
187
282
  } catch (err) {
188
283
  // Corrupt or unreadable snapshot — start fresh and warn
189
284
  console.warn(
@@ -210,7 +305,13 @@ class VirtualFileSystem extends EventEmitter {
210
305
  fsSync.mkdirSync(dir, { recursive: true });
211
306
  const binary = encodeVfs(this.root);
212
307
  fsSync.writeFileSync(this.snapshotFile, binary);
308
+ // Checkpoint complete — truncate the journal (entries are now in the snapshot)
309
+ if (this.journalFile) truncateJournal(this.journalFile);
310
+ this._dirty = false;
311
+ this._writesSinceFlush = 0;
213
312
  this.emit("mirror:flush", { path: this.snapshotFile });
313
+ // Evict large files from RAM now that the snapshot is on disk
314
+ this.evictLargeFiles();
214
315
  }
215
316
 
216
317
  /** Returns the current persistence mode. */
@@ -227,6 +328,174 @@ class VirtualFileSystem extends EventEmitter {
227
328
 
228
329
  /** Creates a directory (and any missing parents). */
229
330
 
331
+
332
+
333
+ // ── Auto-flush scheduler ──────────────────────────────────────────────────
334
+
335
+ /** Internal: flush triggered by timer or write-count threshold. */
336
+ private async _autoFlush(): Promise<void> {
337
+ if (!this._dirty) return;
338
+ await this.flushMirror();
339
+ }
340
+
341
+ /** Mark VFS as having unflushed writes and trigger threshold flush if needed. */
342
+ private _markDirty(): void {
343
+ this._dirty = true;
344
+ if (this.flushAfterNWrites > 0) {
345
+ this._writesSinceFlush++;
346
+ if (this._writesSinceFlush >= this.flushAfterNWrites) {
347
+ this._writesSinceFlush = 0;
348
+ void this._autoFlush();
349
+ }
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Stop the automatic flush timer and perform a final checkpoint.
355
+ * Call this when shutting down to ensure all data is persisted.
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * process.on("SIGINT", async () => {
360
+ * await shell.vfs.stopAutoFlush();
361
+ * process.exit(0);
362
+ * });
363
+ * ```
364
+ */
365
+ public async stopAutoFlush(): Promise<void> {
366
+ if (this._flushTimer !== null) {
367
+ clearInterval(this._flushTimer);
368
+ this._flushTimer = null;
369
+ }
370
+ if (this._dirty) await this.flushMirror();
371
+ }
372
+
373
+ /**
374
+ * Replace the entire root tree — used internally by `bootstrapLinuxRootfs`
375
+ * to hot-swap the static rootfs snapshot without going through importSnapshot
376
+ * (which would re-journal every node in fs mode).
377
+ * @internal
378
+ */
379
+ public importRootTree(root: InternalDirectoryNode): void {
380
+ const prev = this._replayMode;
381
+ this._replayMode = true;
382
+ try { this.root = root; } finally { this._replayMode = prev; }
383
+ }
384
+
385
+ /** Serialise current tree to VFSB binary. Used for the static rootfs cache. */
386
+ public encodeBinary(): Buffer {
387
+ return encodeVfs(this.root);
388
+ }
389
+
390
+ /**
391
+ * Release the in-memory VFS tree, freeing all InternalNode objects for GC.
392
+ * The tree MUST be restored via `importRootTree()` before any VFS operation.
393
+ * Called by IdleManager when freezing an idle shell.
394
+ * @internal
395
+ */
396
+ public releaseTree(): void {
397
+ // Replace root with a minimal stub — keeps the object alive but frees all children
398
+ this.root = this.makeDir("", 0o755);
399
+ }
400
+
401
+ /** Set to true during journal replay to suppress re-journaling. */
402
+ private _replayMode = false;
403
+
404
+ /** Append a journal entry if in fs mode and not replaying. */
405
+ private _journal(entry: Parameters<typeof appendJournalEntry>[1]): void {
406
+ if (this.journalFile && !this._replayMode) {
407
+ appendJournalEntry(this.journalFile, entry);
408
+ this._markDirty();
409
+ }
410
+ }
411
+
412
+ /** Replay a list of journal entries onto the in-memory tree. */
413
+ private _replayJournal(entries: ReturnType<typeof readJournal>): void {
414
+ this._replayMode = true;
415
+ try {
416
+ for (const e of entries) {
417
+ try {
418
+ if (e.op === JournalOp.WRITE) {
419
+ this.writeFile(e.path, e.content ?? Buffer.alloc(0), { mode: e.mode });
420
+ } else if (e.op === JournalOp.MKDIR) {
421
+ this.mkdir(e.path, e.mode);
422
+ } else if (e.op === JournalOp.REMOVE) {
423
+ if (this.exists(e.path)) this.remove(e.path, { recursive: true });
424
+ } else if (e.op === JournalOp.CHMOD) {
425
+ if (this.exists(e.path)) this.chmod(e.path, e.mode ?? 0o644);
426
+ } else if (e.op === JournalOp.MOVE) {
427
+ if (this.exists(e.path) && e.dest) this.move(e.path, e.dest);
428
+ } else if (e.op === JournalOp.SYMLINK) {
429
+ if (e.dest) this.symlink(e.dest, e.path);
430
+ }
431
+ } catch { /* ignore individual replay errors — best-effort */ }
432
+ }
433
+ } finally {
434
+ this._replayMode = false;
435
+ }
436
+ }
437
+
438
+
439
+ // ── RAM eviction ──────────────────────────────────────────────────────────
440
+
441
+ /**
442
+ * Walk the in-memory tree and evict file contents that exceed
443
+ * `evictionThreshold`. Called automatically after `flushMirror()`.
444
+ * Safe to call at any time — evicted files are reloaded on demand.
445
+ */
446
+ public evictLargeFiles(): void {
447
+ if (!this.snapshotFile || this.evictionThreshold === 0) return;
448
+ if (!fsSync.existsSync(this.snapshotFile)) return;
449
+ this._evictDir(this.root);
450
+ }
451
+
452
+ private _evictDir(dir: InternalDirectoryNode): void {
453
+ for (const node of Object.values(dir.children)) {
454
+ if (node.type === "directory") {
455
+ this._evictDir(node);
456
+ } else if (node.type === "file" && !node.evicted) {
457
+ const rawSize = node.compressed
458
+ ? (node.size ?? node.content.length * 2) // estimate uncompressed
459
+ : node.content.length;
460
+ if (rawSize > this.evictionThreshold) {
461
+ node.size = rawSize;
462
+ node.content = Buffer.alloc(0); // free heap
463
+ node.evicted = true;
464
+ }
465
+ }
466
+ // stubs: nothing to evict — content is already a plain string, not a Buffer
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Reload a single evicted file node's content from the current snapshot.
472
+ * No-op if the node is not evicted.
473
+ */
474
+ private _reloadEvicted(node: InternalFileNode, normalizedPath: string): void {
475
+ if (!node.evicted || !this.snapshotFile) return;
476
+ if (!fsSync.existsSync(this.snapshotFile)) return;
477
+ try {
478
+ // Load and parse the snapshot to find this specific node
479
+ const raw = fsSync.readFileSync(this.snapshotFile);
480
+ const tmpRoot = decodeVfs(raw);
481
+ const parts = normalizedPath.split("/").filter(Boolean);
482
+ let cur: InternalNode = tmpRoot;
483
+ for (const part of parts) {
484
+ if (cur.type !== "directory") return;
485
+ const next: InternalNode | undefined = cur.children[part];
486
+ if (!next) return;
487
+ cur = next;
488
+ }
489
+ if (cur.type === "file") {
490
+ node.content = cur.content;
491
+ node.compressed = cur.compressed;
492
+ node.evicted = undefined;
493
+ }
494
+ } catch {
495
+ // Snapshot unreadable — leave evicted; caller will get empty content
496
+ }
497
+ }
498
+
230
499
  // ── Mount API ─────────────────────────────────────────────────────────────
231
500
 
232
501
  /**
@@ -359,7 +628,7 @@ class VirtualFileSystem extends EventEmitter {
359
628
  (p) => this.mkdirRecursive(p, 0o755),
360
629
  );
361
630
 
362
- const existing = parent.children.get(name);
631
+ const existing = parent.children[name];
363
632
  if (existing?.type === "directory") {
364
633
  throw new Error(
365
634
  `Cannot write file '${normalized}': path is a directory.`,
@@ -373,20 +642,21 @@ class VirtualFileSystem extends EventEmitter {
373
642
  const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
374
643
  const mode = options.mode ?? 0o644;
375
644
 
376
- if (existing) {
645
+ if (existing && existing.type === "file") {
646
+ // Update real file in place
377
647
  const f = existing as InternalFileNode;
378
648
  f.content = storedContent;
379
649
  f.compressed = shouldCompress;
380
650
  f.mode = mode;
381
- f.updatedAt = new Date();
651
+ f.updatedAt = Date.now();
382
652
  } else {
383
- parent.children.set(
384
- name,
385
- this.makeFile(name, storedContent, mode, shouldCompress),
386
- );
653
+ // Create new real file — also promotes stubs (no _childCount change for stubs)
654
+ if (!existing) parent._childCount++;
655
+ parent.children[name] = this.makeFile(name, storedContent, mode, shouldCompress);
387
656
  }
388
657
 
389
658
  this.emit("file:write", { path: normalized, size: storedContent.length });
659
+ this._journal({ op: JournalOp.WRITE, path: normalized, content: rawContent, mode });
390
660
  }
391
661
 
392
662
  /**
@@ -401,10 +671,15 @@ class VirtualFileSystem extends EventEmitter {
401
671
  }
402
672
  const normalized = normalizePath(targetPath);
403
673
  const node = getNode(this.root, normalized);
674
+ if (node.type === "stub") {
675
+ this.emit("file:read", { path: normalized, size: node.stubContent.length });
676
+ return node.stubContent;
677
+ }
404
678
  if (node.type !== "file") {
405
679
  throw new Error(`Cannot read '${targetPath}': not a file.`);
406
680
  }
407
681
  const f = node as InternalFileNode;
682
+ if (f.evicted) this._reloadEvicted(f, normalized);
408
683
  const raw = f.compressed ? gunzipSync(f.content) : f.content;
409
684
  this.emit("file:read", { path: normalized, size: raw.length });
410
685
  return raw.toString("utf8");
@@ -419,10 +694,16 @@ class VirtualFileSystem extends EventEmitter {
419
694
  }
420
695
  const normalized = normalizePath(targetPath);
421
696
  const node = getNode(this.root, normalized);
697
+ if (node.type === "stub") {
698
+ const buf = Buffer.from(node.stubContent, "utf8");
699
+ this.emit("file:read", { path: normalized, size: buf.length });
700
+ return buf;
701
+ }
422
702
  if (node.type !== "file") {
423
703
  throw new Error(`Cannot read '${targetPath}': not a file.`);
424
704
  }
425
705
  const f = node as InternalFileNode;
706
+ if (f.evicted) this._reloadEvicted(f, normalized);
426
707
  const raw = f.compressed ? gunzipSync(f.content) : f.content;
427
708
  this.emit("file:read", { path: normalized, size: raw.length });
428
709
  return raw;
@@ -442,7 +723,9 @@ class VirtualFileSystem extends EventEmitter {
442
723
 
443
724
  /** Updates mode bits on a node. */
444
725
  public chmod(targetPath: string, mode: number): void {
445
- getNode(this.root, normalizePath(targetPath)).mode = mode;
726
+ const normalized = normalizePath(targetPath);
727
+ getNode(this.root, normalized).mode = mode;
728
+ this._journal({ op: JournalOp.CHMOD, path: normalized, mode });
446
729
  }
447
730
 
448
731
  /** Returns metadata for a file or directory. */
@@ -478,6 +761,19 @@ class VirtualFileSystem extends EventEmitter {
478
761
  const normalized = normalizePath(targetPath);
479
762
  const node = getNode(this.root, normalized);
480
763
  const name = normalized === "/" ? "" : path.posix.basename(normalized);
764
+ if (node.type === "stub") {
765
+ const s = node as InternalStubNode;
766
+ return {
767
+ type: "file",
768
+ name,
769
+ path: normalized,
770
+ mode: s.mode,
771
+ createdAt: new Date(s.createdAt),
772
+ updatedAt: new Date(s.updatedAt),
773
+ compressed: false,
774
+ size: s.stubContent.length,
775
+ };
776
+ }
481
777
  if (node.type === "file") {
482
778
  const f = node as InternalFileNode;
483
779
  return {
@@ -485,10 +781,10 @@ class VirtualFileSystem extends EventEmitter {
485
781
  name,
486
782
  path: normalized,
487
783
  mode: f.mode,
488
- createdAt: f.createdAt,
489
- updatedAt: f.updatedAt,
784
+ createdAt: new Date(f.createdAt),
785
+ updatedAt: new Date(f.updatedAt),
490
786
  compressed: f.compressed,
491
- size: f.content.length,
787
+ size: f.evicted ? (f.size ?? 0) : f.content.length,
492
788
  };
493
789
  }
494
790
  const d = node as InternalDirectoryNode;
@@ -497,9 +793,9 @@ class VirtualFileSystem extends EventEmitter {
497
793
  name,
498
794
  path: normalized,
499
795
  mode: d.mode,
500
- createdAt: d.createdAt,
501
- updatedAt: d.updatedAt,
502
- childrenCount: d.children.size,
796
+ createdAt: new Date(d.createdAt),
797
+ updatedAt: new Date(d.updatedAt),
798
+ childrenCount: d._childCount,
503
799
  };
504
800
  }
505
801
 
@@ -517,7 +813,7 @@ class VirtualFileSystem extends EventEmitter {
517
813
  if (node.type !== "directory") {
518
814
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
519
815
  }
520
- return Array.from((node as InternalDirectoryNode).children.keys()).sort();
816
+ return Object.keys((node as InternalDirectoryNode).children).sort();
521
817
  }
522
818
 
523
819
  /** Renders ASCII tree view of a directory hierarchy. */
@@ -533,10 +829,10 @@ class VirtualFileSystem extends EventEmitter {
533
829
 
534
830
  private renderTreeLines(dir: InternalDirectoryNode, label: string): string {
535
831
  const lines = [label];
536
- const entries = Array.from(dir.children.keys()).sort();
832
+ const entries = Object.keys(dir.children).sort();
537
833
  for (let i = 0; i < entries.length; i++) {
538
834
  const name = entries[i]!;
539
- const child = dir.children.get(name)!;
835
+ const child = dir.children[name]!;
540
836
  const isLast = i === entries.length - 1;
541
837
  const connector = isLast ? "└── " : "├── ";
542
838
  const nextPrefix = isLast ? " " : "│ ";
@@ -559,8 +855,9 @@ class VirtualFileSystem extends EventEmitter {
559
855
 
560
856
  private computeUsage(node: InternalNode): number {
561
857
  if (node.type === "file") return (node as InternalFileNode).content.length;
858
+ if (node.type === "stub") return node.stubContent.length;
562
859
  let total = 0;
563
- for (const child of (node as InternalDirectoryNode).children.values()) {
860
+ for (const child of Object.values((node as InternalDirectoryNode).children)) {
564
861
  total += this.computeUsage(child);
565
862
  }
566
863
  return total;
@@ -575,7 +872,7 @@ class VirtualFileSystem extends EventEmitter {
575
872
  if (!f.compressed) {
576
873
  f.content = gzipSync(f.content);
577
874
  f.compressed = true;
578
- f.updatedAt = new Date();
875
+ f.updatedAt = Date.now();
579
876
  }
580
877
  }
581
878
 
@@ -588,7 +885,7 @@ class VirtualFileSystem extends EventEmitter {
588
885
  if (f.compressed) {
589
886
  f.content = gunzipSync(f.content);
590
887
  f.compressed = false;
591
- f.updatedAt = new Date();
888
+ f.updatedAt = Date.now();
592
889
  }
593
890
  }
594
891
 
@@ -613,10 +910,13 @@ class VirtualFileSystem extends EventEmitter {
613
910
  content: Buffer.from(normalizedTarget, "utf8"),
614
911
  mode: 0o120777,
615
912
  compressed: false,
616
- createdAt: new Date(),
617
- updatedAt: new Date(),
913
+ createdAt: Date.now(),
914
+ updatedAt: Date.now(),
618
915
  };
619
- parent.children.set(name, symNode);
916
+ parent.children[name] = symNode;
917
+ parent._childCount++;
918
+ // Journal before emit
919
+ this._journal({ op: JournalOp.SYMLINK, path: normalizedLink, dest: normalizedTarget });
620
920
  this.emit("symlink:create", {
621
921
  link: normalizedLink,
622
922
  target: normalizedTarget,
@@ -678,7 +978,7 @@ class VirtualFileSystem extends EventEmitter {
678
978
  const node = getNode(this.root, normalized);
679
979
  if (node.type === "directory") {
680
980
  const dir = node as InternalDirectoryNode;
681
- if (!options.recursive && dir.children.size > 0) {
981
+ if (!options.recursive && dir._childCount > 0) {
682
982
  throw new Error(
683
983
  `Directory '${normalized}' is not empty. Use recursive option.`,
684
984
  );
@@ -690,8 +990,9 @@ class VirtualFileSystem extends EventEmitter {
690
990
  false,
691
991
  () => {},
692
992
  );
693
- parent.children.delete(name);
694
- this.emit("node:remove", { path: normalized });
993
+ delete parent.children[name];
994
+ parent._childCount--; this.emit("node:remove", { path: normalized });
995
+ this._journal({ op: JournalOp.REMOVE, path: normalized });
695
996
  }
696
997
 
697
998
  /** Moves or renames a node. */
@@ -718,9 +1019,12 @@ class VirtualFileSystem extends EventEmitter {
718
1019
  false,
719
1020
  () => {},
720
1021
  );
721
- srcParent.children.delete(srcName);
1022
+ delete srcParent.children[srcName];
1023
+ srcParent._childCount--;
722
1024
  node.name = destName;
723
- destParent.children.set(destName, node);
1025
+ destParent.children[destName] = node;
1026
+ destParent._childCount++;
1027
+ this._journal({ op: JournalOp.MOVE, path: fromNormalized, dest: toNormalized });
724
1028
  }
725
1029
 
726
1030
  // ── Snapshot serialisation ─────────────────────────────────────────────────
@@ -737,19 +1041,30 @@ class VirtualFileSystem extends EventEmitter {
737
1041
 
738
1042
  private serializeDir(dir: InternalDirectoryNode): VfsSnapshotDirectoryNode {
739
1043
  const children: VfsSnapshotNode[] = [];
740
- for (const child of dir.children.values()) {
741
- children.push(
742
- child.type === "file"
743
- ? this.serializeFile(child as InternalFileNode)
744
- : this.serializeDir(child as InternalDirectoryNode),
745
- );
1044
+ for (const child of Object.values(dir.children)) {
1045
+ if (child.type === "stub") {
1046
+ // Serialize stub as a regular file node
1047
+ children.push({
1048
+ type: "file",
1049
+ name: child.name,
1050
+ mode: child.mode,
1051
+ createdAt: new Date(child.createdAt).toISOString(),
1052
+ updatedAt: new Date(child.updatedAt).toISOString(),
1053
+ compressed: false,
1054
+ contentBase64: Buffer.from(child.stubContent, "utf8").toString("base64"),
1055
+ } satisfies VfsSnapshotFileNode);
1056
+ } else if (child.type === "file") {
1057
+ children.push(this.serializeFile(child as InternalFileNode));
1058
+ } else {
1059
+ children.push(this.serializeDir(child as InternalDirectoryNode));
1060
+ }
746
1061
  }
747
1062
  return {
748
1063
  type: "directory",
749
1064
  name: dir.name,
750
1065
  mode: dir.mode,
751
- createdAt: dir.createdAt.toISOString(),
752
- updatedAt: dir.updatedAt.toISOString(),
1066
+ createdAt: new Date(dir.createdAt).toISOString(),
1067
+ updatedAt: new Date(dir.updatedAt).toISOString(),
753
1068
  children,
754
1069
  };
755
1070
  }
@@ -759,8 +1074,8 @@ class VirtualFileSystem extends EventEmitter {
759
1074
  type: "file",
760
1075
  name: file.name,
761
1076
  mode: file.mode,
762
- createdAt: file.createdAt.toISOString(),
763
- updatedAt: file.updatedAt.toISOString(),
1077
+ createdAt: new Date(file.createdAt).toISOString(),
1078
+ updatedAt: new Date(file.updatedAt).toISOString(),
764
1079
  compressed: file.compressed,
765
1080
  contentBase64: file.content.toString("base64"),
766
1081
  };
@@ -802,29 +1117,31 @@ class VirtualFileSystem extends EventEmitter {
802
1117
  type: "directory",
803
1118
  name,
804
1119
  mode: snap.mode,
805
- createdAt: new Date(snap.createdAt),
806
- updatedAt: new Date(snap.updatedAt),
807
- children: new Map(),
1120
+ createdAt: Date.parse(snap.createdAt),
1121
+ updatedAt: Date.parse(snap.updatedAt),
1122
+ children: Object.create(null) as Record<string, InternalNode>,
1123
+ _childCount: 0,
808
1124
  };
809
1125
  for (const child of snap.children) {
810
1126
  if (child.type === "file") {
811
1127
  const f = child as VfsSnapshotFileNode;
812
- dir.children.set(f.name, {
1128
+ dir.children[f.name] = {
813
1129
  type: "file",
814
1130
  name: f.name,
815
1131
  mode: f.mode,
816
- createdAt: new Date(f.createdAt),
817
- updatedAt: new Date(f.updatedAt),
1132
+ createdAt: Date.parse(f.createdAt),
1133
+ updatedAt: Date.parse(f.updatedAt),
818
1134
  compressed: f.compressed,
819
1135
  content: Buffer.from(f.contentBase64, "base64"),
820
- });
1136
+ };
821
1137
  } else {
822
1138
  const sub = this.deserializeDir(
823
1139
  child as VfsSnapshotDirectoryNode,
824
1140
  child.name,
825
1141
  );
826
- dir.children.set(child.name, sub);
1142
+ dir.children[child.name] = sub;
827
1143
  }
1144
+ dir._childCount++;
828
1145
  }
829
1146
  return dir;
830
1147
  }