# How Instatic Uses QuickJS-WASM Sandbox for Secure Plugin Isolation

> Discover how Instatic uses a QuickJS-WASM sandbox within Bun Workers to securely isolate plugins. Learn about memory limits, CPU controls, and the SDK API for robust server-side plugin architecture.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-31

---

**Instatic isolates untrusted server-side plugins using a QuickJS-WASM sandbox running inside a Bun Worker, enforcing strict memory and CPU limits while exposing a permission-checked SDK API through host functions.**

The CoreBunch/Instatic repository implements a deterministic, resource-bounded execution environment where third-party JavaScript plugins run without direct access to the host filesystem or network. By combining the QuickJS interpreter compiled to WebAssembly with Bun's multi-threading capabilities, Instatic creates a defense-in-depth architecture that prevents plugins from crashing the main server process or exhausting system resources.

## QuickJS-WASM Sandbox Architecture

Instatic's security model relies on nested isolation layers. At the outermost level, each plugin executes inside a **Bun Worker** that provides crash isolation and CPU yielding. Within that worker, the **QuickJS-WASM interpreter** (from the `quickjs-emscripten` package) creates a separate JavaScript context per plugin.

### Isolation Layers

The topology minimizes attack surface by ensuring the host process never shares objects directly with the VM:

```

Bun host (main process)
 └─ Bun.Worker (crash-isolation, CPU-yield)
      └─ QuickJS-WASM context (security sandbox)
           ├─ Bootstrap (SDK façade + handler registries)
           └─ Plugin source (IIFE → globalThis.__plugin_exports)

```

Communication occurs exclusively through registered **host functions** (`__hostCall`, `__hostSleep`, `__log`). These functions act as the only bridge between the sandboxed code and the host environment.

### Resource Limits

Before executing any user code, Instatic enforces hard caps on memory and stack usage. In [`server/plugins/quickjs/vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts), the `createPluginVm` function initializes the VM with `DEFAULT_MEMORY_LIMIT_BYTES` and `DEFAULT_STACK_SIZE_BYTES`, preventing plugins from allocating unbounded memory or causing stack overflow errors.

## Bootstrap Process and Dispatcher Registration

The VM initialization follows a two-stage evaluation process that separates the runtime infrastructure from untrusted plugin code.

### Bootstrap Bundle Evaluation

First, the VM evaluates `BOOTSTRAP_SOURCE` defined in [`server/plugins/quickjs/bootstrap/index.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/bootstrap/index.ts). This bootstrap bundle installs:

- **Dispatcher functions** (`__runLifecycle`, `__runRoute`, etc.) that the host will later invoke
- Polyfills for standard timers (`setTimeout`, `clearTimeout`) that forward to the host

The bootstrap creates a controlled environment where plugin code can register handlers without accessing dangerous globals.

### Dispatcher Function Registration

After bootstrap completion, the actual plugin bundle executes as an IIFE that populates `globalThis.__plugin_exports`. The host retrieves persistent handles for each dispatcher listed in `DISPATCHER_NAMES` (defined in [`server/plugins/quickjs/vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts)) and stores them in `dispatcherHandles`.

When the host needs to trigger plugin logic, it uses the generic helpers `callString`, `callVoid`, and others from [`server/plugins/quickjs/eval.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/eval.ts) to invoke these handles safely across the WebAssembly boundary.

## Secure Host-Plugin Communication

All interactions between the sandboxed plugin and the Instatic server flow through a strictly controlled API surface.

### Host Functions and SDK Facade

The bootstrap exposes `env.hostCall` to plugin code, which maps to the host-side `__hostCall` function. This is the **only** mechanism for plugins to request server-side operations. The host implementation in [`server/host/apiDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/host/apiDispatch.ts) validates permissions through `dispatchApiCall` before executing any `api.plugin.*` or `api.cms.*` operations.

Plugins cannot access the filesystem, network, or other system resources directly. They must serialise requests through this permission-checked channel, with the host returning only JSON-serializable results.

### Micro-Task Draining

QuickJS maintains its own internal promise queue and timer callbacks. To prevent the VM from stalling the worker thread, Instatic explicitly drains pending jobs after every host-side promise resolution or timer event using `runtime.executePendingJobs()` (wrapped in `pumpPendingJobs` within [`server/plugins/quickjs/vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts)). This guarantees that asynchronous plugin code yields control back to the host regularly.

## Graceful Shutdown and Resource Cleanup

When a plugin is unloaded or reloaded, Instatic performs deterministic cleanup to prevent memory leaks and use-after-free vulnerabilities.

The `dispose()` method in [`server/plugins/quickjs/vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts) executes the following sequence:

1. Marks the VM as dead to reject new incoming calls
2. Clears all pending timers
3. Disposes any still-pending `Deferred` promises
4. Releases all host-function handles
5. Destroys the QuickJS context

This ordered disposal ensures that dangling references cannot crash the worker or leak sensitive host functions.

## Creating a Sandboxed Plugin VM

The public API surface abstracts the complexity of the QuickJS-WASM implementation. The following example demonstrates spinning up a sandboxed VM and executing plugin lifecycle hooks:

```typescript
import { createPluginVm, type PluginVmEnv } from '@/server/plugins/quickjs/vm';

// 1️⃣ Build the host environment that the VM sees
const env: PluginVmEnv = {
  pluginId: 'my-awesome-plugin',
  manifestVersion: '1.0.3',
  grantedPermissions: ['cms.read', 'cms.write'],
  assetBasePath: '/uploads/plugins/my-awesome-plugin/1.0.3',
  settings: { theme: 'light' },

  // Host-side implementation of the SDK API
  async hostCall(target, args) {
    // Validates permission and dispatches to real handler
    return await dispatchApiCall(target, args);
  },

  // Simple fire-and-forget logger
  log(args) {
    console.log('[plugin:my-awesome-plugin]', ...args);
  },
};

// 2️⃣ Load the compiled plugin bundle (generated by the SDK)
const pluginSource = await Bun.file('./plugins/my-awesome-plugin/dist/plugin.js').text();

// 3️⃣ Spin up a sandboxed VM with resource limits
const vm = await createPluginVm({ pluginSource, env });

// 4️⃣ Run lifecycle hooks (install, activate, etc.)
await vm.runLifecycle('install');

// 5️⃣ Execute a custom route defined by the plugin
const result = await vm.runRoute('myRoute', {
  request: { url: '/api/foo', method: 'GET', headers: {}, body: '' },
  body: {},
  user: null,
});
console.log('Route result →', result);

// 6️⃣ Clean up when the plugin is removed or reloaded
vm.dispose();   // Releases all QuickJS resources safely

```

The `PluginVm` type returned by `createPluginVm` exposes strongly-typed async methods (`runLifecycle`, `runRoute`, `runHookListener`, `runSchedule`) that internally forward to the QuickJS dispatcher functions while handling marshalling through [`server/plugins/quickjs/marshal.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/marshal.ts).

## Summary

- **Nested Isolation**: Instatic uses Bun Workers containing independent QuickJS-WASM contexts to prevent plugins from crashing the main process or accessing host memory directly.
- **Resource Enforcement**: Hard memory and stack limits (`DEFAULT_MEMORY_LIMIT_BYTES`, `DEFAULT_STACK_SIZE_BYTES`) are applied per-plugin in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts) before any untrusted code executes.
- **Controlled Communication**: Plugins interact with the host solely through `env.hostCall`, which validates permissions in [`apiDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/apiDispatch.ts) before processing `api.plugin.*` or `api.cms.*` requests.
- **Deterministic Cleanup**: The `dispose()` method in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts) releases all handles, clears timers, and destroys the QuickJS context in a specific order to prevent use-after-free errors.

## Frequently Asked Questions

### What is QuickJS-WASM and why does Instatic use it?

QuickJS-WASM is the QuickJS JavaScript interpreter compiled to WebAssembly using Emscripten. Instatic uses it because it provides a lightweight, embeddable JS engine that runs inside a sandboxed WebAssembly memory space, offering stronger isolation than running code directly in the host Bun process while maintaining near-native performance for plugin logic.

### How does Instatic prevent plugins from blocking the event loop?

Instatic implements explicit micro-task draining using `runtime.executePendingJobs()` (wrapped in `pumpPendingJobs`) after every host function call returns. This forces the QuickJS VM to process all pending promise continuations and timers immediately, preventing infinite loops or long-running synchronous operations from stalling the Bun Worker thread.

### Can plugins access the filesystem or network directly?

No. Plugins have zero direct access to system resources. All file system, database, and network operations must route through `env.hostCall`, which validates the plugin's granted permissions (defined in `PluginVmEnv.grantedPermissions`) before dispatching to the actual implementation in [`server/host/apiDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/host/apiDispatch.ts). The QuickJS-WASM sandbox has no bindings to system APIs.

### What happens if a plugin exceeds its memory limit?

If a plugin attempts to allocate beyond `DEFAULT_MEMORY_LIMIT_BYTES` or exceeds `DEFAULT_STACK_SIZE_BYTES`, the QuickJS runtime throws an out-of-memory error that propagates to the host. The host can then catch this error and trigger `vm.dispose()` to terminate the plugin without affecting other plugins or the main Bun process, ensuring strict resource containment per-plugin.