# How the QuickJS-WASM Plugin Sandbox Isolates and Executes Server Entrypoints

> Discover how the QuickJS-WASM plugin sandbox isolates and executes server entrypoints using Bun Workers, WASM modules, strict limits, and a minimal API for enhanced security and performance.

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

---

**Instatic runs each plugin's server-side code inside a QuickJS-WASM virtual machine spawned within a Bun Worker, using a singleton WASM module, strict resource limits, and a minimal host-bridge API to ensure complete isolation from the host process.**

The QuickJS-WASM plugin sandbox in Instatic provides a secure execution environment for untrusted server-side plugin code. Instead of running plugins directly in the host Node.js/Bun process, the system creates isolated QuickJS contexts within dedicated workers. According to the Instatic source code, this architecture ensures that plugin entrypoints cannot access the filesystem, network, or environment variables without explicit permission grants.

## The Sandbox Architecture: Bun Worker to QuickJS Context

The isolation topology follows a three-layer hierarchy: **Bun host → Bun Worker → QuickJS-WASM context**. As implemented in [`server/plugins/quickjs/vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts), each active plugin receives its own Bun Worker, eliminating shared memory space with the host process.

The VM factory loads a **singleton WASM module** once per worker and reuses it for every context creation. This pattern, found at lines 65-71 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts), minimizes memory overhead while maintaining complete isolation between plugin instances. The WASM module serves as the security boundary; all plugin code executes within this VM without direct access to the underlying Bun runtime.

## Isolation Guarantees and Security Boundaries

The QuickJS runtime operates with **zero access** to Node.js or Bun APIs, the filesystem, environment variables, or network capabilities unless explicitly granted. All host interaction flows through a restricted bridge comprising three polyfilled functions registered synchronously:

- `__hostCall` – for asynchronous host operations
- `__hostSleep` – for timer-based delays  
- `__log` – for debug output

These functions are wired at lines 11-13 and 90-95 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts), ensuring the plugin cannot invoke arbitrary host capabilities.

Before any plugin code executes, the system applies **resource limits** defined in [`server/plugins/quickjs/limits.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/limits.ts). These constraints include maximum memory allocation, stack depth, and execution timeouts, preventing runaway scripts from consuming host resources. Limits are injected at lines 102-108 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts) immediately after context creation.

## How Plugin Entrypoints Are Loaded and Executed

Plugin bundles follow a specific format: a **self-executing IIFE** that attaches exported hooks to `globalThis.__plugin_exports`. The VM evaluates code in a strict sequence to maintain security invariants.

First, the **bootstrap** code from [`server/plugins/quickjs/bootstrap/index.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/bootstrap/index.ts) executes. This bootstrap provides the SDK façade and registers dispatcher functions. Only after the bootstrap completes does the VM evaluate the actual plugin bundle. This ordering at lines 84-92 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts) guarantees the plugin cannot overwrite the dispatcher handles established by the host.

After bootstrap execution, the VM stores persistent handles for dispatcher functions in a `Map<DispatcherName, QuickJSHandle>`. These dispatchers include:

- `__runLifecycle` – for plugin lifecycle hooks
- `__runRoute` – for HTTP route handlers  
- `__runHookListener` – for event listeners
- `__runLoopFetch` – for scheduled tasks

The host invokes these dispatchers via `ctx.callFunction`, wrapped by helper methods `callString` and `callVoid` in [`server/plugins/quickjs/eval.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/eval.ts) (lines 24-30).

## Host-to-Sandbox Communication Bridge

When plugin code calls `__hostCall`, the host creates a **VM-side Promise** using `ctx.newPromise()` and returns the handle immediately. The host then executes the actual asynchronous work (such as database queries or HTTP requests) outside the sandbox.

Once the operation completes, the host resolves the promise, and the VM's micro-task queue drains via `runtime.executePendingJobs()`. This cycle, detailed at lines 21-28 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts), propagates results back into the sandbox while keeping the untrusted code confined. The [`server/plugins/quickjs/marshal.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/marshal.ts) file provides utilities (`jsToHandle`, `handleToJs`) that convert JSON-serializable values across this boundary.

## Cleanup and Resource Management

Proper disposal prevents memory leaks and use-after-free errors. The `dispose()` method at lines 10-15 and 52-55 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts) clears all host-function handles, dispatcher handles, pending timers, and any deferred promises. This ensures that when a plugin is disabled or restarted, no references persist in the host process.

For additional safety, [`src/core/plugins/sandboxScan.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/sandboxScan.ts) scans bundled plugin code for forbidden literals (such as `node:` prefixes or `require(` calls) before the code ever reaches the QuickJS sandbox.

## Practical Implementation Example

The following example demonstrates creating a sandboxed VM and invoking an HTTP route handler:

```typescript
import { createPluginVm } from '@/server/plugins/quickjs/vm'
import type { PluginVm } from '@/server/plugins/quickjs/types'

// Load the compiled plugin bundle produced by `instatic-plugin build`
const pluginSource = await Bun.file('./uploads/plugins/my-plugin.bundle.js').text()

// Create the VM with environment identifiers for the host bridge
const vm: PluginVm = await createPluginVm({
  pluginSource,
  env: { pluginId: 'my-plugin', pluginVersion: '1.0.0' },
})

// Invoke the route dispatcher for "/api/hello"
const responseJson = await vm.runRoute({
  path: '/api/hello',
  method: 'GET',
  body: null,
})

console.log('Plugin responded with:', responseJson)

// Clean up the VM when finished
await vm.dispose()

```

Inside the plugin, code exports handlers via the global object:

```typescript
globalThis.__plugin_exports = {
  loopFetch: async ({ fetch }) => {
    const res = await fetch('https://api.example.com/data')
    const data = await res.json()
    return { body: JSON.stringify(data) }
  },
}

```

The host calls `vm.runLoopFetch()` to trigger the `__runLoopFetch` dispatcher, marshaling the `fetch` function across the bridge via `__hostCall`.

## Summary

- **QuickJS-WASM** provides the security boundary for plugin execution, with each plugin running in its own Bun Worker.
- **Singleton WASM module** pattern minimizes memory while maintaining isolation between contexts.
- **Bootstrap-first evaluation** ensures dispatchers are protected from plugin overwrite attempts.
- **Resource limits** (memory, stack, timeout) enforced in [`limits.ts`](https://github.com/CoreBunch/Instatic/blob/main/limits.ts) prevent resource exhaustion.
- **Host bridge** via `__hostCall` uses VM-side Promises to handle async operations without exposing host APIs.
- **Automatic cleanup** via `dispose()` prevents handle leaks and ensures safe plugin reloads.

## Frequently Asked Questions

### How does the QuickJS-WASM sandbox prevent plugins from accessing the filesystem?

The sandbox prevents filesystem access by running plugins inside a QuickJS virtual machine that has **no built-in IO capabilities**. Unlike Node.js or Bun, the QuickJS runtime does not expose `fs`, `path`, or `process` modules. The only communication channel is through the explicit `__hostCall` bridge, which the host controls. As implemented in [`server/plugins/quickjs/vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts), the VM context is created without any host environment bindings, ensuring that calls to `require('fs')` or `Bun.file()` fail immediately with reference errors.

### What happens when a plugin exceeds the memory or execution time limits?

When a plugin exceeds the configured resource limits defined in [`server/plugins/quickjs/limits.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/limits.ts), the QuickJS runtime throws an **out-of-memory** or **interrupted** exception that propagates to the host. The host catches these exceptions at the `ctx.callFunction` boundary in [`server/plugins/quickjs/eval.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/eval.ts), terminates the specific plugin worker, and logs the violation. The limits are applied at lines 102-108 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts) before any plugin code runs, ensuring that runaway scripts cannot impact the host process or other plugins.

### Can plugins make HTTP requests or access external APIs?

Plugins can only make HTTP requests **indirectly** through the `__hostCall` bridge. When plugin code invokes `fetch()`, the SDK façade routes this to `__hostCall`, which the host handles by creating a `ctx.newPromise()`. The host performs the actual HTTP request outside the sandbox, then resolves the VM-side promise with the response. This architecture, detailed at lines 21-28 in [`vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/vm.ts), means plugins cannot open raw sockets or bypass network policies, as all traffic flows through the host's audited HTTP client.

### How does the system handle memory leaks in long-running plugins?

The system prevents memory leaks through **explicit resource cleanup** and **worker isolation**. The `dispose()` method in [`server/plugins/quickjs/vm.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts) (lines 52-55) clears all QuickJS handles, pending timers, and deferred promises when a plugin is stopped. Because each plugin runs in a separate Bun Worker, terminating the worker via `vm.dispose()` releases all associated WASM memory back to the OS. Additionally, the singleton WASM module design ensures that recurring context creation does not accumulate module-level memory overhead.