How Instatic's Plugin System Uses QuickJS-WASM Sandboxing
Instatic executes every third-party plugin inside an isolated QuickJS-WASM context running within a Bun Worker, enforcing strict memory and stack limits while mediating all host interactions through permission-checked bridge functions.
Instatic, an open-source CMS from the CoreBunch organization, implements a deterministic, resource-bounded sandbox for server-side plugin execution. The architecture guarantees that arbitrary JavaScript code cannot access the host filesystem, network, or memory outside its allocated bounds. This is achieved through a multi-layered isolation strategy combining the quickjs-emscripten WebAssembly interpreter, Bun's Worker threads, and a tightly controlled API façade.
Sandboxing Architecture Overview
The execution topology creates three distinct isolation boundaries:
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)
Context isolation ensures each plugin receives its own QuickJS virtual machine. The host never shares JavaScript objects with the VM; instead, communication occurs exclusively through host functions registered within the VM (__hostCall, __hostSleep, __log). This prevents prototype pollution or memory leakage between the host and guest environments.
Resource Constraints and Memory Limits
Before any user code executes, the runtime enforces hard caps on consumption. In server/plugins/quickjs/vm.ts, the createPluginVm factory applies two critical thresholds:
- Memory limit:
DEFAULT_MEMORY_LIMIT_BYTEScaps the heap size - Stack limit:
DEFAULT_STACK_SIZE_BYTESrestricts call depth
These limits are defined in server/plugins/quickjs/limits.ts and apply per-plugin. If a script exceeds either boundary, QuickJS raises a catchable exception that the host converts into a sandbox violation error, terminating the specific plugin without affecting the worker thread or other plugins.
Bootstrap Process and Dispatcher Registration
Every QuickJS context undergoes a controlled initialization sequence. First, the VM evaluates the BOOTSTRAP_SOURCE string exported from server/plugins/quickjs/bootstrap/index.ts. This bootstrap script:
- Installs polyfills for timers (
setTimeout,clearTimeout) - Registers dispatcher functions (
__runLifecycle,__runRoute, etc.) that act as the plugin's public API surface - Prepares
globalThis.__plugin_exportsto receive the plugin's IIFE bundle
After the bootstrap completes, the actual plugin code—compiled as an IIFE that writes to globalThis.__plugin_exports—is evaluated. The host stores persistent handles to these dispatchers in a dispatcherHandles map, keyed by the DISPATCHER_NAMES constant defined in vm.ts.
Host-to-Plugin Invocation
When the CMS needs to execute plugin logic, it invokes strongly-typed methods on the PluginVm handle returned by createPluginVm. These methods—runLifecycle, runRoute, runHookListener, and runSchedule—forward to generic helpers in server/plugins/quickjs/eval.ts (callString, callVoid, evalJson) that:
- Look up the dispatcher handle by name
- Marshal arguments using
server/plugins/quickjs/marshal.ts - Enforce execution deadlines
- Return deserialized JSON results
This architecture keeps the host in control of when and how plugin code runs, preventing guest scripts from blocking the event loop.
Plugin-to-Host Communication and Permissions
Plugins cannot directly import Node.js modules or access Bun APIs. Instead, they interact with the CMS through env.hostCall, which implements the plugin SDK API (api.plugin.*, api.cms.*, etc.).
In server/host/apiDispatch.ts, the dispatchApiCall function validates the plugin's granted permissions against the requested operation before executing it. For example, a plugin with only cms.read permission that attempts to write will be rejected at the host boundary. All data crossing this boundary must be JSON-serializable, ensuring no object references leak between contexts.
Micro-Task Management and Event Loop
QuickJS queues promise continuations and timer callbacks inside its own internal micro-task queue. After every host-side promise resolves, the runtime explicitly drains pending jobs via runtime.executePendingJobs() (wrapped in pumpPendingJobs inside vm.ts). This guarantees that asynchronous plugin code completes promptly and never stalls the Bun Worker thread.
Graceful Shutdown and Resource Cleanup
When a plugin is unloaded or reloaded, the dispose() method in vm.ts executes a deterministic teardown sequence:
- Marks the VM as dead to prevent new calls
- Clears all pending timers
- Disposes any pending
Deferredpromises - Releases all host-function handles
- Destroys the QuickJS context
This strict disposal order prevents use-after-free crashes and ensures the Bun Worker can be safely terminated or reused.
Practical Example
The following demonstrates creating, executing, and safely destroying a sandboxed plugin VM:
import { createPluginVm, type PluginVmEnv } '@/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) {
// Validate permission and dispatch to the 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
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: '', bodyEncoding: 'utf8' },
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
Summary
Instatic's QuickJS-WASM sandboxing strategy delivers secure, server-side plugin execution through several key mechanisms:
- Process isolation: Each plugin runs in a dedicated Bun Worker containing a separate QuickJS context
- Resource enforcement: Hard memory and stack limits defined in
limits.tsprevent resource exhaustion - Controlled API surface: All host interactions flow through
hostCallwith permission validation inapiDispatch.ts - Deterministic lifecycle: Bootstrap dispatchers in
bootstrap/index.tsand explicit cleanup viadispose()eliminate resource leaks - Async safety: Micro-task draining via
pumpPendingJobskeeps the event loop responsive
Frequently Asked Questions
How does Instatic prevent plugins from accessing the host filesystem?
Instatic never shares host objects with the QuickJS VM. The WASM interpreter has no access to Node.js or Bun built-in modules. File system operations must traverse the hostCall bridge to server/host/apiDispatch.ts, where the host validates permissions before executing any I/O on the plugin's behalf.
What happens when a plugin exceeds its memory limit?
If a plugin's heap allocation exceeds DEFAULT_MEMORY_LIMIT_BYTES or its stack exceeds DEFAULT_STACK_SIZE_BYTES, QuickJS throws a catchable exception. The host catches this in server/plugins/quickjs/vm.ts and terminates the specific plugin, returning an error to the caller without crashing the Bun Worker or affecting other plugins.
How does the plugin SDK communicate with the host?
The compiled plugin bundle runs inside an IIFE that populates globalThis.__plugin_exports. During bootstrap, the VM registers dispatcher functions that the host later invokes via callString and callVoid helpers in server/plugins/quickjs/eval.ts. When the plugin needs host services, it calls env.hostCall, which serializes the request, exits the VM, and lets the host validate permissions before executing the operation.
Is the QuickJS sandbox reusable after a plugin stops?
No. Once vm.dispose() is called, the QuickJS context is permanently destroyed and its memory freed. The createPluginVm factory in server/plugins/quickjs/vm.ts creates fresh contexts for each plugin load, ensuring no state persists between executions and preventing cross-plugin contamination.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →