Core Concepts
almostnode is built on four core primitives that work together to simulate a Node.js environment in the browser.
Architecture Overview
Every almostnode app uses these components:
┌─────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────┤
│ Framework Dev Servers (Next.js / Vite) │
├──────────┬──────────┬───────────────────┤
│ Runtime │ npm │ ServerBridge │
├──────────┴──────────┴───────────────────┤
│ VirtualFS (in-memory) │
└─────────────────────────────────────────┘
- VirtualFS — an in-memory filesystem that stores all files
- Runtime — executes JavaScript/TypeScript with Node.js module resolution
- PackageManager — installs npm packages into the virtual filesystem
- ServerBridge — connects virtual servers to real browser URLs via a service worker
VirtualFS
VirtualFS provides a POSIX-compatible filesystem API that stores files in memory. It supports directories, binary files, file watching, and all the common fs operations you'd expect from Node.js.
import { VirtualFS } from 'almostnode';
const vfs = new VirtualFS();
// Create directories
vfs.mkdirSync('/src', { recursive: true });
// Write files
vfs.writeFileSync('/src/index.js', 'console.log("hello")');
// Read files
const content = vfs.readFileSync('/src/index.js', 'utf8');
// List directory contents
const files = vfs.readdirSync('/src'); // ['index.js']
// Watch for changes
vfs.watch('/src', (event, filename) => {
console.log(event, filename);
});
Key Features
writeFileSyncautomatically creates parent directorieswatch()emits events when files change — used by HMR in framework dev servers- Supports both string (utf8) and binary (
Uint8Array) file content toSnapshot()andfromSnapshot()allow serializing the entire filesystem
Runtime
The Runtime executes JavaScript and TypeScript code with CommonJS module resolution. It shims 40+ Node.js built-in modules so code written for Node.js can run in the browser.
import { VirtualFS, Runtime } from 'almostnode';
const vfs = new VirtualFS();
const runtime = new Runtime(vfs);
// Execute code directly
runtime.execute(`
const path = require('path');
console.log(path.join('/foo', 'bar', 'baz.js'));
`);
// Or run a file from the VFS
vfs.writeFileSync('/app.js', 'module.exports = { version: 1 }');
const result = runtime.runFile('/app.js');
console.log(result.exports); // { version: 1 }
Shimmed Modules
The runtime includes shims for these Node.js built-in modules:
assert, buffer, child_process, console, crypto, dns, events, fs, http, https, module, net, os, path, process, querystring, readline, stream, string_decoder, timers, tls, tty, url, util, vm, worker_threads, zlib, and more.
Module Resolution
The runtime follows Node.js module resolution rules:
- Built-in modules (
require('fs'),require('node:path')) - Relative paths (
require('./utils')) — resolves.js,.ts,.json,/index.js - npm packages (
require('express')) — looks innode_modules/
PackageManager
The PackageManager installs real npm packages into the virtual filesystem. It fetches tarballs from the npm registry, resolves the full dependency tree, and extracts files — all in the browser.
import { VirtualFS, PackageManager } from 'almostnode';
const vfs = new VirtualFS();
const npm = new PackageManager(vfs);
// Install a single package
await npm.install('lodash');
// Install a specific version
await npm.install('react@18.2.0');
// Install all deps from package.json
vfs.writeFileSync('/package.json', JSON.stringify({
dependencies: { express: '^4.18.0' }
}));
await npm.installFromPackageJson();
// List installed packages
console.log(npm.list()); // { express: '4.18.2', ... }
How Package Installation Works
When you call npm.install('express'), the PackageManager performs these steps entirely in the browser:
- Resolve version — Fetch the package manifest from the npm registry (
registry.npmjs.org) and resolve semver ranges (e.g.^4.18.0to4.18.2) - Build dependency tree — Recursively resolve all transitive dependencies with correct version matching, handling circular dependencies
- Download tarballs — Fetch
.tgzfiles for each package from the npm CDN - Extract to VFS — Decompress and extract tarball contents into
/node_modules/{name}/in the virtual filesystem - Transform ESM to CJS — Use esbuild-wasm to convert ES module syntax to CommonJS so the Runtime can execute it (enabled by default, can be disabled with
transform: false) - Write lockfile — Create
.package-lock.jsonwith all resolved versions
The PackageManager downloads packages from the npm registry. The framework dev servers (Next.js and Vite) use a different approach — they redirect bare npm imports to esm.sh for browser-side ES module loading (e.g. import React from 'react' becomes import React from 'https://esm.sh/react@18.2.0'). Both approaches can coexist in the same project.
// Track installation progress
await npm.install('express', {
onProgress: (msg) => console.log(msg),
save: true, // Add to package.json dependencies
});
Installing and Using a Package
Here's a complete example — install a package from npm, then use it in your code:
import { createContainer } from 'almostnode';
const { vfs, npm, runtime } = createContainer();
// Install lodash from npm
await npm.install('lodash');
// Use it in your code
vfs.writeFileSync('/app.js', `
const _ = require('lodash');
const data = [1, [2, [3, [4]]]];
console.log(_.flattenDeep(data)); // [1, 2, 3, 4]
console.log(_.chunk([1,2,3,4,5,6], 2)); // [[1,2],[3,4],[5,6]]
`);
runtime.runFile('/app.js');
// Install a specific version
await npm.install('dayjs@1.11.10');
// Scoped packages work too
await npm.install('@hono/node-server');
Shell Commands (child_process)
almostnode integrates just-bash — a simulated bash environment written in TypeScript — to provide child_process.exec() support in the browser. just-bash implements 50+ bash commands and uses an IFileSystem interface that almostnode bridges to the VirtualFS via a VirtualFSAdapter. This means all shell commands operate on the same virtual filesystem your code uses.
const { exec } = require('child_process');
// Run shell commands against the VirtualFS
exec('ls /src', (err, stdout) => {
console.log(stdout); // Lists files in /src
});
// Pipes and redirects work too
exec('cat /data.csv | sort | head -5', (err, stdout) => {
console.log(stdout);
});
// Custom 'node' command runs JS via the Runtime
exec('node /app.js', (err, stdout) => {
console.log(stdout);
});
Available Commands
- File operations —
cat,cp,ls,mkdir,mv,rm,rmdir,touch,stat,tree,ln - Text processing —
awk,cut,grep,head,sed,sort,tail,tr,uniq,wc - Utilities —
echo,env,find,pwd,date,seq,xargs,tee,which - Shell features — pipes (
|), redirects (>,>>), environment variables, subshells - Custom:
node <script>— Runs a JavaScript file using the almostnode Runtime - Custom:
npm <command>— Runs npm scripts, installs packages, and lists dependencies (see below) - Custom:
vitest run— Runs vitest unit tests once using real@vitest/expectassertions (afternpm install vitest) - Custom:
vitest/vitest watch— Watch mode: runs tests then re-runs automatically when VFS files change. Use withcontainer.run()streaming options (see below) - Custom:
convex <args>— Runs the Convex CLI bundle (afternpm install convex)
npm Scripts
Run scripts defined in package.json using the built-in npm command. Supports npm run <script>, npm start, npm test, npm install, and npm ls. Pre/post lifecycle scripts (prebuild, postbuild, etc.) run automatically.
const container = createContainer();
// Run shell commands directly with container.run()
const result = await container.run('npm run build');
console.log(result.stdout);
await container.run('npm test');
await container.run('ls /');
Try the interactive npm scripts demo or the vitest testing demo to see it in action.
Streaming Output & Watch Mode
container.run() accepts optional streaming callbacks and an AbortSignal for long-running commands like vitest watch mode:
const controller = new AbortController();
// Start vitest in watch mode with streaming output
container.run('vitest', {
onStdout: (data) => console.log(data),
onStderr: (data) => console.error(data),
signal: controller.signal,
});
// Tests re-run automatically when VFS files change.
// Stop watching:
controller.abort();
Installing Packages — API Overview
There are multiple ways to install npm packages, each suited for different use cases:
| Method | Best for |
|---|---|
container.npm.install('pkg') |
Programmatic use — typed InstallResult, onProgress callback, save/saveDev options |
container.npm.installFromPackageJson() |
Install all dependencies from an existing package.json |
container.run('npm install pkg') |
Shell workflows, interactive terminals, scripts |
execSync() is not supported in the browser — use the async exec() with callbacks, or the container.run() convenience method which returns a Promise. The shell operates on the VirtualFS, not the real filesystem.
Convex Integration
almostnode can run the Convex CLI entirely in the browser, enabling in-browser Convex development with live deployment. After installing the Convex package, you can deploy functions directly from the browser.
import { VirtualFS, Runtime, PackageManager } from 'almostnode';
const vfs = new VirtualFS();
const npm = new PackageManager(vfs, { cwd: '/project' });
// Install Convex
await npm.install('convex');
// Set up project structure
vfs.writeFileSync('/project/convex.json', '{ "functions": "convex/" }');
// IMPORTANT: Both .ts AND .js config files are required
const configSrc = `import { defineApp } from "convex/server";
const app = defineApp();
export default app;`;
vfs.writeFileSync('/project/convex/convex.config.ts', configSrc);
vfs.writeFileSync('/project/convex/convex.config.js', configSrc);
// Write your Convex functions
vfs.writeFileSync('/project/convex/tasks.ts', `
import { query } from './_generated/server';
export const list = query({
handler: async (ctx) => ctx.db.query('tasks').collect(),
});
`);
// CRITICAL: Create a fresh Runtime for each deployment
const cliRuntime = new Runtime(vfs, { cwd: '/project' });
cliRuntime.execute(`
process.env.CONVEX_DEPLOY_KEY = 'dev:your-deployment|token';
process.argv = ['node', 'convex', 'dev', '--once'];
require('./node_modules/convex/dist/cli.bundle.cjs');
`);
How It Works
- The Convex CLI bundle runs inside the almostnode Runtime via
runtime.execute() - It reads your function files from the VirtualFS
- Bundles them with esbuild (shimmed in browser)
- Pushes the bundled functions to your Convex deployment via the HTTP API
- Generated type files (
_generated/api.js,server.js) are written back to the VFS
Important Notes
- Fresh Runtime per deploy — Always create a new
Runtimeinstance for each deployment. The CLI captures file contents in closures, so reusing a Runtime causes stale code to be deployed. - CWD matters — Set
cwd: '/project'sopath.resolve()andrequire()resolve correctly. - Both config formats — The CLI bundler needs both
convex.config.tsandconvex.config.js. - Deploy key format —
dev:deployment-name|base64token. Generate from the Convex dashboard. - Async completion — The CLI runs async network requests. Poll for
.env.localand_generated/directory to detect completion. - Clean before deploy — Remove existing
_generated/directories before each CLI run, or the CLI may skip the push.
Do not use child_process.exec('convex dev') for deployment — use runtime.execute() with inline code instead. See the full integration guide in docs/CONVEX_CLI_INTEGRATION.md for troubleshooting and advanced patterns.
ServerBridge
The ServerBridge connects virtual HTTP servers to real browser URLs using a service worker. When a request hits /__virtual__/{port}/path, the service worker intercepts it and forwards it to the registered virtual server.
import { getServerBridge } from 'almostnode';
const bridge = getServerBridge();
// Initialize the service worker
await bridge.initServiceWorker();
// Register a virtual server on port 3000
bridge.registerServer(myServer, 3000);
// Now accessible at /__virtual__/3000/ in an iframe
const url = bridge.getServerUrl(3000);
iframe.src = url;
How It Works
initServiceWorker()registers a service worker that intercepts fetch requestsregisterServer()maps a port number to a virtual server object- When the browser requests
/__virtual__/3000/path, the service worker sends it to the registered server - The server processes the request and returns a response — all without any real network traffic
Putting It All Together
The createContainer() helper creates all four components for you:
import { createContainer } from 'almostnode';
const {
vfs, // VirtualFS instance
runtime, // Runtime instance
npm, // PackageManager instance
serverBridge, // ServerBridge instance
execute, // Shorthand for runtime.execute()
runFile, // Shorthand for runtime.runFile()
createREPL, // Create interactive REPL context
} = createContainer();
Supported Node.js APIs
967 compatibility tests verify almostnode's Node.js API coverage.
Fully Shimmed Modules
| Module | Tests | Coverage | Notes |
|---|---|---|---|
path | 219 | High | POSIX paths (no Windows) |
buffer | 95 | High | All common operations |
fs | 76 | High | Sync + promises API |
url | 67 | High | WHATWG URL + legacy parser |
util | 77 | High | format, inspect, promisify |
process | 60 | High | env, cwd, hrtime, EventEmitter |
events | 50 | High | Full EventEmitter API |
os | 58 | High | Platform info (simulated) |
crypto | 57 | High | Hash, HMAC, random, sign/verify |
querystring | 52 | High | parse, stringify, escape |
stream | 44 | Medium | Readable, Writable, Transform |
zlib | 39 | High | gzip, deflate, brotli |
tty | 40 | High | ReadStream, WriteStream |
perf_hooks | 33 | High | Performance API |
Stubbed Modules
These modules export empty objects or no-op functions. They exist so that require() calls don't throw, but they don't provide real functionality:
net,tls,dns,dgramcluster,worker_threadsvm,v8,inspectorasync_hooks