Skip to content

macaly/almostnode

Repository files navigation

almostnode

Node.js in your browser. Just like that.

A lightweight, browser-native Node.js runtime environment. Run Node.js code, install npm packages, and develop with Vite or Next.js - all without a server.

MIT License TypeScript Node.js

Built by the creators of Macaly.com — a tool that lets anyone build websites and web apps, even without coding experience. Think Claude Code for non-developers.

Warning: This project is experimental and may contain bugs. Use with caution in production environments.


Features

  • Virtual File System - Full in-memory filesystem with Node.js-compatible API
  • Node.js API Shims - 40+ shimmed modules (fs, path, http, events, and more)
  • npm Package Installation - Install and run real npm packages in the browser
  • Dev Servers - Built-in Vite and Next.js development servers
  • Hot Module Replacement - React Refresh support for instant updates
  • TypeScript Support - First-class TypeScript/TSX transformation via esbuild-wasm
  • Service Worker Architecture - Intercepts requests for seamless dev experience
  • Optional Web Worker Support - Offload code execution to a Web Worker for improved UI responsiveness
  • Secure by Default - Cross-origin sandbox support for running untrusted code safely

Requirements

  • Node.js 20+ - Required for development and building
  • Modern browser - Chrome, Firefox, Safari, or Edge with ES2020+ support

Note: almostnode runs in the browser and emulates Node.js 20 APIs. The Node.js requirement is only for development tooling (Vite, Vitest, TypeScript).


Quick Start

Installation

npm install almostnode

Basic Usage

import { createContainer } from 'almostnode';

// Create a Node.js container in the browser
const container = createContainer();

// Execute JavaScript code directly
const result = container.execute(`
  const path = require('path');
  const fs = require('fs');

  // Use Node.js APIs in the browser!
  fs.writeFileSync('/hello.txt', 'Hello from the browser!');
  module.exports = fs.readFileSync('/hello.txt', 'utf8');
`);

console.log(result.exports); // "Hello from the browser!"

⚠️ Security Warning: The example above runs code on the main thread with full access to your page. Do not use createContainer() or container.execute() with untrusted code. For untrusted code, use createRuntime() with a cross-origin sandbox - see Sandbox Setup.

Running Untrusted Code Securely

import { createRuntime, VirtualFS } from 'almostnode';

const vfs = new VirtualFS();

// Create a secure runtime with cross-origin isolation
const runtime = await createRuntime(vfs, {
  sandbox: 'https://your-sandbox.vercel.app', // Deploy with generateSandboxFiles()
});

// Now it's safe to run untrusted code
const result = await runtime.execute(untrustedCode);

See Sandbox Setup for deployment instructions.

Working with Virtual File System

import { createContainer } from 'almostnode';

const container = createContainer();
const { vfs } = container;

// Pre-populate the virtual filesystem
vfs.writeFileSync('/src/index.js', `
  const data = require('./data.json');
  console.log('Users:', data.users.length);
  module.exports = data;
`);

vfs.writeFileSync('/src/data.json', JSON.stringify({
  users: [{ name: 'Alice' }, { name: 'Bob' }]
}));

// Run from the virtual filesystem
const result = container.runFile('/src/index.js');

With npm Packages

import { createContainer } from 'almostnode';

const container = createContainer();

// Install a package
await container.npm.install('lodash');

// Use it in your code
container.execute(`
  const _ = require('lodash');
  console.log(_.capitalize('hello world'));
`);
// Output: Hello world

With Next.js Dev Server

import { VirtualFS, NextDevServer, getServerBridge } from 'almostnode';

const vfs = new VirtualFS();

// Create a Next.js page
vfs.mkdirSync('/pages', { recursive: true });
vfs.writeFileSync('/pages/index.jsx', `
  import { useState } from 'react';

  export default function Home() {
    const [count, setCount] = useState(0);
    return (
      <div>
        <h1>Count: {count}</h1>
        <button onClick={() => setCount(c => c + 1)}>+</button>
      </div>
    );
  }
`);

// Start the dev server
const server = new NextDevServer(vfs, { port: 3000 });
const bridge = getServerBridge();
await bridge.initServiceWorker();
bridge.registerServer(server, 3000);

// Access at: /__virtual__/3000/

Service Worker Setup

almostnode uses a Service Worker to intercept HTTP requests and route them to virtual dev servers (e.g., ViteDevServer, NextDevServer).

Note: The service worker is only needed if you're using dev servers with URL access (e.g., /__virtual__/3000/). If you're only executing code with runtime.execute(), you don't need the service worker.

Which Setup Do I Need?

Use Case Setup Required
Cross-origin sandbox (recommended for untrusted code) generateSandboxFiles() - includes everything
Same-origin with Vite almostnodePlugin from almostnode/vite
Same-origin with Next.js getServiceWorkerContent from almostnode/next
Same-origin with other frameworks Manual copy to public directory

Option 1: Cross-Origin Sandbox (Recommended)

When using createRuntime() with a cross-origin sandbox URL, the service worker must be deployed with the sandbox, not your main app.

The generateSandboxFiles() helper generates all required files:

import { generateSandboxFiles } from 'almostnode';
import fs from 'fs';

const files = generateSandboxFiles();

// Creates: index.html, vercel.json, __sw__.js
fs.mkdirSync('sandbox', { recursive: true });
for (const [filename, content] of Object.entries(files)) {
  fs.writeFileSync(`sandbox/${filename}`, content);
}

// Deploy to a different origin:
// cd sandbox && vercel --prod

Generated files:

File Purpose
index.html Sandbox page that loads almostnode and registers the service worker
vercel.json CORS headers for cross-origin iframe embedding
__sw__.js Service worker for intercepting dev server requests

See Sandbox Setup for full deployment instructions.


Option 2: Same-Origin with Vite

For trusted code using dangerouslyAllowSameOrigin: true:

// vite.config.ts
import { defineConfig } from 'vite';
import { almostnodePlugin } from 'almostnode/vite';

export default defineConfig({
  plugins: [almostnodePlugin()]
});

The plugin serves /__sw__.js automatically during development.

Custom path:

// vite.config.ts
almostnodePlugin({ swPath: '/custom/__sw__.js' })

// Then in your app:
await bridge.initServiceWorker({ swUrl: '/custom/__sw__.js' });

Option 3: Same-Origin with Next.js

For trusted code using dangerouslyAllowSameOrigin: true:

App Router:

// app/__sw__.js/route.ts
import { getServiceWorkerContent } from 'almostnode/next';

export async function GET() {
  return new Response(getServiceWorkerContent(), {
    headers: {
      'Content-Type': 'application/javascript',
      'Cache-Control': 'no-cache',
    },
  });
}

Pages Router:

// pages/api/__sw__.ts
import { getServiceWorkerContent } from 'almostnode/next';
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  res.setHeader('Content-Type', 'application/javascript');
  res.setHeader('Cache-Control', 'no-cache');
  res.send(getServiceWorkerContent());
}

Initialize with the correct path:

// App Router (file-based route)
await bridge.initServiceWorker({ swUrl: '/__sw__.js' });

// Pages Router (API route)
await bridge.initServiceWorker({ swUrl: '/api/__sw__' });

Available exports from almostnode/next:

Export Description
getServiceWorkerContent() Returns the service worker file content as a string
getServiceWorkerPath() Returns the absolute path to the service worker file

Option 4: Manual Setup (Other Frameworks)

Copy the service worker to your public directory:

cp node_modules/almostnode/dist/__sw__.js ./public/

Or programmatically:

import { getServiceWorkerPath } from 'almostnode/next';
import fs from 'fs';

fs.copyFileSync(getServiceWorkerPath(), './public/__sw__.js');

Comparison with WebContainers

Feature almostnode WebContainers
Bundle Size ~50KB ~2MB
Startup Time Instant 2-5 seconds
Execution Model Main thread or Web Worker (configurable) Web Worker isolates
Shell just-bash (POSIX subset) Full Linux kernel
Native Modules Stubs only Full support
Networking Virtual ports Real TCP/IP
Use Case Lightweight playgrounds, demos Full development environments

When to use almostnode

  • Building code playgrounds or tutorials
  • Creating interactive documentation
  • Prototyping without server setup
  • Educational tools
  • Lightweight sandboxed execution

Example: Code Playground

import { createContainer } from 'almostnode';

function createPlayground() {
  const container = createContainer();

  return {
    run: (code: string) => {
      try {
        const result = container.execute(code);
        return { success: true, result: result.exports };
      } catch (error) {
        return { success: false, error: error.message };
      }
    },
    reset: () => container.runtime.clearCache(),
  };
}

// Usage
const playground = createPlayground();
const output = playground.run(`
  const crypto = require('crypto');
  module.exports = crypto.randomUUID();
`);
console.log(output); // { success: true, result: "550e8400-e29b-..." }

When to use WebContainers

  • Full-fidelity Node.js development
  • Running native modules
  • Complex build pipelines
  • Production-like environments

API Reference

createContainer(options?)

Creates a new container with all components initialized.

interface ContainerOptions {
  cwd?: string;           // Working directory (default: '/')
  env?: Record<string, string>;  // Environment variables
  onConsole?: (method: string, args: any[]) => void;  // Console hook
}

const container = createContainer({
  cwd: '/app',
  env: { NODE_ENV: 'development' },
  onConsole: (method, args) => console.log(`[${method}]`, ...args),
});

Returns:

  • container.vfs - VirtualFS instance
  • container.runtime - Runtime instance
  • container.npm - PackageManager instance
  • container.serverBridge - ServerBridge instance

VirtualFS

Node.js-compatible filesystem API.

// Synchronous operations
vfs.writeFileSync(path, content);
vfs.readFileSync(path, encoding?);
vfs.mkdirSync(path, { recursive: true });
vfs.readdirSync(path);
vfs.statSync(path);
vfs.unlinkSync(path);
vfs.rmdirSync(path);
vfs.existsSync(path);
vfs.renameSync(oldPath, newPath);

// Async operations
await vfs.readFile(path, encoding?);
await vfs.stat(path);

// File watching
vfs.watch(path, { recursive: true }, (event, filename) => {
  console.log(`${event}: ${filename}`);
});

Runtime

Execute JavaScript/TypeScript code.

// Execute code string
runtime.execute('console.log("Hello")');

// Run a file from VirtualFS
runtime.runFile('/path/to/file.js');

// Require a module
const module = runtime.require('/path/to/module.js');

createRuntime (Async Runtime Factory)

For advanced use cases, use createRuntime to create a runtime with security options:

import { createRuntime, VirtualFS } from 'almostnode';

const vfs = new VirtualFS();

// RECOMMENDED: Cross-origin sandbox (fully isolated)
const secureRuntime = await createRuntime(vfs, {
  sandbox: 'https://your-sandbox.vercel.app',
});

// For demos/trusted code: Same-origin with explicit opt-in
const demoRuntime = await createRuntime(vfs, {
  dangerouslyAllowSameOrigin: true,
  useWorker: true,  // Optional: run in Web Worker
  cwd: '/project',
  env: { NODE_ENV: 'development' },
});

// Both modes use the same async API
const result = await secureRuntime.execute('module.exports = 1 + 1;');
console.log(result.exports); // 2

Security Modes

Mode Option Security Level Use Case
Cross-origin sandbox sandbox: 'https://...' Highest Production, untrusted code
Same-origin Worker dangerouslyAllowSameOrigin: true, useWorker: true Medium Demos with trusted code
Same-origin main thread dangerouslyAllowSameOrigin: true Lowest Trusted code only

Security by default: createRuntime() throws an error if neither sandbox nor dangerouslyAllowSameOrigin is provided.


Sandbox Setup

For running untrusted code securely, deploy a cross-origin sandbox. The key requirement is that the sandbox must be served from a different origin (different domain, subdomain, or port).

Quick Setup (Vercel)

import { generateSandboxFiles } from 'almostnode';
import fs from 'fs';

const files = generateSandboxFiles();
// Generates: index.html, vercel.json, __sw__.js

fs.mkdirSync('sandbox', { recursive: true });
for (const [filename, content] of Object.entries(files)) {
  fs.writeFileSync(`sandbox/${filename}`, content);
}

// Deploy: cd sandbox && vercel --prod

The generated files include:

  • index.html - Sandbox page with service worker registration
  • vercel.json - CORS headers for cross-origin iframe embedding
  • __sw__.js - Service worker for dev server URL access

Manual Setup (Any Platform)

The sandbox requires two things:

1. The sandbox HTML page

Create an index.html that loads almostnode and handles postMessage:

<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body>
<script type="module">
  import { VirtualFS, Runtime } from 'https://unpkg.com/almostnode/dist/index.js';

  let vfs = null;
  let runtime = null;

  window.addEventListener('message', async (event) => {
    const { type, id, code, filename, vfsSnapshot, options, path, content } = event.data;

    try {
      switch (type) {
        case 'init':
          vfs = VirtualFS.fromSnapshot(vfsSnapshot);
          runtime = new Runtime(vfs, {
            cwd: options?.cwd,
            env: options?.env,
            onConsole: (method, args) => {
              parent.postMessage({ type: 'console', consoleMethod: method, consoleArgs: args }, '*');
            },
          });
          break;
        case 'execute':
          const result = runtime.execute(code, filename);
          parent.postMessage({ type: 'result', id, result }, '*');
          break;
        case 'runFile':
          const runResult = runtime.runFile(filename);
          parent.postMessage({ type: 'result', id, result: runResult }, '*');
          break;
        case 'syncFile':
          if (content === null) { try { vfs.unlinkSync(path); } catch {} }
          else { vfs.writeFileSync(path, content); }
          break;
        case 'clearCache':
          runtime?.clearCache();
          break;
      }
    } catch (error) {
      if (id) parent.postMessage({ type: 'error', id, error: error.message }, '*');
    }
  });

  parent.postMessage({ type: 'ready' }, '*');
</script>
</body>
</html>

2. Required HTTP headers

The sandbox server must include these headers:

Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin

Example configurations:

Nginx
server {
    listen 3002;
    root /path/to/sandbox;

    location / {
        add_header Access-Control-Allow-Origin *;
        add_header Cross-Origin-Resource-Policy cross-origin;
    }
}
Apache (.htaccess)
Header set Access-Control-Allow-Origin "*"
Header set Cross-Origin-Resource-Policy "cross-origin"
Express.js
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
  next();
});
app.use(express.static('sandbox'));
app.listen(3002);
Python (http.server)
from http.server import HTTPServer, SimpleHTTPRequestHandler

class CORSHandler(SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Cross-Origin-Resource-Policy', 'cross-origin')
        super().end_headers()

HTTPServer(('', 3002), CORSHandler).serve_forever()

Use in your app

const runtime = await createRuntime(vfs, {
  sandbox: 'https://sandbox.yourdomain.com',  // Must be different origin!
});

// Code runs in isolated cross-origin iframe
const result = await runtime.execute(untrustedCode);

Local Development

For local testing, run the sandbox on a different port:

# Terminal 1: Main app on port 5173
npm run dev

# Terminal 2: Sandbox on port 3002
npm run sandbox

Then use sandbox: 'http://localhost:3002/sandbox/' in your app.

What cross-origin sandbox protects

Threat Status
Cookies Blocked (different origin)
localStorage Blocked (different origin)
IndexedDB Blocked (different origin)
DOM access Blocked (cross-origin iframe)

Note: Network requests from the sandbox are still possible. Add CSP headers for additional protection.

PackageManager

Install npm packages.

// Install a package
await npm.install('react');
await npm.install('lodash@4.17.21');

// Install multiple packages
await npm.install(['react', 'react-dom']);

Supported Node.js APIs

967 compatibility tests verify our 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:

  • net, tls, dns, dgram
  • cluster, worker_threads
  • vm, v8, inspector
  • async_hooks

Framework Support

Vite

import { VirtualFS, ViteDevServer, getServerBridge } from 'almostnode';

const vfs = new VirtualFS();

// Create a React app
vfs.writeFileSync('/index.html', `
  <!DOCTYPE html>
  <html>
    <body>
      <div id="root"></div>
      <script type="module" src="/src/main.jsx"></script>
    </body>
  </html>
`);

vfs.mkdirSync('/src', { recursive: true });
vfs.writeFileSync('/src/main.jsx', `
  import React from 'react';
  import ReactDOM from 'react-dom/client';

  function App() {
    return <h1>Hello Vite!</h1>;
  }

  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
`);

// Start Vite dev server
const server = new ViteDevServer(vfs, { port: 5173 });

Next.js

Supports both Pages Router and App Router:

Pages Router

/pages
  /index.jsx      → /
  /about.jsx      → /about
  /users/[id].jsx → /users/:id
  /api/hello.js   → /api/hello

App Router

/app
  /layout.jsx           → Root layout
  /page.jsx             → /
  /about/page.jsx       → /about
  /users/[id]/page.jsx  → /users/:id

Hot Module Replacement (HMR)

almostnode includes built-in Hot Module Replacement support for instant updates during development. When you edit files, changes appear immediately in the preview without a full page reload.

How It Works

HMR is automatically enabled when using NextDevServer or ViteDevServer. The system uses:

  1. VirtualFS file watching - Detects file changes via vfs.watch()
  2. postMessage API - Communicates updates between the main page and preview iframe
  3. React Refresh - Preserves React component state during updates
// HMR works automatically - just edit files and save
vfs.writeFileSync('/app/page.tsx', updatedContent);
// The preview iframe will automatically refresh with the new content

Setup Requirements

For security, the preview iframe should be sandboxed. HMR uses postMessage for communication, which works correctly with sandboxed iframes:

// Create sandboxed iframe for security
const iframe = document.createElement('iframe');
iframe.src = '/__virtual__/3000/';
// Sandbox restricts the iframe's capabilities - add only what you need
iframe.sandbox = 'allow-forms allow-scripts allow-same-origin allow-popups';
container.appendChild(iframe);

// Register the iframe as HMR target after it loads
iframe.onload = () => {
  if (iframe.contentWindow) {
    devServer.setHMRTarget(iframe.contentWindow);
  }
};

Recommended sandbox permissions:

  • allow-scripts - Required for JavaScript execution
  • allow-same-origin - Allows the iframe to access cookies, localStorage, and IndexedDB (only add if your app needs these; omit for better isolation)
  • allow-forms - If your app uses forms
  • allow-popups - If your app opens new windows/tabs

Note: The service worker intercepts /__virtual__/ requests at the origin level, not the iframe level. The allow-same-origin attribute does NOT affect service worker functionality. For maximum security isolation, consider using cross-origin sandbox mode (see below) which doesn't use allow-same-origin.

Manual HMR Triggering

If you need to manually trigger HMR updates (e.g., after programmatic file changes):

function triggerHMR(path: string, iframe: HTMLIFrameElement): void {
  if (iframe.contentWindow) {
    iframe.contentWindow.postMessage({
      type: 'update',
      path,
      timestamp: Date.now(),
      channel: 'next-hmr', // Use 'vite-hmr' for Vite
    }, '*');
  }
}

// After writing a file
vfs.writeFileSync('/app/page.tsx', newContent);
triggerHMR('/app/page.tsx', iframe);

Supported File Types

File Type HMR Behavior
.jsx, .tsx React Refresh (preserves state)
.js, .ts Full module reload
.css Style injection (no reload)
.json Full page reload

Development

Setup

git clone https://github.com/macaly/almostnode.git
cd almostnode
npm install

Run Tests

# Unit tests
npm test

# E2E tests (requires Playwright)
npm run test:e2e

Development Server

npm run dev

Open http://localhost:5173/examples/next-demo.html to see the Next.js demo.


Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

MIT License - see LICENSE for details.


Acknowledgments


Built by the creators of Macaly.com

About

Node.js in your browser. Just like that.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •