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) or dumpChanges() (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.