typescript-virtual-container 1.4.7 → 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.
- package/README.md +0 -1
- package/benchmark-results.txt +21 -21
- package/builds/self-standalone.js +979 -167
- package/builds/self-standalone.js.map +4 -4
- package/builds/standalone-wo-sftp.js +982 -172
- package/builds/standalone-wo-sftp.js.map +4 -4
- package/builds/standalone.cjs +990 -179
- package/builds/standalone.cjs.map +4 -4
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +2 -0
- package/dist/SSHMimic/index.js.map +1 -1
- package/dist/VirtualFileSystem/binaryPack.d.ts.map +1 -1
- package/dist/VirtualFileSystem/binaryPack.js +21 -9
- package/dist/VirtualFileSystem/binaryPack.js.map +1 -1
- package/dist/VirtualFileSystem/index.d.ts +25 -0
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +152 -47
- package/dist/VirtualFileSystem/index.js.map +1 -1
- package/dist/VirtualFileSystem/internalTypes.d.ts +19 -4
- package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -1
- package/dist/VirtualFileSystem/path.js +1 -1
- package/dist/VirtualFileSystem/path.js.map +1 -1
- package/dist/VirtualShell/idleManager.d.ts +65 -0
- package/dist/VirtualShell/idleManager.d.ts.map +1 -0
- package/dist/VirtualShell/idleManager.js +106 -0
- package/dist/VirtualShell/idleManager.js.map +1 -0
- package/dist/VirtualShell/index.d.ts +28 -0
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +48 -0
- package/dist/VirtualShell/index.js.map +1 -1
- package/dist/commands/man.d.ts.map +1 -1
- package/dist/commands/man.js +5 -28
- package/dist/commands/man.js.map +1 -1
- package/dist/commands/manuals-bundle.d.ts +11 -0
- package/dist/commands/manuals-bundle.d.ts.map +1 -0
- package/dist/commands/manuals-bundle.js +898 -0
- package/dist/commands/manuals-bundle.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/linuxRootfs.d.ts +8 -1
- package/dist/modules/linuxRootfs.d.ts.map +1 -1
- package/dist/modules/linuxRootfs.js +47 -14
- package/dist/modules/linuxRootfs.js.map +1 -1
- package/dist/self-standalone.js +15 -0
- package/dist/self-standalone.js.map +1 -1
- package/dist/standalone.js +22 -0
- package/dist/standalone.js.map +1 -1
- package/docs/assets/hierarchy.js +1 -1
- package/docs/assets/navigation.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/classes/HoneyPot.html +8 -8
- package/docs/classes/IdleManager.html +159 -0
- package/docs/classes/SshClient.html +18 -18
- package/docs/classes/VirtualFileSystem.html +57 -32
- package/docs/classes/VirtualPackageManager.html +12 -12
- package/docs/classes/VirtualSftpServer.html +3 -3
- package/docs/classes/VirtualShell.html +40 -25
- package/docs/classes/VirtualSshServer.html +5 -5
- package/docs/classes/VirtualUserManager.html +26 -26
- package/docs/functions/assertDiff.html +1 -1
- package/docs/functions/diffSnapshots.html +1 -1
- package/docs/functions/formatDiff.html +1 -1
- package/docs/functions/getArg.html +1 -1
- package/docs/functions/getFlag.html +1 -1
- package/docs/functions/ifFlag.html +1 -1
- package/docs/hierarchy.html +1 -1
- package/docs/index.html +1 -2
- package/docs/interfaces/AuditLogEntry.html +2 -2
- package/docs/interfaces/CommandContext.html +11 -11
- package/docs/interfaces/CommandResult.html +12 -12
- package/docs/interfaces/ExecStream.html +5 -5
- package/docs/interfaces/HoneyPotStats.html +2 -2
- package/docs/interfaces/IdleManagerOptions.html +7 -0
- package/docs/interfaces/InstalledPackage.html +10 -10
- package/docs/interfaces/NanoEditorSession.html +4 -4
- package/docs/interfaces/PackageDefinition.html +13 -13
- package/docs/interfaces/PackageFile.html +4 -4
- package/docs/interfaces/RemoveOptions.html +2 -2
- package/docs/interfaces/ShellEnv.html +3 -3
- package/docs/interfaces/ShellModule.html +7 -7
- package/docs/interfaces/ShellProperties.html +4 -4
- package/docs/interfaces/ShellStream.html +6 -6
- package/docs/interfaces/SudoChallenge.html +8 -8
- package/docs/interfaces/VfsBaseNode.html +6 -6
- package/docs/interfaces/VfsDiff.html +5 -5
- package/docs/interfaces/VfsDiffEntry.html +3 -3
- package/docs/interfaces/VfsDiffModified.html +5 -5
- package/docs/interfaces/VfsDirectoryNode.html +7 -7
- package/docs/interfaces/VfsFileNode.html +8 -8
- package/docs/interfaces/VfsOptions.html +18 -4
- package/docs/interfaces/VfsSnapshot.html +2 -2
- package/docs/interfaces/VfsSnapshotBaseNode.html +3 -3
- package/docs/interfaces/VfsSnapshotDirectoryNode.html +4 -4
- package/docs/interfaces/VfsSnapshotFileNode.html +5 -5
- package/docs/interfaces/WriteFileOptions.html +3 -3
- package/docs/modules.html +1 -1
- package/docs/types/CommandMode.html +1 -1
- package/docs/types/CommandOutcome.html +1 -1
- package/docs/types/IdleState.html +1 -0
- package/docs/types/VfsNodeStats.html +1 -1
- package/docs/types/VfsNodeType.html +1 -1
- package/docs/types/VfsPersistenceMode.html +1 -1
- package/docs/types/VfsSnapshotNode.html +1 -1
- package/package.json +5 -4
- package/scripts/generate-manuals-bundle.mjs +49 -0
- package/src/SSHMimic/index.ts +2 -0
- package/src/VirtualFileSystem/binaryPack.ts +21 -9
- package/src/VirtualFileSystem/index.ts +151 -53
- package/src/VirtualFileSystem/internalTypes.ts +20 -4
- package/src/VirtualFileSystem/path.ts +1 -1
- package/src/VirtualShell/idleManager.ts +133 -0
- package/src/VirtualShell/index.ts +48 -0
- package/src/commands/man.ts +5 -37
- package/src/commands/manuals-bundle.ts +898 -0
- package/src/index.ts +2 -0
- package/src/modules/linuxRootfs.ts +58 -14
- package/src/self-standalone.ts +13 -0
- package/src/standalone.ts +23 -0
|
@@ -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 =
|
|
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:
|
|
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 =
|
|
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
|
|
226
|
+
let child = current.children[part];
|
|
196
227
|
if (!child) {
|
|
197
228
|
child = this.makeDir(part, mode);
|
|
198
|
-
current.children
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
651
|
+
f.updatedAt = Date.now();
|
|
592
652
|
} else {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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:
|
|
832
|
-
updatedAt:
|
|
913
|
+
createdAt: Date.now(),
|
|
914
|
+
updatedAt: Date.now(),
|
|
833
915
|
};
|
|
834
|
-
parent.children
|
|
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.
|
|
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
|
|
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
|
|
1022
|
+
delete srcParent.children[srcName];
|
|
1023
|
+
srcParent._childCount--;
|
|
940
1024
|
node.name = destName;
|
|
941
|
-
destParent.children
|
|
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
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
:
|
|
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:
|
|
1025
|
-
updatedAt:
|
|
1026
|
-
children:
|
|
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
|
|
1128
|
+
dir.children[f.name] = {
|
|
1032
1129
|
type: "file",
|
|
1033
1130
|
name: f.name,
|
|
1034
1131
|
mode: f.mode,
|
|
1035
|
-
createdAt:
|
|
1036
|
-
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
|
|
1142
|
+
dir.children[child.name] = sub;
|
|
1046
1143
|
}
|
|
1144
|
+
dir._childCount++;
|
|
1047
1145
|
}
|
|
1048
1146
|
return dir;
|
|
1049
1147
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
export type InternalNode = InternalFileNode | InternalDirectoryNode;
|
|
1
|
+
export type InternalNode = InternalFileNode | InternalStubNode | InternalDirectoryNode;
|
|
2
2
|
|
|
3
3
|
interface InternalBaseNode {
|
|
4
4
|
name: string;
|
|
5
5
|
mode: number;
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/** Unix timestamp in ms — avoids Date object overhead (~80 bytes each). */
|
|
7
|
+
createdAt: number;
|
|
8
|
+
/** Unix timestamp in ms. */
|
|
9
|
+
updatedAt: number;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export interface InternalFileNode extends InternalBaseNode {
|
|
@@ -17,7 +19,21 @@ export interface InternalFileNode extends InternalBaseNode {
|
|
|
17
19
|
size?: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Lazy stub — stores static rootfs file content as a plain string.
|
|
24
|
+
* No Buffer allocation until the file is actually read or written.
|
|
25
|
+
* On first write, promoted to a real InternalFileNode.
|
|
26
|
+
*/
|
|
27
|
+
export interface InternalStubNode extends InternalBaseNode {
|
|
28
|
+
type: "stub";
|
|
29
|
+
/** Raw UTF-8 content — never compressed, never evicted. */
|
|
30
|
+
stubContent: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
export interface InternalDirectoryNode extends InternalBaseNode {
|
|
21
34
|
type: "directory";
|
|
22
|
-
|
|
35
|
+
/** Null-prototype object — avoids Map overhead (~40% less RAM per entry). */
|
|
36
|
+
children: Record<string, InternalNode>;
|
|
37
|
+
/** Cached child count — avoids O(n) Object.keys() on hot paths. */
|
|
38
|
+
_childCount: number;
|
|
23
39
|
}
|
|
@@ -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
|
|
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,133 @@
|
|
|
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") await this._thaw();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Signal activity — resets the idle clock and thaws if frozen.
|
|
86
|
+
* Call this before every exec / keypress / session event.
|
|
87
|
+
*/
|
|
88
|
+
public async ping(): Promise<void> {
|
|
89
|
+
this._lastActivity = Date.now();
|
|
90
|
+
if (this._state === "frozen") await this._thaw();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Current idle state. */
|
|
94
|
+
public get state(): IdleState {
|
|
95
|
+
return this._state;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Ms since last activity. */
|
|
99
|
+
public get idleMs(): number {
|
|
100
|
+
return Date.now() - this._lastActivity;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
private _check(): void {
|
|
106
|
+
if (this._state === "frozen") return; // already frozen
|
|
107
|
+
if (Date.now() - this._lastActivity >= this.idleThresholdMs) {
|
|
108
|
+
void this._freeze();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async _freeze(): Promise<void> {
|
|
113
|
+
if (this._state === "frozen") return;
|
|
114
|
+
// Flush any pending writes before freezing
|
|
115
|
+
await this.vfs.stopAutoFlush();
|
|
116
|
+
// Serialise the live tree to a compact binary buffer
|
|
117
|
+
this._frozenBuffer = this.vfs.encodeBinary();
|
|
118
|
+
// Release the live tree — GC can now collect all InternalNode objects
|
|
119
|
+
this.vfs.releaseTree();
|
|
120
|
+
this._state = "frozen";
|
|
121
|
+
this.emit("freeze");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async _thaw(): Promise<void> {
|
|
125
|
+
if (this._state !== "frozen" || !this._frozenBuffer) return;
|
|
126
|
+
// Reconstruct the tree from the frozen buffer (~0.07 ms for typical rootfs)
|
|
127
|
+
const root = decodeVfs(this._frozenBuffer);
|
|
128
|
+
this.vfs.importRootTree(root);
|
|
129
|
+
this._frozenBuffer = null;
|
|
130
|
+
this._state = "active";
|
|
131
|
+
this.emit("thaw");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -14,6 +14,7 @@ import { createPerfLogger } from "../utils/perfLogger";
|
|
|
14
14
|
import VirtualFileSystem, { type VfsOptions } from "../VirtualFileSystem";
|
|
15
15
|
import { VirtualPackageManager } from "../VirtualPackageManager";
|
|
16
16
|
import { VirtualUserManager } from "../VirtualUserManager";
|
|
17
|
+
import { IdleManager, type IdleManagerOptions } from "./idleManager";
|
|
17
18
|
import { startShell } from "./shell";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -143,6 +144,8 @@ class VirtualShell extends EventEmitter {
|
|
|
143
144
|
properties: ShellProperties;
|
|
144
145
|
/** Unix ms timestamp of shell creation — used by `uptime` and `/proc/uptime`. */
|
|
145
146
|
startTime: number;
|
|
147
|
+
/** Idle / cold-start manager — null until `enableIdleManagement()` is called. */
|
|
148
|
+
private _idle: IdleManager | null = null;
|
|
146
149
|
private initialized: Promise<void>;
|
|
147
150
|
|
|
148
151
|
/**
|
|
@@ -236,6 +239,7 @@ class VirtualShell extends EventEmitter {
|
|
|
236
239
|
*/
|
|
237
240
|
executeCommand(rawInput: string, authUser: string, cwd: string): void {
|
|
238
241
|
perf.mark("executeCommand");
|
|
242
|
+
if (this._idle) void this._idle.ping();
|
|
239
243
|
runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
|
|
240
244
|
this.emit("command", { command: rawInput, user: authUser, cwd });
|
|
241
245
|
}
|
|
@@ -262,6 +266,7 @@ class VirtualShell extends EventEmitter {
|
|
|
262
266
|
terminalSize: { cols: number; rows: number },
|
|
263
267
|
): void {
|
|
264
268
|
perf.mark("startInteractiveSession");
|
|
269
|
+
if (this._idle) void this._idle.ping();
|
|
265
270
|
// Interactive shell logic
|
|
266
271
|
this.emit("session:start", { user: authUser, sessionId, remoteAddress });
|
|
267
272
|
startShell(
|
|
@@ -404,6 +409,49 @@ class VirtualShell extends EventEmitter {
|
|
|
404
409
|
this.users.assertWriteWithinQuota(authUser, targetPath, content);
|
|
405
410
|
this.vfs.writeFile(targetPath, content);
|
|
406
411
|
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Enable idle detection and cold-start freeze/thaw for this shell.
|
|
415
|
+
*
|
|
416
|
+
* After `idleThresholdMs` of inactivity the VFS tree is serialised and
|
|
417
|
+
* released from RAM. The next command transparently restores it in ~0.1 ms.
|
|
418
|
+
*
|
|
419
|
+
* @example
|
|
420
|
+
* ```ts
|
|
421
|
+
* await shell.ensureInitialized();
|
|
422
|
+
* shell.enableIdleManagement({ idleThresholdMs: 60_000 });
|
|
423
|
+
* ```
|
|
424
|
+
*/
|
|
425
|
+
public enableIdleManagement(options?: IdleManagerOptions): void {
|
|
426
|
+
if (this._idle) return; // already enabled
|
|
427
|
+
this._idle = new IdleManager(this.vfs, options);
|
|
428
|
+
this._idle.on("freeze", () => this.emit("shell:freeze"));
|
|
429
|
+
this._idle.on("thaw", () => this.emit("shell:thaw"));
|
|
430
|
+
this._idle.start();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Disable idle management and thaw the shell if currently frozen.
|
|
435
|
+
* Safe to call even if idle management was never enabled.
|
|
436
|
+
*/
|
|
437
|
+
public async disableIdleManagement(): Promise<void> {
|
|
438
|
+
if (!this._idle) return;
|
|
439
|
+
await this._idle.stop();
|
|
440
|
+
this._idle = null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Current idle state — `"active"` or `"frozen"`.
|
|
445
|
+
* Returns `"active"` when idle management is disabled.
|
|
446
|
+
*/
|
|
447
|
+
public get idleState(): "active" | "frozen" {
|
|
448
|
+
return this._idle?.state ?? "active";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Milliseconds since last shell activity. 0 when idle management is disabled. */
|
|
452
|
+
public get idleMs(): number {
|
|
453
|
+
return this._idle?.idleMs ?? 0;
|
|
454
|
+
}
|
|
407
455
|
}
|
|
408
456
|
|
|
409
457
|
export { VirtualShell };
|