strapkit

Working with the filesystem

Recipes for reading, writing, and shipping files in and out of Strapkit.

The filesystem is where your code lives. Anything you run with exec() reads from it, anything that writes a log writes to it, and when you persist or restore state with the Cache, you're snapshotting it.

This page is task-oriented. For the full method list, see the Filesystem API reference.

Seed a project from a file map

The most common starting point is a project tree. Strapkit accepts an object whose keys are absolute paths and whose values follow a nested-file.contents shape:

await sk.writeMultiple({
  '/app/package.json': {
    file: { contents: JSON.stringify({ name: 'demo', type: 'module' }) },
  },
  '/app/src/index.js': {
    file: { contents: 'console.log("hello from strapkit")' },
  },
  '/app/src/lib/util.js': {
    file: { contents: 'export const add = (a, b) => a + b' },
  },
});

Parent directories are created for you. There's no rollback if one entry fails partway through, so if you're loading user-supplied trees, validate them first.

Drop in a file the user picked

When the user opens a file from disk in the browser, you've got a File (which is a Blob). Read it as bytes and hand it to write():

async function importFile(file, dest) {
  const bytes = new Uint8Array(await file.arrayBuffer());
  await sk.write(dest, bytes);
}

inputElement.addEventListener('change', (e) => {
  for (const f of e.target.files) {
    importFile(f, `/app/uploads/${f.name}`);
  }
});

Strings get written as UTF-8 automatically. Always pass a Uint8Array for binaries (images, archives, fonts) — round-tripping through a string corrupts non-text bytes.

Render a directory tree

readTree() walks a directory and returns a sorted, recursive description — directories first, alphabetical within a level. Useful for file panels:

function render(entry, depth = 0) {
  const indent = '  '.repeat(depth);
  if (entry.type === 'directory') {
    console.log(indent + entry.name + '/');
    for (const child of entry.children) render(child, depth + 1);
  } else {
    console.log(indent + entry.name);
  }
}

const tree = await sk.readTree('/app');
for (const entry of tree) render(entry);

If you only need one level, use readdir() instead — it returns the raw entry names without recursion.

Export the project as a zip

There's no built-in zip helper, but snapshot() plus a tiny zip library is enough. snapshot() returns an object mapping file paths to their text contents — pair it with @zip.js/zip.js to build a downloadable archive:

import { ZipWriter, BlobWriter, TextReader } from '@zip.js/zip.js';

async function downloadZip(rootPath, zipName) {
  const snap = await sk.snapshot((p) => p.startsWith(rootPath));
  const writer = new ZipWriter(new BlobWriter('application/zip'));

  for (const [path, contents] of Object.entries(snap)) {
    const rel = path.slice(rootPath.length + 1);
    await writer.add(rel, new TextReader(contents));
  }

  const blob = await writer.close();
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = zipName;
  a.click();
  URL.revokeObjectURL(a.href);
}

downloadZip('/app', 'project.zip');

Persist between page reloads

The in-browser filesystem is wiped every time the page reloads. cacheSave and cacheRestore give you persistence keyed by an arbitrary string — usually a hash of the lockfile so you auto-invalidate when dependencies change:

async function hash(text) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}

const lockKey = await hash(await sk.read('/app/pnpm-lock.yaml'));

if (!(await sk.cacheRestore('/app/node_modules', lockKey))) {
  await sk.exec('cd /app && pnpm install');
  await sk.cacheSave('/app/node_modules', lockKey);
}

The first visit installs and caches. Every later visit restores from the cache in milliseconds.

Watch for changes

Strapkit doesn't ship a file watcher, but the runtime does — Node's fs.watch and packages like chokidar work normally inside spawn(). If you need to react to changes from your host page, it's usually simpler to drive the filesystem from outside (you wrote it, you know when it changed) than to watch it from inside.

Clean up

rm() removes either a file or a whole directory tree. It throws on missing paths — wrap it for idempotent cleanup:

async function rmIfExists(path) {
  try {
    await sk.rm(path);
  } catch (e) {
    if (e.errno !== 44 /* ENOENT */) throw e;
  }
}

await rmIfExists('/app/dist');

For a full reset, drop the Strapkit instance and construct a new one — the runtime's filesystem starts empty again.

On this page