// Snapshot an app directory (node_modules included unless `modules: false`) into the // shape runtime.mountSnapshot and runtime.mount take: a binary v3 format (`.snap`): // bytes 0..3: ASCII magic "NIBS" // bytes 4..7: u32 little-endian header length H // bytes 8..8+H: UTF-8 JSON header {"version":3,"files":[[path, size, mtimeMs], ...]} // then every file's raw bytes, concatenated in header order. // App-owned files are kept whatever their extension (static assets). Inside node_modules // only the files keepInPackage (src/shared/package-files.ts) keeps, as runtime.install does. // // Runs under Node, and as .ts directly (type stripping): erasable syntax only. Public // types use Uint8Array, not Buffer, so a consumer needs no @types/node. import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve, sep } from "node:path"; import { keepInPackage } from "../shared/package-files.ts"; export interface SnapshotFile { /** The guest path: `mount` plus the path relative to the app directory. */ path: string; bytes: Uint8Array; mtimeMs: number; } export interface Snapshot { version: 3; /** In walk order (entries sorted by name, depth first), which is also encoded order. */ files: SnapshotFile[]; } export interface SnapshotOptions { /** Include node_modules. `false` leaves it out: the app's own files and lockfile, for * `runtime.install()` to fill in. Default: true. */ modules?: boolean; } /** Walk `appDir` and return its snapshot; `mount` is the guest path prefix. */ export function snapshot(appDir: string, mount = "/app", { modules = true }: SnapshotOptions = {}): Snapshot { const root = resolve(appDir); const files: SnapshotFile[] = []; const walk = (dir: string, inPackages: boolean): void => { for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const abs = join(dir, entry.name); if (entry.isDirectory()) { if (!modules && !inPackages && entry.name === "node_modules") continue; // node_modules/.vite is Vite's dep-optimizer cache from a run under real Node; // the guest builds its own. if (inPackages && entry.name === ".vite") continue; walk(abs, inPackages || entry.name === "node_modules"); continue; } if (!entry.isFile()) continue; const bytes = readFileSync(abs); if (inPackages && !keepInPackage(entry.name, bytes)) continue; const guestPath = `${mount.replace(/\/$/, "")}/${relative(root, abs).split(sep).join("/")}`; files.push({ path: guestPath, bytes, mtimeMs: statSync(abs).mtimeMs }); } }; walk(root, false); return { version: 3, files }; } /** Encode a snapshot (as `snapshot()` returns) into the v3 binary format. */ export function encodeSnapshot(snap: Snapshot): Uint8Array { const header = new TextEncoder().encode(JSON.stringify({ version: 3, files: snap.files.map((f) => [f.path, f.bytes.length, f.mtimeMs]), })); const out = new Uint8Array(8 + header.length + snap.files.reduce((n, f) => n + f.bytes.length, 0)); out.set([0x4e, 0x49, 0x42, 0x53]); // "NIBS" new DataView(out.buffer).setUint32(4, header.length, true); out.set(header, 8); let offset = 8 + header.length; for (const f of snap.files) { out.set(f.bytes, offset); offset += f.bytes.length; } return out; }