typescript-virtual-container 1.4.7 → 1.4.9

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 (156) hide show
  1. package/README.md +1 -2
  2. package/benchmark-results.txt +21 -21
  3. package/builds/self-standalone.js +979 -168
  4. package/builds/standalone-wo-sftp.js +982 -173
  5. package/builds/standalone.cjs +989 -179
  6. package/builds/web-full-api.min.js +0 -1
  7. package/builds/web.min.js +0 -1
  8. package/dist/Honeypot/index.d.ts +6 -0
  9. package/dist/Honeypot/index.d.ts.map +1 -1
  10. package/dist/Honeypot/index.js +20 -0
  11. package/dist/Honeypot/index.js.map +1 -1
  12. package/dist/SSHMimic/index.d.ts +6 -5
  13. package/dist/SSHMimic/index.d.ts.map +1 -1
  14. package/dist/SSHMimic/index.js +8 -5
  15. package/dist/SSHMimic/index.js.map +1 -1
  16. package/dist/SSHMimic/sftp.d.ts +1 -0
  17. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  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 +25 -0
  23. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  24. package/dist/VirtualFileSystem/index.js +152 -47
  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 +1 -0
  29. package/dist/VirtualFileSystem/journal.d.ts.map +1 -1
  30. package/dist/VirtualFileSystem/journal.js.map +1 -1
  31. package/dist/VirtualFileSystem/path.js +1 -1
  32. package/dist/VirtualFileSystem/path.js.map +1 -1
  33. package/dist/VirtualShell/idleManager.d.ts +69 -0
  34. package/dist/VirtualShell/idleManager.d.ts.map +1 -0
  35. package/dist/VirtualShell/idleManager.js +110 -0
  36. package/dist/VirtualShell/idleManager.js.map +1 -0
  37. package/dist/VirtualShell/index.d.ts +41 -1
  38. package/dist/VirtualShell/index.d.ts.map +1 -1
  39. package/dist/VirtualShell/index.js +54 -1
  40. package/dist/VirtualShell/index.js.map +1 -1
  41. package/dist/VirtualUserManager/index.d.ts +4 -2
  42. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  43. package/dist/VirtualUserManager/index.js +0 -1
  44. package/dist/VirtualUserManager/index.js.map +1 -1
  45. package/dist/commands/command-helpers.d.ts +5 -2
  46. package/dist/commands/command-helpers.d.ts.map +1 -1
  47. package/dist/commands/command-helpers.js.map +1 -1
  48. package/dist/commands/man.d.ts.map +1 -1
  49. package/dist/commands/man.js +5 -28
  50. package/dist/commands/man.js.map +1 -1
  51. package/dist/commands/manuals-bundle.d.ts +11 -0
  52. package/dist/commands/manuals-bundle.d.ts.map +1 -0
  53. package/dist/commands/manuals-bundle.js +898 -0
  54. package/dist/commands/manuals-bundle.js.map +1 -0
  55. package/dist/index.d.ts +7 -2
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +1 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/modules/linuxRootfs.d.ts +8 -1
  60. package/dist/modules/linuxRootfs.d.ts.map +1 -1
  61. package/dist/modules/linuxRootfs.js +47 -14
  62. package/dist/modules/linuxRootfs.js.map +1 -1
  63. package/dist/self-standalone.js +15 -0
  64. package/dist/self-standalone.js.map +1 -1
  65. package/dist/standalone.js +22 -0
  66. package/dist/standalone.js.map +1 -1
  67. package/docs/assets/hierarchy.js +1 -1
  68. package/docs/assets/navigation.js +1 -1
  69. package/docs/assets/search.js +1 -1
  70. package/docs/classes/HoneyPot.html +9 -9
  71. package/docs/classes/IdleManager.html +162 -0
  72. package/docs/classes/SshClient.html +18 -18
  73. package/docs/classes/VirtualFileSystem.html +50 -33
  74. package/docs/classes/VirtualPackageManager.html +13 -13
  75. package/docs/classes/VirtualSftpServer.html +3 -3
  76. package/docs/classes/VirtualShell.html +47 -28
  77. package/docs/classes/VirtualSshServer.html +12 -7
  78. package/docs/classes/VirtualUserManager.html +30 -30
  79. package/docs/functions/assertDiff.html +2 -2
  80. package/docs/functions/diffSnapshots.html +2 -2
  81. package/docs/functions/formatDiff.html +2 -2
  82. package/docs/functions/getArg.html +3 -3
  83. package/docs/functions/getFlag.html +2 -2
  84. package/docs/functions/ifFlag.html +2 -2
  85. package/docs/hierarchy.html +1 -1
  86. package/docs/index.html +4 -5
  87. package/docs/interfaces/AuditLogEntry.html +3 -3
  88. package/docs/interfaces/CommandContext.html +13 -13
  89. package/docs/interfaces/CommandResult.html +13 -13
  90. package/docs/interfaces/ExecStream.html +6 -6
  91. package/docs/interfaces/HoneyPotStats.html +5 -3
  92. package/docs/interfaces/IdleManagerOptions.html +7 -0
  93. package/docs/interfaces/InstalledPackage.html +11 -11
  94. package/docs/interfaces/NanoEditorSession.html +5 -5
  95. package/docs/interfaces/PackageDefinition.html +14 -14
  96. package/docs/interfaces/PackageFile.html +5 -5
  97. package/docs/interfaces/PasswordChallenge.html +16 -0
  98. package/docs/interfaces/RemoveOptions.html +3 -3
  99. package/docs/interfaces/ShellEnv.html +4 -4
  100. package/docs/interfaces/ShellModule.html +8 -8
  101. package/docs/interfaces/ShellProperties.html +5 -5
  102. package/docs/interfaces/ShellStream.html +7 -7
  103. package/docs/interfaces/SudoChallenge.html +9 -9
  104. package/docs/interfaces/VfsBaseNode.html +7 -7
  105. package/docs/interfaces/VfsDiff.html +6 -6
  106. package/docs/interfaces/VfsDiffEntry.html +4 -4
  107. package/docs/interfaces/VfsDiffModified.html +6 -6
  108. package/docs/interfaces/VfsDirectoryNode.html +8 -8
  109. package/docs/interfaces/VfsFileNode.html +9 -9
  110. package/docs/interfaces/VfsOptions.html +18 -4
  111. package/docs/interfaces/VfsSnapshot.html +3 -3
  112. package/docs/interfaces/VfsSnapshotBaseNode.html +4 -4
  113. package/docs/interfaces/VfsSnapshotDirectoryNode.html +5 -5
  114. package/docs/interfaces/VfsSnapshotFileNode.html +6 -6
  115. package/docs/interfaces/VirtualActiveSession.html +12 -0
  116. package/docs/interfaces/VirtualSftpServerOptions.html +7 -0
  117. package/docs/interfaces/VirtualShellVfsLike.html +15 -0
  118. package/docs/interfaces/VirtualShellVfsOptions.html +3 -0
  119. package/docs/interfaces/WriteFileOptions.html +4 -4
  120. package/docs/modules.html +1 -1
  121. package/docs/types/ArgParseOptions.html +4 -0
  122. package/docs/types/CommandMode.html +2 -2
  123. package/docs/types/CommandOutcome.html +2 -2
  124. package/docs/types/IdleState.html +1 -0
  125. package/docs/types/VfsNodeStats.html +2 -2
  126. package/docs/types/VfsNodeType.html +2 -2
  127. package/docs/types/VfsPersistenceMode.html +2 -2
  128. package/docs/types/VfsSnapshotNode.html +2 -2
  129. package/examples/web.min.js +0 -1
  130. package/package.json +13 -12
  131. package/scripts/generate-manuals-bundle.mjs +49 -0
  132. package/scripts/publish-package.sh +1 -1
  133. package/src/Honeypot/index.ts +24 -0
  134. package/src/SSHMimic/index.ts +9 -5
  135. package/src/SSHMimic/sftp.ts +6 -0
  136. package/src/VirtualFileSystem/binaryPack.ts +21 -9
  137. package/src/VirtualFileSystem/index.ts +151 -53
  138. package/src/VirtualFileSystem/internalTypes.ts +24 -4
  139. package/src/VirtualFileSystem/journal.ts +1 -0
  140. package/src/VirtualFileSystem/path.ts +1 -1
  141. package/src/VirtualShell/idleManager.ts +137 -0
  142. package/src/VirtualShell/index.ts +64 -1
  143. package/src/VirtualUserManager/index.ts +4 -2
  144. package/src/commands/command-helpers.ts +5 -1
  145. package/src/commands/man.ts +5 -37
  146. package/src/commands/manuals-bundle.ts +898 -0
  147. package/src/index.ts +7 -1
  148. package/src/modules/linuxRootfs.ts +58 -14
  149. package/src/self-standalone.ts +13 -0
  150. package/src/standalone.ts +23 -0
  151. package/typedoc.json +45 -0
  152. package/builds/self-standalone.js.map +0 -7
  153. package/builds/standalone-wo-sftp.js.map +0 -7
  154. package/builds/standalone.cjs.map +0 -7
  155. package/builds/web-full-api.min.js.map +0 -7
  156. package/builds/web.min.js.map +0 -7
@@ -19,6 +19,7 @@ import type {
19
19
  InternalDirectoryNode,
20
20
  InternalFileNode,
21
21
  InternalNode,
22
+ InternalStubNode,
22
23
  } from "./internalTypes";
23
24
  import { appendJournalEntry, JournalOp, readJournal, truncateJournal } from "./journal";
24
25
  import { getNode, getParentDirectory, normalizePath } from "./path";
@@ -155,14 +156,15 @@ class VirtualFileSystem extends EventEmitter {
155
156
  // ── Internal helpers ──────────────────────────────────────────────────────
156
157
 
157
158
  private makeDir(name: string, mode: number): InternalDirectoryNode {
158
- const now = new Date();
159
+ const now = Date.now();
159
160
  return {
160
161
  type: "directory",
161
162
  name,
162
163
  mode,
163
164
  createdAt: now,
164
165
  updatedAt: now,
165
- children: new Map(),
166
+ children: Object.create(null) as Record<string, InternalNode>,
167
+ _childCount: 0,
166
168
  };
167
169
  }
168
170
 
@@ -172,7 +174,7 @@ class VirtualFileSystem extends EventEmitter {
172
174
  mode: number,
173
175
  compressed: boolean,
174
176
  ): InternalFileNode {
175
- const now = new Date();
177
+ const now = Date.now();
176
178
  return {
177
179
  type: "file",
178
180
  name,
@@ -184,6 +186,35 @@ class VirtualFileSystem extends EventEmitter {
184
186
  };
185
187
  }
186
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
+
187
218
  private mkdirRecursive(targetPath: string, mode: number): void {
188
219
  const normalized = normalizePath(targetPath);
189
220
  if (normalized === "/") return;
@@ -192,10 +223,11 @@ class VirtualFileSystem extends EventEmitter {
192
223
  let builtPath = "";
193
224
  for (const part of parts) {
194
225
  builtPath += `/${part}`;
195
- let child = current.children.get(part);
226
+ let child = current.children[part];
196
227
  if (!child) {
197
228
  child = this.makeDir(part, mode);
198
- current.children.set(part, child);
229
+ current.children[part] = child;
230
+ current._childCount++;
199
231
  this.emit("dir:create", { path: builtPath, mode });
200
232
  this._journal({ op: JournalOp.MKDIR, path: builtPath, mode });
201
233
  } else if (child.type !== "directory") {
@@ -338,7 +370,33 @@ class VirtualFileSystem extends EventEmitter {
338
370
  if (this._dirty) await this.flushMirror();
339
371
  }
340
372
 
341
- // ── WAL Journal helpers ───────────────────────────────────────────────────
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
+ }
342
400
 
343
401
  /** Set to true during journal replay to suppress re-journaling. */
344
402
  private _replayMode = false;
@@ -392,10 +450,10 @@ class VirtualFileSystem extends EventEmitter {
392
450
  }
393
451
 
394
452
  private _evictDir(dir: InternalDirectoryNode): void {
395
- for (const node of dir.children.values()) {
453
+ for (const node of Object.values(dir.children)) {
396
454
  if (node.type === "directory") {
397
455
  this._evictDir(node);
398
- } else if (!node.evicted) {
456
+ } else if (node.type === "file" && !node.evicted) {
399
457
  const rawSize = node.compressed
400
458
  ? (node.size ?? node.content.length * 2) // estimate uncompressed
401
459
  : node.content.length;
@@ -405,6 +463,7 @@ class VirtualFileSystem extends EventEmitter {
405
463
  node.evicted = true;
406
464
  }
407
465
  }
466
+ // stubs: nothing to evict — content is already a plain string, not a Buffer
408
467
  }
409
468
  }
410
469
 
@@ -423,7 +482,7 @@ class VirtualFileSystem extends EventEmitter {
423
482
  let cur: InternalNode = tmpRoot;
424
483
  for (const part of parts) {
425
484
  if (cur.type !== "directory") return;
426
- const next = cur.children.get(part);
485
+ const next: InternalNode | undefined = cur.children[part];
427
486
  if (!next) return;
428
487
  cur = next;
429
488
  }
@@ -569,7 +628,7 @@ class VirtualFileSystem extends EventEmitter {
569
628
  (p) => this.mkdirRecursive(p, 0o755),
570
629
  );
571
630
 
572
- const existing = parent.children.get(name);
631
+ const existing = parent.children[name];
573
632
  if (existing?.type === "directory") {
574
633
  throw new Error(
575
634
  `Cannot write file '${normalized}': path is a directory.`,
@@ -583,17 +642,17 @@ class VirtualFileSystem extends EventEmitter {
583
642
  const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
584
643
  const mode = options.mode ?? 0o644;
585
644
 
586
- if (existing) {
645
+ if (existing && existing.type === "file") {
646
+ // Update real file in place
587
647
  const f = existing as InternalFileNode;
588
648
  f.content = storedContent;
589
649
  f.compressed = shouldCompress;
590
650
  f.mode = mode;
591
- f.updatedAt = new Date();
651
+ f.updatedAt = Date.now();
592
652
  } else {
593
- parent.children.set(
594
- name,
595
- this.makeFile(name, storedContent, mode, shouldCompress),
596
- );
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);
597
656
  }
598
657
 
599
658
  this.emit("file:write", { path: normalized, size: storedContent.length });
@@ -612,6 +671,10 @@ class VirtualFileSystem extends EventEmitter {
612
671
  }
613
672
  const normalized = normalizePath(targetPath);
614
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
+ }
615
678
  if (node.type !== "file") {
616
679
  throw new Error(`Cannot read '${targetPath}': not a file.`);
617
680
  }
@@ -631,6 +694,11 @@ class VirtualFileSystem extends EventEmitter {
631
694
  }
632
695
  const normalized = normalizePath(targetPath);
633
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
+ }
634
702
  if (node.type !== "file") {
635
703
  throw new Error(`Cannot read '${targetPath}': not a file.`);
636
704
  }
@@ -693,6 +761,19 @@ class VirtualFileSystem extends EventEmitter {
693
761
  const normalized = normalizePath(targetPath);
694
762
  const node = getNode(this.root, normalized);
695
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
+ }
696
777
  if (node.type === "file") {
697
778
  const f = node as InternalFileNode;
698
779
  return {
@@ -700,8 +781,8 @@ class VirtualFileSystem extends EventEmitter {
700
781
  name,
701
782
  path: normalized,
702
783
  mode: f.mode,
703
- createdAt: f.createdAt,
704
- updatedAt: f.updatedAt,
784
+ createdAt: new Date(f.createdAt),
785
+ updatedAt: new Date(f.updatedAt),
705
786
  compressed: f.compressed,
706
787
  size: f.evicted ? (f.size ?? 0) : f.content.length,
707
788
  };
@@ -712,9 +793,9 @@ class VirtualFileSystem extends EventEmitter {
712
793
  name,
713
794
  path: normalized,
714
795
  mode: d.mode,
715
- createdAt: d.createdAt,
716
- updatedAt: d.updatedAt,
717
- childrenCount: d.children.size,
796
+ createdAt: new Date(d.createdAt),
797
+ updatedAt: new Date(d.updatedAt),
798
+ childrenCount: d._childCount,
718
799
  };
719
800
  }
720
801
 
@@ -732,7 +813,7 @@ class VirtualFileSystem extends EventEmitter {
732
813
  if (node.type !== "directory") {
733
814
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
734
815
  }
735
- return Array.from((node as InternalDirectoryNode).children.keys()).sort();
816
+ return Object.keys((node as InternalDirectoryNode).children).sort();
736
817
  }
737
818
 
738
819
  /** Renders ASCII tree view of a directory hierarchy. */
@@ -748,10 +829,10 @@ class VirtualFileSystem extends EventEmitter {
748
829
 
749
830
  private renderTreeLines(dir: InternalDirectoryNode, label: string): string {
750
831
  const lines = [label];
751
- const entries = Array.from(dir.children.keys()).sort();
832
+ const entries = Object.keys(dir.children).sort();
752
833
  for (let i = 0; i < entries.length; i++) {
753
834
  const name = entries[i]!;
754
- const child = dir.children.get(name)!;
835
+ const child = dir.children[name]!;
755
836
  const isLast = i === entries.length - 1;
756
837
  const connector = isLast ? "└── " : "├── ";
757
838
  const nextPrefix = isLast ? " " : "│ ";
@@ -774,8 +855,9 @@ class VirtualFileSystem extends EventEmitter {
774
855
 
775
856
  private computeUsage(node: InternalNode): number {
776
857
  if (node.type === "file") return (node as InternalFileNode).content.length;
858
+ if (node.type === "stub") return node.stubContent.length;
777
859
  let total = 0;
778
- for (const child of (node as InternalDirectoryNode).children.values()) {
860
+ for (const child of Object.values((node as InternalDirectoryNode).children)) {
779
861
  total += this.computeUsage(child);
780
862
  }
781
863
  return total;
@@ -790,7 +872,7 @@ class VirtualFileSystem extends EventEmitter {
790
872
  if (!f.compressed) {
791
873
  f.content = gzipSync(f.content);
792
874
  f.compressed = true;
793
- f.updatedAt = new Date();
875
+ f.updatedAt = Date.now();
794
876
  }
795
877
  }
796
878
 
@@ -803,7 +885,7 @@ class VirtualFileSystem extends EventEmitter {
803
885
  if (f.compressed) {
804
886
  f.content = gunzipSync(f.content);
805
887
  f.compressed = false;
806
- f.updatedAt = new Date();
888
+ f.updatedAt = Date.now();
807
889
  }
808
890
  }
809
891
 
@@ -828,10 +910,11 @@ class VirtualFileSystem extends EventEmitter {
828
910
  content: Buffer.from(normalizedTarget, "utf8"),
829
911
  mode: 0o120777,
830
912
  compressed: false,
831
- createdAt: new Date(),
832
- updatedAt: new Date(),
913
+ createdAt: Date.now(),
914
+ updatedAt: Date.now(),
833
915
  };
834
- parent.children.set(name, symNode);
916
+ parent.children[name] = symNode;
917
+ parent._childCount++;
835
918
  // Journal before emit
836
919
  this._journal({ op: JournalOp.SYMLINK, path: normalizedLink, dest: normalizedTarget });
837
920
  this.emit("symlink:create", {
@@ -895,7 +978,7 @@ class VirtualFileSystem extends EventEmitter {
895
978
  const node = getNode(this.root, normalized);
896
979
  if (node.type === "directory") {
897
980
  const dir = node as InternalDirectoryNode;
898
- if (!options.recursive && dir.children.size > 0) {
981
+ if (!options.recursive && dir._childCount > 0) {
899
982
  throw new Error(
900
983
  `Directory '${normalized}' is not empty. Use recursive option.`,
901
984
  );
@@ -907,8 +990,8 @@ class VirtualFileSystem extends EventEmitter {
907
990
  false,
908
991
  () => {},
909
992
  );
910
- parent.children.delete(name);
911
- this.emit("node:remove", { path: normalized });
993
+ delete parent.children[name];
994
+ parent._childCount--; this.emit("node:remove", { path: normalized });
912
995
  this._journal({ op: JournalOp.REMOVE, path: normalized });
913
996
  }
914
997
 
@@ -936,9 +1019,11 @@ class VirtualFileSystem extends EventEmitter {
936
1019
  false,
937
1020
  () => {},
938
1021
  );
939
- srcParent.children.delete(srcName);
1022
+ delete srcParent.children[srcName];
1023
+ srcParent._childCount--;
940
1024
  node.name = destName;
941
- destParent.children.set(destName, node);
1025
+ destParent.children[destName] = node;
1026
+ destParent._childCount++;
942
1027
  this._journal({ op: JournalOp.MOVE, path: fromNormalized, dest: toNormalized });
943
1028
  }
944
1029
 
@@ -956,19 +1041,30 @@ class VirtualFileSystem extends EventEmitter {
956
1041
 
957
1042
  private serializeDir(dir: InternalDirectoryNode): VfsSnapshotDirectoryNode {
958
1043
  const children: VfsSnapshotNode[] = [];
959
- for (const child of dir.children.values()) {
960
- children.push(
961
- child.type === "file"
962
- ? this.serializeFile(child as InternalFileNode)
963
- : this.serializeDir(child as InternalDirectoryNode),
964
- );
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
+ }
965
1061
  }
966
1062
  return {
967
1063
  type: "directory",
968
1064
  name: dir.name,
969
1065
  mode: dir.mode,
970
- createdAt: dir.createdAt.toISOString(),
971
- updatedAt: dir.updatedAt.toISOString(),
1066
+ createdAt: new Date(dir.createdAt).toISOString(),
1067
+ updatedAt: new Date(dir.updatedAt).toISOString(),
972
1068
  children,
973
1069
  };
974
1070
  }
@@ -978,8 +1074,8 @@ class VirtualFileSystem extends EventEmitter {
978
1074
  type: "file",
979
1075
  name: file.name,
980
1076
  mode: file.mode,
981
- createdAt: file.createdAt.toISOString(),
982
- updatedAt: file.updatedAt.toISOString(),
1077
+ createdAt: new Date(file.createdAt).toISOString(),
1078
+ updatedAt: new Date(file.updatedAt).toISOString(),
983
1079
  compressed: file.compressed,
984
1080
  contentBase64: file.content.toString("base64"),
985
1081
  };
@@ -1021,29 +1117,31 @@ class VirtualFileSystem extends EventEmitter {
1021
1117
  type: "directory",
1022
1118
  name,
1023
1119
  mode: snap.mode,
1024
- createdAt: new Date(snap.createdAt),
1025
- updatedAt: new Date(snap.updatedAt),
1026
- 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,
1027
1124
  };
1028
1125
  for (const child of snap.children) {
1029
1126
  if (child.type === "file") {
1030
1127
  const f = child as VfsSnapshotFileNode;
1031
- dir.children.set(f.name, {
1128
+ dir.children[f.name] = {
1032
1129
  type: "file",
1033
1130
  name: f.name,
1034
1131
  mode: f.mode,
1035
- createdAt: new Date(f.createdAt),
1036
- updatedAt: new Date(f.updatedAt),
1132
+ createdAt: Date.parse(f.createdAt),
1133
+ updatedAt: Date.parse(f.updatedAt),
1037
1134
  compressed: f.compressed,
1038
1135
  content: Buffer.from(f.contentBase64, "base64"),
1039
- });
1136
+ };
1040
1137
  } else {
1041
1138
  const sub = this.deserializeDir(
1042
1139
  child as VfsSnapshotDirectoryNode,
1043
1140
  child.name,
1044
1141
  );
1045
- dir.children.set(child.name, sub);
1142
+ dir.children[child.name] = sub;
1046
1143
  }
1144
+ dir._childCount++;
1047
1145
  }
1048
1146
  return dir;
1049
1147
  }
@@ -1,12 +1,16 @@
1
- export type InternalNode = InternalFileNode | InternalDirectoryNode;
1
+ /** @internal */
2
+ export type InternalNode = InternalFileNode | InternalStubNode | InternalDirectoryNode;
2
3
 
3
4
  interface InternalBaseNode {
4
5
  name: string;
5
6
  mode: number;
6
- createdAt: Date;
7
- updatedAt: Date;
7
+ /** Unix timestamp in ms — avoids Date object overhead (~80 bytes each). */
8
+ createdAt: number;
9
+ /** Unix timestamp in ms. */
10
+ updatedAt: number;
8
11
  }
9
12
 
13
+ /** @internal */
10
14
  export interface InternalFileNode extends InternalBaseNode {
11
15
  type: "file";
12
16
  content: Buffer;
@@ -17,7 +21,23 @@ export interface InternalFileNode extends InternalBaseNode {
17
21
  size?: number;
18
22
  }
19
23
 
24
+ /**
25
+ * Lazy stub — stores static rootfs file content as a plain string.
26
+ * No Buffer allocation until the file is actually read or written.
27
+ * On first write, promoted to a real InternalFileNode.
28
+ * @internal
29
+ */
30
+ export interface InternalStubNode extends InternalBaseNode {
31
+ type: "stub";
32
+ /** Raw UTF-8 content — never compressed, never evicted. */
33
+ stubContent: string;
34
+ }
35
+
36
+ /** @internal */
20
37
  export interface InternalDirectoryNode extends InternalBaseNode {
21
38
  type: "directory";
22
- children: Map<string, InternalNode>;
39
+ /** Null-prototype object — avoids Map overhead (~40% less RAM per entry). */
40
+ children: Record<string, InternalNode>;
41
+ /** Cached child count — avoids O(n) Object.keys() on hot paths. */
42
+ _childCount: number;
23
43
  }
@@ -32,6 +32,7 @@ export const JournalOp = {
32
32
 
33
33
  export type JournalOp = typeof JournalOp[keyof typeof JournalOp];
34
34
 
35
+ /** @internal */
35
36
  export interface JournalEntry {
36
37
  op: JournalOp;
37
38
  path: string;
@@ -33,7 +33,7 @@ export function getNode(
33
33
  throw new Error(`Path '${normalized}' does not exist.`);
34
34
  }
35
35
 
36
- const next = current.children.get(part);
36
+ const next: InternalNode | undefined = current.children[part];
37
37
  if (!next) {
38
38
  throw new Error(`Path '${normalized}' does not exist.`);
39
39
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * IdleManager — cold-start / freeze-thaw system for VirtualShell.
3
+ *
4
+ * When a shell has had no activity for `idleThresholdMs`, the VFS tree is
5
+ * serialised to a compact binary buffer and the in-memory tree is released.
6
+ * On the next command the tree is reconstructed in ~0.1 ms.
7
+ *
8
+ * Memory freed per idle shell: the full JS object graph (~250+ InternalNodes
9
+ * for a typical rootfs + user files). The frozen buffer itself stays in RAM
10
+ * but is ~27% smaller than the live tree and GC-friendly (one flat Buffer).
11
+ *
12
+ * CPU freed: the VFS auto-flush setInterval is suspended while frozen.
13
+ *
14
+ * Usage:
15
+ * ```ts
16
+ * const idle = new IdleManager(shell, { idleThresholdMs: 60_000 });
17
+ * idle.start();
18
+ * // call idle.ping() on every shell activity (exec, keypress, …)
19
+ * // call idle.stop() on shell destroy
20
+ * ```
21
+ */
22
+
23
+ import { EventEmitter } from "node:events";
24
+ import type VirtualFileSystem from "../VirtualFileSystem";
25
+ import { decodeVfs } from "../VirtualFileSystem/binaryPack";
26
+
27
+ export interface IdleManagerOptions {
28
+ /**
29
+ * Milliseconds of inactivity before the shell is frozen.
30
+ * Default: 60_000 (1 minute).
31
+ */
32
+ idleThresholdMs?: number;
33
+ /**
34
+ * How often the idle check runs.
35
+ * Default: 15_000 (15 seconds).
36
+ */
37
+ checkIntervalMs?: number;
38
+ }
39
+
40
+ export type IdleState = "active" | "frozen";
41
+
42
+ export class IdleManager extends EventEmitter {
43
+ private readonly vfs: VirtualFileSystem;
44
+ private readonly idleThresholdMs: number;
45
+ private readonly checkIntervalMs: number;
46
+
47
+ private _state: IdleState = "active";
48
+ private _lastActivity = Date.now();
49
+ private _frozenBuffer: Buffer | null = null;
50
+ private _checkTimer: ReturnType<typeof setInterval> | null = null;
51
+
52
+ /** Emitted when the shell is frozen (VFS tree released). */
53
+ declare on: ((event: "freeze", listener: () => void) => this) &
54
+ ((event: "thaw", listener: () => void) => this) &
55
+ ((event: string, listener: (...args: unknown[]) => void) => this);
56
+
57
+ constructor(vfs: VirtualFileSystem, options: IdleManagerOptions = {}) {
58
+ super();
59
+ this.vfs = vfs;
60
+ this.idleThresholdMs = options.idleThresholdMs ?? 60_000;
61
+ this.checkIntervalMs = options.checkIntervalMs ?? 15_000;
62
+ }
63
+
64
+ /** Start monitoring for idle. Call once after shell initialisation. */
65
+ public start(): void {
66
+ if (this._checkTimer) return;
67
+ this._lastActivity = Date.now();
68
+ this._checkTimer = setInterval(() => this._check(), this.checkIntervalMs);
69
+ // Don't block process exit
70
+ if (typeof this._checkTimer === "object" && this._checkTimer !== null && "unref" in this._checkTimer) {
71
+ (this._checkTimer as NodeJS.Timeout).unref();
72
+ }
73
+ }
74
+
75
+ /** Stop monitoring and thaw if frozen. Call on shell destroy. */
76
+ public async stop(): Promise<void> {
77
+ if (this._checkTimer) {
78
+ clearInterval(this._checkTimer);
79
+ this._checkTimer = null;
80
+ }
81
+ if (this._state === "frozen") this._thaw();
82
+ }
83
+
84
+ /**
85
+ * Signal activity — resets the idle clock and thaws synchronously if frozen.
86
+ * Call this before every exec / keypress / session event.
87
+ *
88
+ * Thaw is intentionally synchronous: decodeVfs() is a pure CPU operation
89
+ * (~0.07 ms) with no I/O, so it is safe to block briefly on the hot path.
90
+ * This guarantees the VFS tree is fully restored before any command runs.
91
+ */
92
+ public ping(): void {
93
+ this._lastActivity = Date.now();
94
+ if (this._state === "frozen") this._thaw();
95
+ }
96
+
97
+ /** Current idle state. */
98
+ public get state(): IdleState {
99
+ return this._state;
100
+ }
101
+
102
+ /** Ms since last activity. */
103
+ public get idleMs(): number {
104
+ return Date.now() - this._lastActivity;
105
+ }
106
+
107
+ // ── Internal ──────────────────────────────────────────────────────────────
108
+
109
+ private _check(): void {
110
+ if (this._state === "frozen") return; // already frozen
111
+ if (Date.now() - this._lastActivity >= this.idleThresholdMs) {
112
+ void this._freeze();
113
+ }
114
+ }
115
+
116
+ private async _freeze(): Promise<void> {
117
+ if (this._state === "frozen") return;
118
+ // Flush any pending writes before freezing
119
+ await this.vfs.stopAutoFlush();
120
+ // Serialise the live tree to a compact binary buffer
121
+ this._frozenBuffer = this.vfs.encodeBinary();
122
+ // Release the live tree — GC can now collect all InternalNode objects
123
+ this.vfs.releaseTree();
124
+ this._state = "frozen";
125
+ this.emit("freeze");
126
+ }
127
+
128
+ private _thaw(): void {
129
+ if (this._state !== "frozen" || !this._frozenBuffer) return;
130
+ // Reconstruct the tree from the frozen buffer (~0.07 ms — pure CPU, no I/O)
131
+ const root = decodeVfs(this._frozenBuffer);
132
+ this.vfs.importRootTree(root);
133
+ this._frozenBuffer = null;
134
+ this._state = "active";
135
+ this.emit("thaw");
136
+ }
137
+ }