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-jscwas 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:
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.
# 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 distrosOn 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
postMessagewith structured-cloned args. Available everywhere Bun runs (including Windows). - FFI — direct
bun:fficalls intolibJavaScriptCore. 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 FFIawaitpays 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
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.
| Lane | Worker | FFI | Ops/sec (best) |
|---|---|---|---|
| Pure handler | 0.09 ms | 0.33 ms | 7,364 (worker) |
| actions.change | 0.42 ms | 0.92 ms | 1,934 (worker) |
| 20-tenant cold spawn | 35.4 ms | 5.7 ms | 6.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:
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:
createIsolatedRunner({ policy, pool })createContext()setGlobal('__dispatch', new Reference(...))runner.precompile(name, wrappedSource, { key })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:
// 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:
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.