QuickJS JavaScript runtime with WAMR AOT compilation for fast, sandboxed JavaScript execution at the edge.
┌───────────────────────────────────────────────────────────┐
│ EdgeBox │
├───────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │
│ │ QuickJS-NG │ │ WASI │ │ WAMR Runtime │ │
│ │ (ES2024) │──│ (preview1) │──│ + AOT Compiler │ │
│ │ [vendored] │ │ │ │ (LLVM-based) │ │
│ └─────────────┘ └─────────────┘ └───────────────────┘ │
├───────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Node.js Polyfills │ │
│ │ - Buffer, path, events, util, os, tty │ │
│ │ - process.stdin/stdout/stderr, env, argv │ │
│ │ - fetch (HTTP/HTTPS), child_process (spawnSync) │ │
│ │ - TLS 1.3 (X25519 + AES-GCM via std.crypto) │ │
│ └─────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ WAMR AOT (Ahead-of-Time) Compilation │ │
│ │ - WASM → Native code at build time (via LLVM) │ │
│ │ - Still sandboxed: memory bounds checks preserved │ │
│ │ - WASI syscalls intercepted by runtime │ │
│ │ - 13ms cold start, 454KB binary size │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
WAMR (WebAssembly Micro Runtime) is a lightweight WASM runtime from the Bytecode Alliance. EdgeBox uses WAMR with AOT (Ahead-of-Time) compilation to achieve native performance while maintaining WASM's security guarantees.
Build Time (wamrc compiler):
module.wasm ──LLVM──> module.aot
│ │
│ WASM bytecode │ Native machine code
│ (portable) │ (platform-specific)
│ │
└── Validated & bounds └── Bounds checks compiled in
checks at parse as native instructions
The .aot file contains native machine code, but it's still sandboxed:
-
Memory Isolation: All memory accesses go through bounds-checked instructions compiled into the AOT code. Out-of-bounds access triggers a trap, not a segfault.
-
WASI Syscall Interception: All I/O (filesystem, network, process) goes through WASI, which EdgeBox controls. You configure allowed directories in
.edgebox.json. -
No Arbitrary Code Execution: The AOT code is compiled from validated WASM - it can only do what WASM allows, just faster.
┌─────────────────────────────────────────────────────────┐
│ Security Model │
├─────────────────────────────────────────────────────────┤
│ .aot file (native code) │
│ ├── Memory access: bounds-checked instructions │
│ ├── Function calls: validated call table │
│ └── System calls: all go through WASI → │
│ ↓ │
│ WAMR Runtime │
│ ├── WASI filesystem: only dirs in .edgebox.json │
│ ├── WASI sockets: controlled by runtime │
│ └── Process spawning: command allowlist │
└─────────────────────────────────────────────────────────┘
Think of it as: "Pre-compiled WASM" - same sandbox, native speed.
LLVM 18 is the only system dependency required to build EdgeBox tools:
# macOS
brew install llvm@18
# Ubuntu/Debian
sudo apt-get install llvm-18 llvm-18-dev
# Arch Linux
sudo pacman -S llvmWhy LLVM is a system dependency:
- Only needed for
edgeboxc(the build tool with embedded AOT compiler) - NOT needed for runtime (
edgebox) - it uses WAMR's interpreter/AOT - LLVM is ~1.5GB source, 30-60min build time - impractical to vendor
- Standard practice: Rust, Node.js, and other compiled languages have system build dependencies
All other dependencies are vendored as git submodules:
- QuickJS-NG (JavaScript engine)
- WAMR (WebAssembly runtime)
- Binaryen (WASM optimizer)
- Zig 0.15.2 - Build system (install)
- Bun - For bundling JS files (install)
- CMake & Ninja - For building WAMR and Binaryen
# macOS (all tools)
brew install zig llvm@18 oven-sh/bun/bun cmake ninja
# Ubuntu/Debian (all tools)
curl -fsSL https://ziglang.org/download/0.15.2/zig-linux-x86_64-0.15.2.tar.xz | tar -xJ
curl -fsSL https://bun.sh/install | bash
sudo apt-get install llvm-18 llvm-18-dev cmake ninja-buildEdgeBox fully supports ARM64 Mac with these execution modes:
| Mode | Binary | Performance | Notes |
|---|---|---|---|
| AOT | edgebox file.aot |
100% native | Recommended - compile once with wamrc |
| Fast JIT | edgebox-rosetta file.wasm |
~50% native | Auto-built, runs x86_64 via Rosetta 2 |
| Interpreter | edgebox file.wasm |
~5% native | Fallback, always works |
Why no native ARM64 JIT?
- WAMR's Fast JIT uses
asmjitlibrary which only supports x86_64 - WAMR's LLVM JIT supports ARM64 but requires linking ~1.8GB LLVM libs
- Rosetta 2 is a pragmatic solution: run x86_64 Fast JIT with ~95% translation efficiency
Recommendation: Use AOT for production (best performance, smallest binary).
# 1. Build EdgeBox CLI tools
zig build cli -Doptimize=ReleaseFast
# 2. Build your JS app to AOT (frozen functions + native code)
./zig-out/bin/edgeboxc build myapp.js
# Creates: edgebox-static.aot (fast!)
# 3. Run
./zig-out/bin/edgebox edgebox-static.aotBenchmarks on Apple M3 Max, macOS 15.5. EdgeBox uses WAMR with AOT compilation + frozen interpreter for native performance. EdgeBox wins ALL 6 benchmarks.
| Metric | EdgeBox (AOT) | Bun | Node.js | EdgeBox Advantage |
|---|---|---|---|---|
| Startup | 14 ms | 51 ms | 76 ms | 3.5x faster than Bun |
| Memory | 1.4 MB | 104 MB | 145 MB | 75x less than Bun |
| fib(45) | 4.7s | 24.9s | 24.5s | 5.3x faster than Bun |
| Tail Call (1M) | 202 ms | 6969 ms | 155s | 35x faster than Bun |
| Loop (100k×100) | 224 ms | 620 ms | 1549 ms | 2.8x faster than Bun |
| TypedArray | 320 ms | 597 ms | 1335 ms | 1.9x faster than Bun |
| Runtime | Time | Relative |
|---|---|---|
| EdgeBox (AOT) | 14.4 ms | 1.00x |
| Bun | 51.0 ms | 3.55x slower |
| Node.js | 76.2 ms | 5.30x slower |
Measured with
hyperfine -Nto eliminate shell overhead. EdgeBox daemon mode provides <1ms for cached modules.
| Runtime | Memory | Relative |
|---|---|---|
| EdgeBox (AOT) | 1.4 MB | 1.00x |
| EdgeBox (WASM) | 1.4 MB | 1.00x |
| Bun | 104.3 MB | 75x more |
| Node.js | 145.4 MB | 104x more |
EdgeBox runs inside a WASM sandbox with bounded linear memory. No V8/JSC heap overhead.
| Runtime | Time | Relative |
|---|---|---|
| EdgeBox (AOT) | 4,709 ms | 1.00x |
| Node.js | 24,545 ms | 5.21x slower |
| Bun | 24,893 ms | 5.29x slower |
The frozen interpreter transpiles pure recursive functions to native C code.
| Runtime | Time | Relative |
|---|---|---|
| EdgeBox (AOT) | 202 ms | 1.00x |
| Bun | 6,969 ms | 35x slower |
| Node.js | 155,126 ms | 768x slower |
EdgeBox optimizes tail calls in the frozen interpreter.
| Runtime | Time | Relative |
|---|---|---|
| EdgeBox (AOT) | 224 ms | 1.00x |
| Bun | 620 ms | 2.77x slower |
| Node.js | 1,549 ms | 6.91x slower |
| Runtime | Time | Relative |
|---|---|---|
| EdgeBox (AOT) | 320 ms | 1.00x |
| Bun | 597 ms | 1.87x slower |
| Node.js | 1,335 ms | 4.17x slower |
The frozen interpreter transpiles recursive JS to native C code, eliminating JSValue boxing overhead in tight loops.
The frozen interpreter transpiles ALL pure JS functions to native C code:
- Detection: Scans bytecode - any function using only supported opcodes gets frozen
- Code generation: Unrolls bytecode to direct C with SMI (Small Integer) fast paths
- Hook injection: Adds
if(globalThis.__frozen_X) return __frozen_X(...)to JS - AOT compilation: WAMR compiles WASM+frozen C to native machine code
- Self-recursion optimization: Recursive functions get direct C calls (no JS overhead)
// Generated frozen function - works for ANY pure function
static JSValue frozen_square(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
JSValue stack[256]; int sp = 0;
/* get_arg0 */ PUSH(argc > 0 ? FROZEN_DUP(ctx, argv[0]) : JS_UNDEFINED);
/* dup */ PUSH(FROZEN_DUP(ctx, TOP()));
/* mul */ { JSValue b = POP(), a = POP(); PUSH(frozen_mul(ctx, a, b)); }
return POP();
}
// Self-recursive functions get additional optimization (direct C recursion)
static int64_t frozen_fib_native(int64_t n) {
if (n < 2) return n;
return frozen_fib_native(n - 1) + frozen_fib_native(n - 2);
}Why it's safe:
- Only optimizes pure functions (no side effects, no closures)
- SMI fast path for int32 arithmetic (no heap allocation)
- Falls back to JS interpreter for unsupported opcodes
Integrated into edgeboxc build pipeline:
# Build automatically freezes ALL pure functions
zig build cli -Doptimize=ReleaseFast
./zig-out/bin/edgeboxc build myapp/
# Manual usage
./zig-out/bin/qjsc -e -o bytecode.c -N mymodule myfunc.js
./zig-out/bin/edgebox-freeze bytecode.c -o frozen.c -m mymoduleArchitecture: Function-level validation ensures correctness. If a function contains unsupported opcodes, it runs in the interpreter instead - no crashes, no wrong results.
Supported opcodes (87 of ~250):
| Category | Count | Opcodes | Comptime |
|---|---|---|---|
| Arithmetic | 12 | add, sub, mul, div, mod, inc, dec, neg, plus, inc_loc, dec_loc, add_loc | 9 ✓ |
| Comparison | 8 | lt, lte, gt, gte, eq, neq, strict_eq, strict_neq | 8 ✓ |
| Bitwise | 7 | and, or, xor, shl, sar, shr, not | 7 ✓ |
| Push/const | 14 | push_minus1, push_0..7, push_true, push_false, null, undefined, push_i8/i16/i32 | 13 ✓ |
| Locals | 12 | get_loc0..3, get_loc, get_loc8, put_loc0..3, put_loc, put_loc8 | 8 ✓ |
| Arguments | 7 | get_arg0..3, get_arg, put_arg0, put_arg1 | 6 ✓ |
| Var refs | 4 | get_var_ref0..3 | - |
| Control | 10 | if_false/8, if_true/8, goto/8/16, return, return_undef | 2 ✓ |
| Calls | 4 | call0, call1, call2, call3 | - |
| Stack | 3 | drop, dup, dup2 | 3 ✓ |
| Property | 3 | get_field, get_field2, put_field | 3 ✓ |
| TCO | 2 | tail_call, tail_call_method | 2 ✓ |
Bold = comptime-generated from
opcode_handlers.zigpatterns (61 ops)
Supported: Any pure function using only the above opcodes (arithmetic, comparison, locals, args, control flow, property access).
Not supported: Closures, async/await, classes, eval.
The frozen interpreter uses Zig comptime to auto-generate opcode handlers from patterns. When QuickJS-NG updates, handlers regenerate automatically:
vendor/quickjs-ng/quickjs-opcode.h # QuickJS opcode definitions
↓ zig build gen-opcodes
src/freeze/opcodes_gen.zig # Generated enum with opcode values
↓ comptime
src/freeze/opcode_handlers.zig # Pattern mappings (name → handler)
↓ comptime generateCode()
Generated C code # Handlers auto-generated at compile time
Why this works:
- Opcode values may change between QuickJS versions (e.g.,
add= 45 → 47) - Comptime patterns are tied to names, not values
getHandler(.add)always maps tobinary_arithpattern regardless of value
// opcode_handlers.zig - tied to NAME, not numeric value
.add => .{ .pattern = .binary_arith, .c_func = "frozen_add" },
.sub => .{ .pattern = .binary_arith, .c_func = "frozen_sub" },
// When QuickJS updates opcodes_gen.zig with new values,
// the pattern mapping stays the same - handlers auto-regenerate!After QuickJS-NG update:
cd vendor/quickjs-ng && git pull # Update QuickJS-NG
zig build gen-opcodes # Regenerate opcodes_gen.zig from headers
zig build freeze # Comptime regenerates all 61 handlersKey Insights:
- EdgeBox daemon warm (~15ms) is competitive with Bun for startup when pool is ready
- EdgeBox AOT is 1.84x faster than Bun, 2.69x faster than Node.js on CPU-bound tasks
- EdgeBox is sandboxed via WASM - memory bounds checks + WASI syscall interception
- CPU-bound tasks: Frozen interpreter delivers native C performance
- Binary size: 454KB (minimal WAMR runtime)
Trade-offs:
- Startup overhead (~40ms for WAMR AOT loading) is higher than Bun/Node.js (~10-20ms)
- Best for: CPU-intensive workloads, sandboxed execution, edge deployment
- For short-lived tasks: Use daemon mode with warm pool for fast startup
EdgeBox uses different allocators depending on the execution mode:
| Mode | Allocator | Why |
|---|---|---|
| Daemon (cached modules) | Bump/Arena + Reset | O(1) alloc, instant cleanup between requests |
| Single-run CLI | mimalloc/jemalloc | Proper free(), lower peak memory |
Optimized for serverless/request-response patterns:
| Operation | Speed | How |
|---|---|---|
| malloc | O(1) | Bump pointer with size header |
| realloc (grow) | O(1)* | In-place if at top of arena |
| free | O(1)* | Reclaim if at top (LIFO pattern) |
| reset | O(1) | Reset pointer to mark position |
*LIFO optimization - hits ~80% of QuickJS allocations
Request 1: alloc → alloc → alloc → reset() // Instant cleanup
Request 2: alloc → alloc → alloc → reset() // Reuses same memory
Trade-off: Higher peak memory (free is no-op for non-LIFO), but faster allocation and zero fragmentation.
For one-off CLI invocations, uses mimalloc/jemalloc for proper memory reclamation:
- Real
free()returns memory to OS - Lower peak memory usage
- Better for interactive/REPL usage
EdgeBox runs a background daemon that caches loaded modules for instant subsequent execution:
# First run: daemon auto-starts, loads module (~40ms)
edgebox app.aot
# Subsequent runs: module already cached (<1ms)
edgebox app.aot
# Daemon management (Docker-style)
edgebox up app.aot # Pre-load module into cache
edgebox down app.aot # Remove from cache
edgebox exit # Stop daemon gracefullyArchitecture:
┌─────────────────────────────────────────────────────────────────┐
│ edgebox daemon │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Module Cache (HashMap) │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │app1.aot│ │app2.aot│ │app3.aot│ ... │ │
│ │ └────────┘ └────────┘ └────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────┴─────────┐ │
│ │ Unix Socket │ Clients send run/up/down/exit │
│ │ /tmp/edgebox.sock│ Daemon responds with output │
│ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key insight: Instead of loading the WASM/AOT module on every execution (~40ms), the daemon keeps modules cached in memory. Subsequent runs reuse the cached module for near-instant startup.
How it works:
- First run: Daemon auto-starts (forked process), loads and caches module
- Subsequent runs: Module found in cache, execute immediately (<1ms overhead)
upcommand: Explicitly pre-load a module before first usedowncommand: Remove a module from cache (free memory)exitcommand: Stop the daemon process gracefully
This is similar to how Docker manages containers - first pull is slow, subsequent runs are instant.
EdgeBox takes a different approach to sandboxing compared to Anthropic's sandbox-runtime:
| Aspect | EdgeBox | sandbox-runtime |
|---|---|---|
| Approach | WASM sandbox (code runs inside) | OS-level sandbox (wraps process) |
| Technology | WAMR AOT + QuickJS | macOS: sandbox-exec, Linux: bubblewrap |
| Cold Start | 13ms | ~50-200ms |
| Memory | ~2MB | ~50MB+ |
| Binary Size | 454KB | N/A (uses system tools) |
| Can Run | JavaScript only | Any binary (git, python, etc.) |
| Network | Built-in fetch (via host) | HTTP/SOCKS5 proxy filtering |
| Command Control | Argument-level allowlist | N/A (wraps any process) |
| Windows | Yes (WASM portable) | No |
When to use EdgeBox: Running untrusted JS at edge/serverless scale with minimal overhead.
When to use sandbox-runtime: Sandboxing arbitrary binaries on developer machines.
Complementary: EdgeBox can use sandbox-runtime to wrap child_process.spawnSync() calls for defense-in-depth.
EdgeBox includes an OS-level sandbox wrapper (edgebox-sandbox) that enforces .edgebox.json dirs permissions when spawning child processes:
| Platform | Technology | Status |
|---|---|---|
| macOS | sandbox-exec |
✅ |
| Linux | bubblewrap |
✅ |
| Windows | Job Objects + Restricted Tokens | ✅ |
This prevents shell escape attacks like git checkout > /etc/passwd by restricting filesystem access at the kernel level. The sandbox is automatic - when dirs is configured, all child_process commands are wrapped.
- WAMR AOT Compilation - LLVM compiles WASM → native code at build time
- Lightweight Runtime - WAMR is 465KB binary
- Memory Bounds Checks - Compiled into native code (no runtime overhead)
- WASM SIMD128 - 16-byte vector operations for string processing
- Bump Allocator - O(1) malloc (pointer bump), NO-OP free (memory reclaimed at exit)
- wasm-opt -Oz - 82% binary size reduction (5.8MB → 1.6MB WASM)
- Lazy Polyfills - Only inject minimal bootstrap on startup, load Node.js polyfills on-demand
- Bytecode Caching - Pre-compile JavaScript at build time, skip parsing at runtime
EdgeBox uses qjsc (QuickJS compiler) to pre-compile JavaScript to bytecode at build time:
edgebox build my-app/
↓
bundle.js (12KB) → JavaScript source + polyfills
↓ qjsc
bundle_compiled.c (71KB) → Bytecode embedded as C array
↓ zig build
edgebox-static.wasm → WASM with bytecode baked in
edgebox run edgebox-static.wasm
↓
Load bytecode directly → Execute (no JS parsing)
This eliminates all JavaScript parsing at runtime - the bytecode is part of the WASM binary.
EdgeBox includes a pure Zig implementation of Wizer (src/wizer.zig) to pre-initialize the QuickJS runtime at build time. This removes the Rust dependency and integrates directly into the build pipeline.
Build time (wizer_init):
├── JS_NewRuntime() # Create QuickJS runtime
├── JS_NewContext() # Create context
├── js_init_module_std() # Initialize std module
├── initStaticPolyfills() # TextEncoder, URL, Event, etc.
└── Snapshot memory # Embedded in WASM binary
Runtime (_start):
├── Check wizer_initialized flag
├── js_std_add_helpers() # Bind console/print
├── bindDynamicState() # process.argv, process.env
└── Execute user code # 0.03ms to first instruction
The Zig Wizer implementation:
- Loads WASM via WAMR C API
- Runs
wizer.initializeexport to execute init code - Snapshots memory with sparse segment optimization (merges gaps < 4 bytes)
- Rewrites WASM binary with pre-initialized data segments
# Manual snapshot (usually handled by edgeboxc build)
edgeboxc snapshot input.wasm output.wasm --init-func=wizer.initialize- Zig 0.13+ (required for building):
# macOS
brew install zig
# Linux: https://ziglang.org/download/- LLVM 18 (optional, for AOT compilation):
# macOS
brew install llvm@18
# Only needed to build wamrc (AOT compiler)
# Pre-compiled .aot files don't need LLVM- Bun (optional, for JS bundling):
# macOS
brew install oven-sh/bun/bun
# Linux/macOS (alternative)
curl -fsSL https://bun.sh/install | bash# Build WAMR runtime (recommended)
zig build wamr -Doptimize=ReleaseFast
# Build WASM module
zig build wasm -Doptimize=ReleaseFast
# AOT compile WASM → native (requires wamrc)
./vendor/wamr/wamr-compiler/build/wamrc -o edgebox.aot zig-out/bin/edgebox-base.wasm
# Run with AOT (fastest - 10ms cold start)
./zig-out/bin/edgebox-wamr edgebox.aot hello.js
# Run with interpreter (no AOT needed - 48ms cold start)
./zig-out/bin/edgebox-wamr zig-out/bin/edgebox-base.wasm hello.jsLLVM is required only for building wamrc (the AOT compiler). Once you have an .aot file, LLVM is NOT needed to run it.
# 1. Install LLVM 18 (one-time setup)
brew install llvm@18 # macOS
# apt install llvm-18 # Linux
# 2. Build wamrc
cd vendor/wamr/wamr-compiler
mkdir -p build && cd build
cmake .. -DWAMR_BUILD_WITH_CUSTOM_LLVM=1 -DCMAKE_PREFIX_PATH="/opt/homebrew/opt/llvm@18"
make -j4
# 3. AOT compile your WASM
./wamrc -o edgebox.aot edgebox-base.wasm
# 4. Run (no LLVM needed!)
./edgebox-wamr edgebox.aot hello.jsNote: We don't bundle LLVM (it's 1.8GB). Users either:
- Install LLVM and build
wamrcthemselves, OR - Use pre-compiled
.aotfiles (we can ship these), OR - Run in interpreter mode (no AOT, 48ms instead of 10ms)
EdgeBox provides two binaries:
| Binary | Purpose | Description |
|---|---|---|
edgebox |
Runtime | Runs WASM/AOT via daemon (auto-starts) |
edgeboxc |
Compiler | Compiles JS → WASM → AOT |
# Run compiled module (daemon auto-starts and caches)
edgebox app.aot # Run AOT (fastest)
edgebox app.wasm # Run WASM (interpreter)
# Explicit run command
edgebox run app.aot # Same as above
# Daemon management
edgebox up app.aot # Pre-load into cache
edgebox down app.aot # Remove from cache
edgebox exit # Stop daemonedgeboxc build <app_dir> # Compile JS to WASM/AOT
edgeboxc build my-app # Example
edgeboxc --help # Show help
edgeboxc --version # Show versionzig build wasm
↓
edgebox-base.wasm (1.6MB) → QuickJS + polyfills as WASM
↓ wamrc (LLVM AOT compiler)
edgebox.aot (2.6MB) → Native machine code (still sandboxed!)
↓
edgebox-wamr edgebox.aot hello.js → 10ms cold start
The AOT file is native code but still runs in the WASM sandbox - all memory accesses are bounds-checked and all I/O goes through WASI.
Apps can include a .edgebox.json config file:
{
"name": "my-app",
"npm": "@anthropic-ai/claude-code",
"runtime": {
"stack_size": 16777216,
"heap_size": 268435456,
"max_memory_pages": 32768,
"max_instructions": -1
},
"dirs": [
{"path": "/", "read": true, "write": false, "execute": true},
{"path": "/tmp", "read": true, "write": true, "execute": true},
{"path": "$HOME", "read": true, "write": true, "execute": true}
],
"env": ["ANTHROPIC_API_KEY", "HOME", "USER", "PATH"],
"allowCommands": ["git", "npm", "node", "curl", "cat", "ls"],
"denyCommands": ["sudo", "su", "rm"],
"useKeychain": true
}| Field | Description |
|---|---|
npm |
npm package to install and use as entry point |
runtime |
Runtime memory configuration: stack_size, heap_size, max_memory_pages |
dirs |
Directory permissions (see below) |
env |
Environment variables to pass to the app |
allowCommands |
Commands allowed for spawn (empty = allow all) |
denyCommands |
Commands denied for spawn (takes precedence) |
allowedUrls |
URL patterns allowed for fetch (glob: https://api.anthropic.com/*) |
blockedUrls |
URL patterns blocked (takes precedence over allowed) |
useKeychain |
Read API key from macOS keychain (default: false) |
rateLimitRps |
Max HTTP requests per second (default: 0 = unlimited) |
maxConnections |
Max concurrent HTTP connections (default: 100) |
Control WASM runtime memory limits for CPU and memory-intensive applications.
{
"runtime": {
"stack_size": 16777216,
"heap_size": 268435456,
"max_memory_pages": 32768,
"max_instructions": -1
}
}| Field | Default | Description |
|---|---|---|
stack_size |
8388608 (8MB) |
WASM execution stack size in bytes |
heap_size |
268435456 (256MB) |
Host-managed heap for WAMR runtime allocations |
max_memory_pages |
32768 (2GB) |
Maximum WASM linear memory pages (64KB each) |
max_instructions |
-1 (unlimited) |
CPU limit: max instructions before termination (interpreter only) |
Memory model explained:
┌─────────────────────────────────────────────────────────────┐
│ WASM Memory Model │
├─────────────────────────────────────────────────────────────┤
│ heap_size (host-managed) │
│ └── WAMR internal structures, module metadata │
│ │
│ max_memory_pages × 64KB (WASM linear memory) │
│ └── QuickJS heap, JS objects, bytecode, user data │
│ └── Can grow dynamically up to max_memory_pages limit │
│ └── WASM32 maximum: 65536 pages = 4GB │
└─────────────────────────────────────────────────────────────┘
Key difference:
heap_size: Fixed allocation for WAMR's internal usemax_memory_pages: Cap on how much WASM can dynamically allocate viamemory.grow
Common configurations:
| Use Case | stack_size | heap_size | max_memory_pages | Notes |
|---|---|---|---|---|
| Simple scripts | 8MB | 256MB | 32768 (2GB) | Default, sufficient for most apps |
| Large bundles (>10MB bytecode) | 16MB | 256MB | 32768 (2GB) | Claude CLI, complex apps |
| Memory-intensive (large objects) | 16MB | 512MB | 65536 (4GB) | Data processing, large JSON |
Example for large applications:
{
"runtime": {
"stack_size": 16777216,
"heap_size": 268435456,
"max_memory_pages": 32768
}
}Debugging memory issues:
# Enable debug output to see actual memory settings
EDGEBOX_DEBUG=1 ./zig-out/bin/edgebox app.aot
# Output shows:
# [DEBUG] Instantiating module (stack=16MB, heap=256MB, max_memory=2048MB)...Notes:
max_memory_pagescontrols how much memory WASM can dynamically request- Each page is 64KB, so 32768 pages = 2GB, 65536 pages = 4GB (WASM32 max)
- For 40MB+ bytecode (like Claude CLI), the default 2GB max is usually sufficient
- Stack overflow manifests as "out of bounds memory access" errors
- Environment variable
$HOMEand$PWDare expanded indirspaths
CPU Limits (max_instructions):
- Only works in interpreter mode (
.wasmfiles), not AOT mode (.aotfiles) - Set to a positive integer to limit CPU time (e.g.,
1000000000for ~1B instructions) - When exceeded, execution terminates with "instruction limit exceeded" error
- AOT mode ignores this setting (no overhead) - use OS-level timeout instead
- Useful for sandboxing untrusted code in development/testing
Control filesystem and shell access per directory. Default: no access.
{
"dirs": [
{"path": "/", "read": true, "write": false, "execute": true},
{"path": "/tmp", "read": true, "write": true, "execute": true},
{"path": "$HOME", "read": true, "write": true, "execute": true},
{"path": "$PWD", "read": true, "write": true, "execute": true}
]
}| Field | Type | Description |
|---|---|---|
path |
string | Directory path (supports $HOME, $PWD, ~) |
read |
boolean | Allow reading files in directory |
write |
boolean | Allow writing/creating files in directory |
execute |
boolean | Allow spawning commands (child_process) |
Security notes:
- By default, no directories are accessible (must explicitly grant)
- Shell access requires
execute: trueon at least one directory - Use
allowCommands/denyCommandsfor additional command filtering - Supports
$HOME,$PWD, and~for path expansion /home/userpaths are automatically mapped to actual$HOMEon the host
Control which commands can be spawned via child_process.
{
"allowCommands": ["git", "npm", "node", "curl"],
"denyCommands": ["sudo", "su", "rm", "chmod"]
}| Field | Description |
|---|---|
allowCommands |
If set, only these commands can run (empty = allow all) |
denyCommands |
These commands are always blocked (takes precedence) |
Security notes:
- Commands are matched by basename (e.g.,
/usr/bin/gitmatchesgit) - Deny list always takes precedence over allow list
- If allow list is empty, all commands are allowed (use deny list to block specific ones)
- Both require
xpermission indirs- command filtering is an additional layer
Control which URLs the app can fetch. Default: permissive (no restrictions).
{
"allowedUrls": [
"https://api.anthropic.com/*",
"https://api.openai.com/*"
],
"blockedUrls": [
"https://internal.corp/*"
],
"useKeychain": true
}| Field | Default | Description |
|---|---|---|
allowedUrls |
[] (allow all) |
Glob patterns for allowed URLs |
blockedUrls |
[] |
Glob patterns to block (takes precedence) |
useKeychain |
false |
Read ANTHROPIC_API_KEY from macOS keychain |
rateLimitRps |
0 |
Requests per second limit (0 = unlimited) |
maxConnections |
100 |
Max concurrent connections |
Security notes:
- WASM cannot access keychain directly - host reads it if
useKeychain: true - URL restrictions are enforced on the host side (WASM has no raw network access)
- Empty
allowedUrls= allow all URLs (permissive by default)
The commands field provides fine-grained control over which system commands can be executed via child_process.spawnSync():
{
"commands": {
"git": ["clone", "status", "add", "commit"],
"npm": ["install", "run"],
"node": true,
"curl": true
}
}| Value | Meaning |
|---|---|
["subcommand1", "subcommand2"] |
Only allow these subcommands (first argument must match) |
true |
Allow all arguments |
| Binary not listed | Denied (permission error) |
Security Model:
- Default deny: If
commandsis not specified, no commands can be executed - Explicit allowlist: Only whitelisted binaries can run
- Subcommand filtering: For
git,npm, etc., you can restrict to specific operations - No escape hatch: Unlike containers, there's no way to bypass with shell tricks
Example: Minimal permissions for a git-only workflow:
{
"commands": {
"git": ["status", "diff", "log"]
}
}Example: Full development permissions:
{
"commands": {
"git": true,
"npm": true,
"node": true,
"bun": true
}
}This is more secure than containers because:
- Permission is checked at the command+argument level, not just syscall level
- The WASM sandbox cannot execute arbitrary binaries not in the allowlist
- The config is auditable and explicit (no hidden capabilities)
edgebox/
├── build.zig # Zig build configuration
├── vendor/
│ ├── quickjs-ng/ # QuickJS-NG C source (vendored)
│ └── wamr/ # WAMR runtime (submodule)
│ └── wamr-compiler/ # AOT compiler (requires LLVM)
└── src/
├── edgebox_wamr.zig # WAMR runtime (recommended)
├── wasm_main.zig # WASM entry point & polyfills
├── quickjs_core.zig # QuickJS Zig bindings
├── wasm_fetch.zig # HTTP/HTTPS fetch via WASI sockets
├── wasi_tls.zig # TLS 1.3 client (X25519 + AES-GCM)
├── wasi_sock.zig # WASI socket bindings
└── wasi_process.zig # Process spawning (edgebox_process API)
Target Version: Node.js 22.x (tested in CI against official Node.js core test suite)
All 58 compatibility tests pass. Run edgebox run test/test_node_compat.js to verify.
| API | Status | Notes |
|---|---|---|
globalThis |
✅ | |
console |
✅ | log, error, warn, info |
process |
✅ | env, argv, cwd, exit, platform, stdin, stdout, stderr |
Buffer |
✅ | from, alloc, concat, toString |
fetch |
✅ | HTTP and HTTPS (TLS 1.3) |
Promise |
✅ | async/await |
setTimeout/setInterval |
✅ | Polyfilled (synchronous execution) |
queueMicrotask |
✅ | |
TextEncoder/TextDecoder |
✅ | UTF-8 support |
URL/URLSearchParams |
✅ | Full URL parsing |
AbortController/AbortSignal |
✅ | Request cancellation |
crypto |
✅ | randomUUID, getRandomValues |
require() |
✅ | CommonJS module loader |
fs module |
✅ | Sync operations + promises |
path module |
✅ | join, resolve, parse, etc. |
events module |
✅ | EventEmitter |
util module |
✅ | format, promisify |
os module |
✅ | platform, arch, homedir |
tty module |
✅ | isatty, ReadStream, WriteStream |
child_process |
✅ | spawnSync, execSync (edgebox_process API) |
stream module |
✅ | Stub module |
http/https modules |
✅ | Stub modules |
net module |
✅ | Stub module |
dns module |
✅ | Stub module |
| Capability | Status | Description |
|---|---|---|
filesystem |
✅ | fd_read, fd_write, path_open |
environ |
✅ | environ_get, environ_sizes_get |
args |
✅ | args_get, args_sizes_get |
clock |
✅ | clock_time_get |
random |
✅ | random_get |
sockets |
✅ | WAMR sock_open, sock_connect, sock_send, sock_recv |
tty |
✅ | fd_fdstat_get for isatty detection |
process |
✅ | edgebox_process API |
| File | Description |
|---|---|
edgebox-base.wasm |
QuickJS + polyfills as WASM (1.6MB) |
edgebox.aot |
AOT-compiled native module (2.6MB) |
edgebox-wamr |
WAMR runtime binary (465KB) |
# Build WAMR runtime (recommended)
zig build wamr -Doptimize=ReleaseFast
# Build WASM module
zig build wasm -Doptimize=ReleaseFast
# Build everything
zig build -Doptimize=ReleaseFast
# Run tests
zig build testApache License 2.0
Vendored dependencies:
- QuickJS-NG: MIT License (see
vendor/quickjs-ng/LICENSE) - WAMR: Apache 2.0 License (see
vendor/wamr/LICENSE)