A Vite plugin that makes import ... from 'fs' work during local vite dev for
immediately.run apps.
immediately.run apps can use a Node-like filesystem by importing fs — it is
async only (fs.promises.* and callback style; no *Sync methods) and, in
the hosted sandbox, backed by ZenFS persisted in the browser. There is no such
filesystem when you run the app locally with Vite, so import 'fs' would
normally fail in the browser.
This plugin fills that gap: during vite dev it serves a browser shim in place
of the fs builtin and bridges every call to your real local disk,
chrooted to the project directory and laid out like the hosted sandbox (the
repo at /app, scratch elsewhere — see Filesystem layout).
It is dev-only — it never runs in vite build, and on immediately.run the
real ZenFS is used instead.
app code → fs shim (browser) → HTTP /__devfs → node:fs (read/write/stat/…)
→ SSE /__devfs/watch ← fs.watch (change events)
npm install -D @immediately-run/dev-fsAdd the plugin to your Vite config:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { devFs } from '@immediately-run/dev-fs'
export default defineConfig({
plugins: [
devFs(),
react(),
],
})For TypeScript types on the fs import (so app code type-checks without pulling
all of @types/node into your browser project), add a one-line reference in any
.d.ts file your tsconfig includes — for example src/devfs.d.ts:
/// <reference types="@immediately-run/dev-fs/fs" />Now fs works the same locally as it does on immediately.run:
import fs from 'fs'
await fs.promises.mkdir('/data', { recursive: true })
await fs.promises.writeFile('/data/notes.txt', 'hello', 'utf8')
const text = await fs.promises.readFile('/data/notes.txt', 'utf8') // string
// watch is an async iterable of change events
for await (const ev of fs.promises.watch('/data', { recursive: true })) {
console.log(ev.eventType, ev.filename)
}The virtual filesystem mirrors the hosted sandbox, chrooted to your project directory:
| app path | disk path | what it is |
|---|---|---|
/app/... |
<project>/... |
the repo itself, as in prod |
everything else under / |
<project>/.devfs/root/... |
scratch space |
/spaces/{id} |
<project>/.devfs/root/spaces/{id} |
dev space data |
So /app/src/App.tsx reads <project>/src/App.tsx on disk, a listing of /
shows app (the repo mount point) next to whatever scratch entries exist, and
relative paths resolve against /app — the sandbox's working directory.
Nothing dev-fs does ever touches disk outside the project directory.
devFs({
// Globs the Vite file watcher should ignore, so writes your app makes during
// dev don't trigger HMR reloads. The plugin's own `watch` uses an independent
// fs.watch, so it still reports these changes.
// Default: ['**/.devfs/**', '**/devfs-playground/**']
ignore: ['**/.devfs/**', '**/my-scratch-dir/**'],
})Async only — there are no synchronous (*Sync) methods, matching what
immediately.run exposes.
readFile, writeFile, appendFile, readdir (incl. withFileTypes),
mkdir, rm, rmdir, unlink, stat, lstat, access, rename,
copyFile, realpath, and watch — available on fs.promises.*, plus the
Node callback forms on the default export (e.g. fs.readFile(path, cb)).
- No sync methods. The bridge is request/response and can't service synchronous calls — the same constraint immediately.run has.
- Binary reads return a
Uint8Array, not a NodeBuffer. Pass an encoding (readFile(path, 'utf8')) when you want a string. watchon Linux: the bridge uses recursivefs.watch, which is unsupported on Linux —watchworks on macOS/Windows but may miss nested changes in a Linux dev container. immediately.run's ownwatchis unaffected.- Scope: the bridge only reads and writes under the project root; paths that
escape it are rejected with
EACCES. - Dev-only: the plugin sets
apply: 'serve', so it is absent from production builds and has no effect on immediately.run.
On immediately.run, apps can request spaces — Firestore-backed user
filesystems — through @immediately-run/sdk (openAppSpace, createSpace,
mountSpace, listSpaces, unmountSpace). The host signs the user in, renders
the create / pick UI, mounts the space at /spaces/{id}, and exposes a runtime
global the SDK talks to. Once mounted, a space is read and written through the
ordinary fs module at its mount path — no separate API.
There is no host under vite dev, so this plugin emulates it, letting the
unmodified SDK work locally exactly as it does in production:
app code → @immediately-run/sdk (unchanged)
│ reads module.evaluation.module.bundler.{mounts, messageBus}
▼
client-spaces.js (installed in <head>, before app code)
→ POST /__devfs/spaces → registry + node:fs (open/create/mount/list/unmount)
→ SSE /__devfs/spaces/events ← mount-set changes (so useMounts/waitForMount fire)
- Each space is a real directory at
<project>/.devfs/root/spaces/{id}—/spaces/{id}in the virtual layout — so the existingfsbridge reads and writes it with no special-casing. - A registry at
<project>/.devfs/spaces.jsontracks space names, slot bindings, and which spaces are mounted. - When the SDK calls
openAppSpace()on an unbound slot, the plugin renders a minimal create-or-pick dialog in the page — the local stand-in for the host's sign-in / create UX.createSpace/mountSpaceneed no dialog.
This is dev-only, like the fs bridge: the substrate is injected via
transformIndexHtml under apply: 'serve' and is absent from vite build. No
app code changes are needed — import the real SDK; it just works in both places.
Add .devfs/ to your .gitignore so dev scratch and space data isn't
committed.
resolveId redirects the bare 'fs' / 'node:fs' specifier to a browser shim
(client-fs.js). The shim implements the async fs surface over the dev
server: request/response operations go over fetch to a /__devfs middleware
that runs node:fs under the project root; fs.promises.watch opens a
Server-Sent Events stream at /__devfs/watch backed by fs.watch. SSE
(rather than polling or a WebSocket) gives push-based change events with
auto-reconnect and no extra dependencies.
MIT