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:
convex <args>— Runs the Convex CLI bundle (afternpm install convex)
execSync() is not supported in the browser — use the async exec() with callbacks or promises instead. 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