AbsoluteJS

Sandboxed Mutations

Run a string-form mutation handler inside an @absolutejs/isolated-jsc Isolate. The handler can't reach the host's modules, closures, or globals — only the args / ctx clones and the actions bridge the engine passes in. Useful for multi-tenant PaaS, plugin systems, AI-generated logic, or any time you want a defensive CPU/memory cap per mutation.

#When to reach for it

Sandboxed mutations are an opt-in, not the default. Sync's regular handler runs natively in your Elysia process with full access to everything you've imported — fast, full power, and what you should reach for first. The sandbox earns its trade-offs (string source, heap isolation, CPU/memory cap) when:

  • The handler source is untrusted. Multi-tenant SaaS where customers write their own mutation logic; user-provided AI-generated handlers; an internal plugin system where third-party code lands at runtime.
  • You want defensive resource caps per mutation. A runaway loop or memory-bomb in one tenant's handler shouldn't take down the host process.
  • You're running the absolutejs hosted PaaS (or building your own). @absolutejs/isolated-jsc was built for this slot — per-tenant isolates with cheap cold spawn (FFI: ~6 ms/tenant) and hard resource bounds.

If none of those apply, use the plain handler and skip this page.

#Quick start

Swap handler for sandboxedHandler (a string) and add a sandbox config. The engine builds the runner lazily on first invocation:

TS
import { createSyncEngine, defineMutation } from '@absolutejs/sync/engine';

const engine = createSyncEngine();
engine.register(itemsCollection);

// String-form handler runs inside an @absolutejs/isolated-jsc Isolate.
// It can't reach the host's modules, closures, or globals — only the
// args / ctx clones and the actions bridge we pass in.
engine.registerMutation(defineMutation({
  name: 'addNote',
  sandboxedHandler: `async (args, ctx, actions) => {
    if (typeof args.title !== 'string' || args.title.length === 0) {
      throw new Error('title required');
    }
    await actions.insert('notes', {
      id: crypto.randomUUID(),
      title: args.title.trim(),
      tenant: ctx.tenant,
    });
  }`,
  sandbox: {
    backend: 'auto',       // FFI when libJSC reachable, Worker otherwise
    memoryLimit: 32,       // MB; isolate self-terminates on overage
    timeout: 5000,         // ms wall-clock per call
  },
}));

// Call exactly like a normal mutation — no API surface change.
await engine.runMutation('addNote', { title: 'first' }, { tenant: 'acme' });

runMutation stays exactly the same on the call site — sandboxed handlers are transparent to the caller. Errors thrown inside the sandbox propagate as JS Error objects with matching .name and .message; a timeout surfaces as TimeoutError; a memory overage as MemoryLimitError.

#Install

@absolutejs/isolated-jsc is an optional peer dependency. The sync package itself stays lean — the sandbox runner loads isolated-jsc via import() the first time a sandboxed mutation runs, so apps that don't use one pay nothing.

BASH
# isolated-jsc is an OPTIONAL peer dep — the sandbox runner loads it
# lazily on first sandboxedHandler call, so apps that don't use one
# pay nothing.
bun add @absolutejs/isolated-jsc

# Linux only: libjavascriptcoregtk is needed for the FFI backend.
# Without it, the runner falls back to the Worker backend automatically.
sudo apt install libjavascriptcoregtk-4.1-0   # Ubuntu/Debian
# or:
sudo apt install libjavascriptcoregtk-6.0-1   # newer distros

On macOS the FFI backend uses the system JavaScriptCore framework — no extra install. On Linux it needs libjavascriptcoregtk. If neither is reachable, the auto backend silently falls back to Worker.

#Backends: Worker vs FFI

isolated-jsc ships two implementations of the same Isolate / Context / Script / Callable API:

  • Worker — one Bun Worker per isolate. Each Worker spawns its own JSC VM with its own GC heap. Calls cross via postMessage with structured-cloned args. Available everywhere Bun runs (including Windows).
  • FFI — direct bun:ffi calls into libJavaScriptCore. No Worker, no IPC. Cold heap ~300 KB (vs Worker's ~46 MB). Interrupt-driven CPU timeouts that keep the isolate alive afterwards. macOS + Linux.

The default backend: 'auto' picks FFI when libJavaScriptCore is reachable and Worker otherwise. Both backends now run the same precompiled-function dispatch hot path (Context.compileCallable, now surfaced by createIsolatedRunner().call() in isolated-jsc 0.8.x), so the choice trades:

  • Cold spawn: FFI wins ~6×. Worker pays Bun's Worker bootstrap (load runtime, parse worker.ts, wire postMessage) per isolate. FFI just does JSContextGroupCreate + JSGlobalContextCreateInGroup.
  • Warm pure dispatch: comparable. Worker ~0.09 ms p50 (postMessage RTT); FFI ~0.33 ms (JSObjectCallAsFunction + arg packing).
  • Warm async-actions dispatch: Worker wins ~2× per await. Each FFI await pays a microtask yield + a read eval to drain the in-VM promise.
  • Web APIs: URL, TextEncoder, WebSocket — Worker only. They live in the Bun-Worker environment, not the bare JSC C API.

#Performance

Cold spawn
6.2×
faster cold spawn on FFI — 5.7 ms vs 35.4 ms per tenant in the 20-tenant lane
Warm dispatch
0.09ms
pure-handler warm dispatch p50 on the Worker backend
Throughput
7,364ops/sec
best pure-handler throughput (Worker backend)

All numbers are warm-dispatch p50 measured by benchmarks/sync/scripts/bench-sandbox.ts on WSL2, Bun 1.3.14, against @absolutejs/sync@1.7.5 + @absolutejs/isolated-jsc@0.8.x.

LaneWorkerFFIOps/sec (best)
Pure handler0.09 ms0.33 ms7,364 (worker)
actions.change0.42 ms0.92 ms1,934 (worker)
20-tenant cold spawn35.4 ms5.7 ms6.2× per-tenant (ffi wins)

Translation: the 'auto' default lands on FFI for Linux/macOS deployments — comparable warm dispatch, 6× faster cold spawn. Pin 'worker' only if you need Web APIs (or your handler hammers many awaited actions per call). Pin 'ffi' to bypass the auto-probe in known-good environments. Otherwise leave it on 'auto' — FFI wins or ties almost every workload.

#Configuration

Each mutation carries its own sandbox config — one isolate per mutation, sized for that mutation's workload:

TS
type SandboxConfig = {
  /** Heap memory cap (MB). Default 32. */
  memoryLimit?: number;
  /** Wall-clock cap per call (ms). Default 5000. */
  timeout?: number;
  /**
   * isolated-jsc backend. Defaults to 'auto' (FFI when libJSC is
   * reachable, Worker otherwise). Both backends now run the same
   * compileCallable-based hot path; pick by deployment context:
   *
   *   - 'worker': required if your handler needs Web APIs
   *     (URL, TextEncoder, WebSocket) — those live in the Bun-Worker
   *     environment, not the bare JSC C API.
   *   - 'ffi': bypass the auto-probe when you know libJSC is reachable
   *     (e.g. CI with a known image).
   *   - 'auto': default. ~6× faster cold spawn than Worker on Linux
   *     and macOS, comparable on warm dispatch.
   */
  backend?: 'auto' | 'ffi' | 'worker';
};

Sizing tips. The default memoryLimit: 32 is conservative; many real handlers want ~128 MB so a burst doesn't trip the watchdog. The default timeout: 5000 matches typical web-write budgets; tighten it for hot mutations, loosen it for long-running ones (jobs are usually a better fit there). The backend is the only knob with no "just leave it" answer — pick by what your handler needs.

#Architecture

The sandbox runner is built lazily per mutation: nothing is spawned until the first call. The first invocation walks four setup steps; every call after that takes only the final hot-path step:

1
first callCreate the runner pool
Creates a keyed pool of JSC VMs — a Bun Worker per isolate on the Worker backend, libJSC via bun:ffi on the FFI backend.
createIsolatedRunner({ policy, pool })
2
first callCreate a hardened context
Fresh global scope, hardened — no fetch, Bun, process, or any other host global.
createContext()
3
first callInstall the dispatch Reference once
Installs a SINGLE Reference that routes actions.* calls back to the host. It closes over a per-mutation callMap keyed by a per-call integer callId, so concurrent calls are safe — each has its own callId and its own actions slot.
setGlobal('__dispatch', new Reference(...))
4
first callPrecompile the wrapped source
Compiles ONCE per tenant/mutation key. The compiled function takes (callId, args, ctx) and builds the in-VM actions shim that dispatches through __dispatch with its callId.
runner.precompile(name, wrappedSource, { key })
5
every callDispatch through the hot path
The per-call hot path is one JSObjectCallAsFunction (FFI) or one postMessage (Worker). No per-call eval, no per-call setGlobal, no Reference alloc.
const callId = nextCallId++; callMap.set(callId, actions); try { return await runner.call(name, wrappedSource, [callId, args, ctx], { key, run: { timeout } }); } finally { callMap.delete(callId); }

The callId + callMap pattern means concurrent calls into the same mutation are safe by construction. Each call has its own callId → its own actions instance in the map. No shared-mutable slot, no serialization queue.

#Limits

The sandbox is a sandbox — it trades capability for isolation. Things to know up front:

TS
// Things sandboxedHandler doesn't (and won't) do:
//
//   1. Host modules / closures
//      The string source evaluates inside the isolate's VM. It can't
//      import('your-helpers') or reference variables from the surrounding
//      file. Use the actions bridge for engine ops, ctx for per-request
//      data, and args for request input. That's the entire surface.
//
//   2. Web APIs on the FFI backend
//      URL, TextEncoder, WebSocket, structured-clone, Web Crypto — those
//      live in the Bun-Worker environment, not the JSC C API. On FFI
//      they're undefined. Pin sandbox: { backend: 'worker' } if you need
//      them. Math, JSON, Promise, the typed-array suite, setTimeout,
//      console all work on both backends.
//
//   3. Synchronous fetch / direct DB access
//      The sandbox is for logic, not capability bypass. If your handler
//      needs to query something the engine doesn't already give it,
//      either pre-fetch on the host and pass via args, or expose a
//      capability via ctx (it's structured-cloned in, so put plain data
//      not functions there).
//
//   4. Long-running work
//      Default timeout is 5000 ms wall-clock per call. On Worker the
//      isolate is terminated on timeout (next call respawns); on FFI a
//      TerminationException is thrown into the script and the isolate
//      keeps running. Either way the caller gets a TimeoutError.
//
//   5. Memory limit isn't a hard cap
//      Polled every 50 ms via bun:jsc.memoryUsage on Worker; checked by
//      JSC's watchdog on FFI. A burst above the limit between checks
//      can briefly exceed it before the isolate self-terminates. Set
//      generously — the default 32 MB is too small for many real
//      handlers; 128 is a more realistic starting point.

#Optimization arc

The sandbox shipped in sync 1.4 with a straightforward per-call eval + setGlobal model. Profiling against benchmarks/sync/scripts/bench-sandbox.ts drove five rounds of architectural cleanup over a single day, interleaving sync releases with isolated-jsc ("jsc") releases:

1.7.2
Router Reference + reused context
FFI pure warm dispatch: 4.69 → 2.47 ms.
jsc 0.5
isolated-jsc: cleanup eval folded into the read eval
Unwrap path drops from 3 evals per call to 2.
1.7.3
Sync IIFE wrap
Sync handlers skip the Promise unwrap entirely. FFI pure: 2.47 → 0.29 ms.
jsc 0.5.1
isolated-jsc: microtask-first pump fast path
FFI actions lane: 6.62 → 0.71 ms.
jsc 0.6
isolated-jsc: Context.compileCallable primitive
Compile once, call many times — per call: no eval, no setGlobal.
1.7.5
callId routing
Dispatch Reference installed once per isolate. FFI pure: 0.96 → 0.33 ms.

Total: 14× faster on the pure FFI lane, 7× on actions FFI, 10× on pure Worker. The bench is the spec — 1.7.4 caught its own regression in the post-publish re-run, fixed same-day as 1.7.5. We're at the floor for the current architecture: further wins would need Bun/JSC API-level work (shared event loops for cheaper async host-fn pumps). Until then, both backends sit at sub-millisecond p50 for the warm cases that matter.

The full arc — profile snapshots, regression catches, and post-fix re-benches — lives in the bench repo's sync/RESULTS.md. Worth reading if you're sizing whether the sandbox fits your workload.