@browsernode/sandbox
v0.0.22
Published
Browser sandbox running Next.js and Vite apps entirely client-side
Readme
@browsernode/sandbox
A virtual filesystem bundler and browser runtime for framework sandboxes. Build and run Next.js applications entirely in the browser from virtual files.
Packages
| Package | Description |
|---------|-------------|
| @browsernode/sandbox | Core sandbox runtime — virtual filesystem, bundler, build pipeline. Built-in 'nextjs' and 'vite' framework adapters; bash subpath for shell. |
| @browsernode/react | React hooks and components (useSandbox, <SandboxBrowser />) |
The bundler is built-in: esbuild-wasm runs in the browser by default, and bundler: 'native' swaps in the native esbuild binary on a Node server. Install whichever you use as a peer dep — yarn add esbuild-wasm (browser) or yarn add esbuild (native).
Quick Start
import { createSandbox } from '@browsernode/sandbox';
const sandbox = await createSandbox({
framework: 'nextjs', // or 'vite'; bundler defaults to esbuild-wasm
});
await sandbox.fs.writeFiles({
'app/layout.tsx': `
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html><body>{children}</body></html>;
}
`,
'app/page.tsx': `
export default function Home() {
return <h1>Hello from the sandbox</h1>;
}
`,
});
const result = await sandbox.build();
if (result.ok) {
console.log(result.html); // self-contained HTML document
console.log(result.stats); // { duration: number, bundleSize: number }
} else {
console.log(result.html); // styled error page ready to serve
console.error(result.errors); // Diagnostic[]
}API
createSandbox(options)
Creates a new sandbox instance.
const sandbox = await createSandbox({
framework: 'nextjs',
bundler: 'native', // optional — defaults to 'wasm'
source: { type: 'git', url: 'https://github.com/user/repo.git' },
dependencies: { 'lodash': '4.17.21' },
scripts: ['https://cdn.jsdelivr.net/npm/[email protected]/fonts/remixicon.css'],
sandboxId: 'project-123',
env: { NEXT_PUBLIC_API_URL: 'https://api.example.com', STRIPE_KEY: 'sk_live_...' },
network: { allow: ['*.example.com'], proxyUrl: '/api/proxy' },
signal: abortController.signal,
});| Option | Type | Description |
|--------|------|-------------|
| framework | 'nextjs' \| 'vite' \| FrameworkAdapter | Required. Pass 'nextjs' or 'vite' for the default adapter, or call the adapter directly (e.g. nextjs({ reactVersion: '18.3.1' })) when you need to customize options. |
| bundler | 'wasm' \| 'native' \| BundlerAdapter | Optional. Defaults to 'wasm' (browser + node). Pass 'native' to use the faster esbuild binary on Node, or a custom adapter object for full control. |
| source | SandboxSource | Initialize from files or a git repo |
| dependencies | Record<string, string> | Override or augment the deps from package.json at build time. Not persisted. |
| scripts | string[] | URLs for external scripts or stylesheets injected into the HTML |
| sandboxId | string | Identifier for proxied requests AND the persistence key. Presence enables auto-persistence to OPFS. |
| env | Record<string, string> | Key/value pairs exposed as process.env in the sandbox |
| network | NetworkPolicy | Control outbound network access ('allow-all', 'deny-all', or { allow, proxyUrl }) |
| signal | AbortSignal | Abort signal to auto-dispose the sandbox |
| persistence | PersistenceAdapter \| false | Override the default OPFS persistence with a custom backend, or false to disable. |
| persistenceDebounceMs | number | Debounce window for auto-save (default 500ms). |
buildSandbox(options)
One-shot convenience — creates a sandbox, writes files, builds, and disposes in one call. Returns a BuildResult.
import { buildSandbox } from '@browsernode/sandbox';
const result = await buildSandbox({
framework: 'nextjs',
files: {
'app/layout.tsx': '...',
'app/page.tsx': '...',
},
});File System
The virtual filesystem is fully in-memory. Paths are normalized (no leading / or ./).
// Write
await sandbox.fs.writeFile('app/page.tsx', 'export default () => <h1>Hi</h1>');
await sandbox.fs.writeFiles({ 'a.ts': '...', 'b.ts': '...' });
// Read
const source = await sandbox.fs.readFile('app/page.tsx');
const binary = await sandbox.fs.readBinary('image.png');
const info = await sandbox.fs.stat('app/page.tsx'); // { type: 'file', size: number }
// Query (synchronous)
sandbox.fs.exists('app/page.tsx'); // true
sandbox.fs.list(); // ['app/layout.tsx', 'app/page.tsx']
sandbox.fs.list('app/'); // ['app/layout.tsx', 'app/page.tsx']
sandbox.fs.tree(); // { app: { 'layout.tsx': null, 'page.tsx': null } }
// Mutate
await sandbox.fs.copyFile('a.ts', 'b.ts');
await sandbox.fs.rename('old.ts', 'new.ts');
await sandbox.fs.rm('temp.ts');
await sandbox.fs.mkdir('lib');
await sandbox.fs.truncate('log.txt', 0);
// Transactions — batches change events into a single emission
await sandbox.fs.transaction(async (fs) => {
await fs.writeFile('a.ts', '...');
await fs.writeFile('b.ts', '...');
});
// Snapshots
const snap = sandbox.fs.snapshot();
// ... make changes ...
await sandbox.fs.restore(snap);
// Watch for changes
const unsubscribe = sandbox.fs.on('change', (events) => {
// events: Array<{ type: 'create' | 'update' | 'delete' | 'rename', path: string }>
});Network Policy
Control which domains the sandbox can reach and whether requests go through a proxy.
network: {
allow: ['*'], // allow all (default when no network option)
allow: [], // deny all
allow: ['api.example.com'], // exact domain
allow: ['*.example.com'], // wildcard subdomain
allow: [...PROXY_DOMAINS, 'my.api'], // preset list + custom
proxyUrl: '/api/proxy', // route allowed requests through proxy
}allow controls which external domains are reachable. proxyUrl is independent — when set, allowed requests are rewritten as ${proxyUrl}/${originalUrl}. When not set, allowed requests go direct via fetch. Git operations also respect the proxy.
import { PROXY_DOMAINS } from '@browsernode/sandbox/nextjs';
const sandbox = await createSandbox({
framework: 'nextjs',
network: {
allow: [...PROXY_DOMAINS, 'api.example.com'],
proxyUrl: '/api/proxy',
},
});Git
Full git support powered by isomorphic-git, operating entirely on the in-memory filesystem.
// Initialize a new repo
await sandbox.git.init();
// Clone a repo into the sandbox
await sandbox.git.clone('https://github.com/user/repo.git', {
ref: 'main', // default: 'main'
depth: 1, // default: 1
username: '...', // optional, for private repos
password: '...', // optional, for private repos
});
// Or initialize from a git source at creation
const sandbox = await createSandbox({
framework: 'nextjs',
source: {
type: 'git',
url: 'https://github.com/user/repo.git',
ref: 'main',
depth: 1,
},
});Branching
await sandbox.git.branch('feature');
await sandbox.git.checkout('feature');
const current = await sandbox.git.currentBranch(); // 'feature'
const branches = await sandbox.git.listBranches(); // ['main', 'feature']Staging & Committing
await sandbox.fs.writeFile('app/page.tsx', '...');
await sandbox.git.add('app/page.tsx');
// or add multiple files
await sandbox.git.add(['app/page.tsx', 'app/layout.tsx']);
const oid = await sandbox.git.commit('feat: add homepage', {
author: { name: 'Alice', email: '[email protected]' },
});Status & History
const fileStatus = await sandbox.git.status('app/page.tsx');
const matrix = await sandbox.git.statusMatrix({
filter: (f) => !f.startsWith('node_modules/'),
});
// Returns Array<[filepath, head, workdir, stage]>
const changed = await sandbox.git.diff(); // string[] of changed paths
const log = await sandbox.git.log({ depth: 5 });
// Array<{ oid, message, author: { name, email, timestamp } }>Push, Pull & Fetch
const auth = { username: 'token', password: 'ghp_...' };
await sandbox.git.push('origin', { ref: 'main', ...auth });
await sandbox.git.pull('origin', { ref: 'main', ...auth });
await sandbox.git.fetch('origin', { ref: 'main', ...auth });
const remoteBranches = await sandbox.git.listBranches({ remote: 'origin' });NPM
sandbox.npm is backed by the VFS-resident package.json — installs and removes mutate that file directly, exactly like real npm. Reads merge dependencies and devDependencies.
await sandbox.npm.install('lodash', '4.17.21'); // writes to package.json#dependencies
await sandbox.npm.install({ axios: '^1', 'date-fns': '^3' });
await sandbox.npm.remove('lodash'); // removes from both deps and devDeps
sandbox.npm.list(); // sync; merged deps + devDepsIf package.json doesn't exist, install auto-creates a minimal one (name: 'sandbox', version: '0.0.0'). Other fields (scripts, name, version, etc.) are preserved untouched. Dependency keys are alphabetized on every write for stable diffs. Malformed JSON throws on install/remove and emits an error event during build.
The dependencies createSandbox option overrides whatever's in package.json at build time only — it does not persist or appear in npm.list(). Useful for pinning or injecting deps without touching the VFS file.
Persistence
When sandboxId is provided, the sandbox auto-persists its filesystem to OPFS, hydrates from it on boot, and debounces saves on every change.
const sandbox = await createSandbox({
framework: 'nextjs',
sandboxId: 'project-1', // enables persistence
source: { type: 'files', files: { ... } }, // only seeded on first boot
});
// AI agent / consumer writes files; OPFS auto-saves ~500ms after the last change.
// Manage stored sandboxes (default OPFS backend)
import { listSandboxes, deleteSandbox } from '@browsernode/sandbox';
const ids = await listSandboxes(); // ['project-1', 'project-2']
await deleteSandbox('project-1');
// Download as zip
const blob = await sandbox.download(); // Blob (lazy-imports jszip)
const url = URL.createObjectURL(blob);Persisted state wins over source when both are present — source only seeds on first boot. To force a reset, await deleteSandbox(id) then create.
Custom persistence backend
Override the default OPFS adapter for any backend (remote DB, encrypted storage, Node fs, etc.):
import type { PersistenceAdapter } from '@browsernode/sandbox';
const remoteAdapter: PersistenceAdapter = {
load: async (id) => fetch(`/api/sandboxes/${id}`).then(r => r.ok ? r.json() : null),
save: async (id, snap) => fetch(`/api/sandboxes/${id}`, { method: 'PUT', body: JSON.stringify(snap) }),
delete: async (id) => fetch(`/api/sandboxes/${id}`, { method: 'DELETE' }),
list: async () => fetch('/api/sandboxes').then(r => r.json()),
};
const sandbox = await createSandbox({
framework: 'nextjs',
sandboxId: 'project-1',
persistence: remoteAdapter,
});
// Or wrap the default adapter:
import { defaultPersistence } from '@browsernode/sandbox';
const audited: PersistenceAdapter = {
...defaultPersistence(),
save: async (id, snap) => {
analytics.track('sandbox.save', { id });
return defaultPersistence().save(id, snap);
},
};
// Or disable persistence entirely
createSandbox({ ..., sandboxId: 'foo', persistence: false });The adapter receives a Snapshot ({ files: Map<string, string | Uint8Array> }) with normalized VFS paths. The default OPFS backend serializes to JSON-with-base64-binaries; custom adapters can use whatever wire format they want.
Events
sandbox.on('build', (result) => {
if (result.ok) console.log('Built in', result.stats.duration, 'ms');
});
sandbox.on('console', (level, args) => {
console.log(`[sandbox:${level}]`, ...args);
});
sandbox.on('error', (error) => {
console.error('Sandbox error:', error);
});
sandbox.on('navigate', (url) => {
console.log('Navigated to:', url);
});
sandbox.on('ready', () => {
console.log('Sandbox ready');
});
// File system changes (cascaded from sandbox.fs.on('change')).
// Events arrive batched: writeFiles/transaction produce a single array.
sandbox.on('change', async (events) => {
for (const e of events) {
// e.type: 'create' | 'update' | 'delete' | 'rename'
// e.path: string (e.oldPath set for 'rename')
if (e.type === 'delete' || e.path === '*') continue;
const content = await sandbox.fs.readFile(e.path); // fetch on demand
}
});
// All listeners return an unsubscribe function
const unsub = sandbox.on('build', () => {});
unsub();React
import { useEffect } from 'react';
import { useSandbox, SandboxBrowser } from '@browsernode/react';
function App() {
const { sandbox, loading, building, result, build } = useSandbox({
framework: 'nextjs', // or 'vite'
// bundler defaults to esbuild-wasm
sandboxId: 'project-1', // enables OPFS auto-persistence
onChange: (events) => console.log('fs:', events),
});
useEffect(() => {
if (sandbox) build();
}, [sandbox]);
if (loading) return <div>Initializing...</div>;
return (
<SandboxBrowser
sandbox={sandbox}
showUrlBar
onBuild={(result) => console.log('Built:', result.ok)}
/>
);
}Architecture
Virtual Files (Map<string, string>)
│
▼
┌──────────────────┐
│ Framework Adapter│ (e.g. framework: 'nextjs' or 'vite')
│ │ - Generates router code
│ │ - Collects CSS
│ │ - Applies shims (next/image, next/navigation, etc.)
│ │ - Detects server actions & API routes
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Bundler │ esbuild-wasm by default; 'native' for Node servers
│ │ - Bundles entry + plugins into a single JS output
│ │ - Resolves virtual files via plugins
│ │ - External deps resolved via import maps at runtime
└────────┬─────────┘
│
▼
HTML Document
- Import map (react, react-dom, react-router-dom via esm.sh)
- Bundled JS (app code + shims)
- Collected CSS
- <div id="root"> mount pointNext.js support
The Next.js adapter (framework: 'nextjs', or nextjs({ reactVersion }) for options) supports:
- App Router — file-based routing with layouts, pages, error boundaries, not-found pages, route groups, dynamic segments, and catch-all routes
- Server components — async components are wrapped for browser execution
- Server actions —
"use server"files are detected and registered with a client-side dispatcher - API routes —
route.tsfiles with GET/POST/PUT/DELETE handlers, intercepted via a patchedfetch - Next.js shims —
next/image,next/link,next/navigation(useRouter, usePathname, useSearchParams, redirect, notFound),next/server(NextRequest, NextResponse),next/headers(cookies, headers),next/font/google,next/font/local,next/head - Node module shims —
path,fs,crypto,buffer,stream,url,events,process, and more - CSS — global CSS files are collected and inlined into the HTML output
- Metadata & viewport — extracted from layouts/pages and rendered as
<meta>tags
Development
# Install dependencies
yarn
# Run tests
yarn test
# Run tests in watch mode
yarn test --watch
# Build all packages
yarn buildLicense
MIT - Frontend AI, Inc.
