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.
- package/.vscode/settings.json +1 -0
- package/README.md +0 -1
- package/benchmark-results.txt +21 -21
- package/builds/self-standalone.js +1111 -299
- package/builds/self-standalone.js.map +4 -4
- package/builds/standalone-wo-sftp.js +993 -183
- package/builds/standalone-wo-sftp.js.map +4 -4
- package/builds/standalone.cjs +984 -173
- package/builds/standalone.cjs.map +4 -4
- package/dist/SSHMimic/exec.d.ts.map +1 -1
- package/dist/SSHMimic/exec.js +0 -1
- package/dist/SSHMimic/exec.js.map +1 -1
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +3 -1
- package/dist/SSHMimic/index.js.map +1 -1
- package/dist/SSHMimic/sftp.d.ts.map +1 -1
- package/dist/SSHMimic/sftp.js +0 -6
- package/dist/SSHMimic/sftp.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 +93 -0
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +361 -46
- package/dist/VirtualFileSystem/index.js.map +1 -1
- package/dist/VirtualFileSystem/internalTypes.d.ts +23 -4
- package/dist/VirtualFileSystem/internalTypes.d.ts.map +1 -1
- package/dist/VirtualFileSystem/journal.d.ts +47 -0
- package/dist/VirtualFileSystem/journal.d.ts.map +1 -0
- package/dist/VirtualFileSystem/journal.js +178 -0
- package/dist/VirtualFileSystem/journal.js.map +1 -0
- 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/VirtualShell/shell.js +4 -4
- package/dist/VirtualShell/shell.js.map +1 -1
- package/dist/commands/man.d.ts.map +1 -1
- package/dist/commands/man.js +5 -27
- 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 +16 -1
- 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/exec.ts +0 -1
- package/src/SSHMimic/index.ts +3 -1
- package/src/SSHMimic/sftp.ts +0 -6
- package/src/VirtualFileSystem/binaryPack.ts +21 -9
- package/src/VirtualFileSystem/index.ts +369 -52
- package/src/VirtualFileSystem/internalTypes.ts +24 -4
- package/src/VirtualFileSystem/journal.ts +163 -0
- package/src/VirtualFileSystem/path.ts +1 -1
- package/src/VirtualShell/idleManager.ts +133 -0
- package/src/VirtualShell/index.ts +48 -0
- package/src/VirtualShell/shell.ts +4 -4
- package/src/commands/man.ts +5 -35
- 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 +14 -1
- package/src/standalone.ts +23 -0
- package/builds/standalone.js +0 -491
- 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 =
|
|
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:
|
|
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 =
|
|
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
|
|
226
|
+
let child = current.children[part];
|
|
146
227
|
if (!child) {
|
|
147
228
|
child = this.makeDir(part, mode);
|
|
148
|
-
current.children
|
|
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))
|
|
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
|
|
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 =
|
|
651
|
+
f.updatedAt = Date.now();
|
|
382
652
|
} else {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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:
|
|
617
|
-
updatedAt:
|
|
913
|
+
createdAt: Date.now(),
|
|
914
|
+
updatedAt: Date.now(),
|
|
618
915
|
};
|
|
619
|
-
parent.children
|
|
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.
|
|
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
|
|
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
|
|
1022
|
+
delete srcParent.children[srcName];
|
|
1023
|
+
srcParent._childCount--;
|
|
722
1024
|
node.name = destName;
|
|
723
|
-
destParent.children
|
|
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
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
:
|
|
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:
|
|
806
|
-
updatedAt:
|
|
807
|
-
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,
|
|
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
|
|
1128
|
+
dir.children[f.name] = {
|
|
813
1129
|
type: "file",
|
|
814
1130
|
name: f.name,
|
|
815
1131
|
mode: f.mode,
|
|
816
|
-
createdAt:
|
|
817
|
-
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
|
|
1142
|
+
dir.children[child.name] = sub;
|
|
827
1143
|
}
|
|
1144
|
+
dir._childCount++;
|
|
828
1145
|
}
|
|
829
1146
|
return dir;
|
|
830
1147
|
}
|