Sync the filesystem
Keep a box's filesystem in sync — full snapshots, an incremental change stream, and recipes for two tabs, a socket, and a database.
A box's filesystem is plain in-memory state, so you can move it or mirror it anywhere: snapshot it to a file, stream changes to another tab, push it over a socket, or persist it to a database one row per file. Lifo gives you two building blocks and you choose the transport.
Full dump vs. incremental
- Full dump — the entire tree at a point in time. Use it to save/restore, to
seed a fresh box, or as the initial handshake before streaming. It's the
snapshot (a
tar.gz) ordumpChanges()(a change list). - Incremental — a live stream of individual changes as they happen. Use it
to keep two things in sync cheaply after the initial dump. That's
watchChanges().
Most real setups combine them: dump once to catch up, then stream to stay in sync.
Snapshots (whole-disk)
const image = await sandbox.fs.exportSnapshot(); // Uint8Array (tar.gz)
// …later, or in another box:
await otherSandbox.fs.importSnapshot(image);Ignoring node_modules
node_modules is large and reproducible from package.json. Exclude it for a
much smaller snapshot — the restored box just needs a reinstall:
const image = await sandbox.fs.exportSnapshot({ exclude: ["node_modules"] });
// restore, then: await sandbox.commands.run("npm install", { cwd });exclude matches path segments, so ["node_modules", ".git"] skips both
wherever they appear. Omit it to get a self-contained snapshot that restores and
runs without reinstalling.
The change primitives
For anything beyond a whole-disk file, work with VfsChanges over the box's VFS
(sandbox.kernel.vfs):
import {
dumpChanges, applyChanges, watchChanges,
serializeChange, deserializeChange,
} from "@lifo-sh/core";
const vfs = sandbox.kernel.vfs;
// Full dump as an ordered change list (dirs before their files).
const changes = dumpChanges(vfs, { root: "/home/user/app", exclude: ["node_modules"] });
// Apply changes into any VFS (idempotent — creates parents, ignores no-ops).
applyChanges(otherVfs, changes);
// Subscribe to live changes; returns an unsubscribe function.
const stop = watchChanges(vfs, (change) => { /* send it somewhere */ }, {
root: "/home/user/app",
exclude: ["node_modules"],
});A VfsChange is one of put (path + bytes), mkdir, delete, or rename.
serializeChange / deserializeChange turn one into a transport-safe string
(JSON with base64 bytes) and back — use them for a channel, a socket, or a DB
row.
Gotcha
When syncing bidirectionally, guard against echo: applying a remote change
mutates the VFS, which fires watchChanges again. Suppress the local emit while
applying (the applying flag in the recipes below).
Recipe: two tabs (BroadcastChannel)
Mirror a box between tabs on the same origin. Each tab streams its local changes and applies the others'.
const channel = new BroadcastChannel("lifo-fs");
let applying = false;
const stop = watchChanges(vfs, (change) => {
if (!applying) channel.postMessage(serializeChange(change));
}, { root: "/home/user/app", exclude: ["node_modules"] });
channel.onmessage = (e) => {
applying = true;
try { applyChanges(vfs, [deserializeChange(e.data)]); }
finally { applying = false; }
};
// When a new tab joins, an existing tab can replay a full dump so it catches up:
// dumpChanges(vfs, { root: "/home/user/app" }).forEach((c) => channel.postMessage(serializeChange(c)));Recipe: over a socket
Stream a box to a server (or peer) over a WebSocket — the same shape works over
the tunnel or postMessage.
const socket = new WebSocket(url);
socket.onopen = () => {
// catch the peer up with a full dump, then stream deltas
for (const c of dumpChanges(vfs, { root: "/home/user/app", exclude: ["node_modules"] })) {
socket.send(serializeChange(c));
}
};
const stop = watchChanges(vfs, (change) => {
if (socket.readyState === WebSocket.OPEN) socket.send(serializeChange(change));
}, { root: "/home/user/app", exclude: ["node_modules"] });
socket.onmessage = (e) => applyChanges(vfs, [deserializeChange(e.data)]);Recipe: a database, one row per file
Persist a box to any key-value or SQL store keyed by path — a row per file. Update rows as changes happen, and hydrate a fresh box from the rows.
// store: { put(path, data), delete(path), all(): Promise<{path, data}[]> }
// 1. Seed the store with the current tree.
for (const c of dumpChanges(vfs, { exclude: ["node_modules"] })) {
if (c.op === "put") await store.put(c.path, c.data);
}
// 2. Keep it in sync — one upsert/delete per change.
const stop = watchChanges(vfs, async (c) => {
if (c.op === "put") await store.put(c.path, c.data);
else if (c.op === "delete") await store.delete(c.path);
else if (c.op === "rename") { await store.delete(c.from); await store.put(c.to, vfs.readFile(c.to)); }
// 'mkdir' is implied by file paths — store it too if you track empty dirs
}, { exclude: ["node_modules"] });
// 3. Hydrate a new box from the rows.
const rows = await store.all();
applyChanges(vfs, rows.map((r) => ({ op: "put" as const, path: r.path, data: r.data })));Tip
This is exactly how you'd back a box with Postgres/SQLite (one row per path),
IndexedDB, or object storage. Pair the initial dumpChanges with watchChanges
and you have durable, incremental persistence — and exclude: ["node_modules"]
keeps the row count sane.
Next
- Filesystem — the VFS, providers, blob store, and snapshots.
- Protocols — the wire formats behind previews and tunnels.