How Instatic Executes Plugin Entrypoints in Sandboxed QuickJS Contexts
Instatic runs every plugin entrypoint inside an isolated QuickJS-WASM sandbox where a Bun Worker instantiates a fresh VM, evaluates a bootstrap runtime that exposes dispatcher functions, and routes all host interactions through a typed protocol, completely eliminating ambient access to Node.js or Bun APIs.
The CoreBunch/Instatic repository implements a strict security model where third-party plugins operate in tightly-controlled environments. When you execute plugin entrypoints in sandboxed contexts, Instatic orchestrates a deterministic pipeline that loads the plugin bundle into a QuickJS VM, initializes a bootstrap runtime to register capability handlers, and dispatches lifecycle hooks through JSON-serialized messages mediated by the __hostCall function.
The Worker-to-VM Pipeline
Instatic isolates plugin execution by delegating all VM operations to a dedicated Bun Worker thread, ensuring that even catastrophic plugin failures cannot crash the main server process.
Spawning the Bun Worker
The entry point resides in server/plugins/pluginWorker.ts, which listens for load-plugin requests from the main process. When the host needs to activate a plugin, it posts a message containing the pluginId, entryFileUrl, manifest, and settings. The worker maintains a vmsByPluginId Map to track active plugin contexts.
// Main process initiates the load
await host.postMessage({
kind: 'load-plugin',
correlationId: 'abc123',
pluginId: 'my-plugin',
entryFileUrl: '/plugins/my-plugin/bundle.js',
manifest: { version: '1.0.0', grantedPermissions: [] },
settings: {}
});
Wrapping and Loading the Bundle
Upon receiving a load request, the worker reads the plugin's bundled entrypoint from disk using readFile and transforms the ESM module into a global-compatible format via wrapEsmAsGlobal. This wrapper exposes the plugin's exports as globalThis.__plugin_exports, allowing the QuickJS runtime to access the module without native ESM support inside the VM.
// From server/plugins/pluginWorker.ts
async function handleLoadPlugin(msg: LoadPluginRequest) {
const rawSource = await readFile(msg.entryFileUrl, 'utf-8');
const pluginSource = wrapEsmAsGlobal(rawSource, '__plugin_exports');
const vm = await createPluginVm({
pluginSource,
env: {
pluginId: msg.pluginId,
manifestVersion: msg.manifest.version,
grantedPermissions: msg.manifest.grantedPermissions ?? [],
assetBasePath: `/uploads/plugins/${msg.pluginId}/${msg.manifest.version}`,
settings: msg.settings,
hostCall: (t, a) => callHostApi(msg.pluginId, t as any, a),
log: (a) => send({ kind: 'log', pluginId: msg.pluginId, args: a })
}
});
vmsByPluginId.set(msg.pluginId, vm);
send({ kind: 'load-plugin-result', correlationId: msg.correlationId, ok: true, hooks: vm.exportedHooks });
}
Bootstrap Runtime Initialization
After the worker loads the plugin source, Instatic evaluates a bootstrap script inside the VM that constructs the restricted API surface and registers dispatcher functions.
Creating the QuickJS Context
The createPluginVm function (from quickjs/vm.ts) spins up a fresh QuickJS context with enforced memory and stack limits. This VM has zero ambient access to Bun or Node APIs; all interaction must flow through the hostCall callback provided during initialization.
Installing the Bootstrap Runtime
The worker evaluates server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts, which executes the logic defined in server/plugins/quickjs/bootstrap/src/pluginRuntime.ts. This bootstrap:
- Registers
globalThis.__plugin_handlersto store callbacks for routes, hooks, and filters - Defines dispatcher functions including
__runLifecycle,__runRoute,__runHookListener, and__runHookFilter - Constructs the plugin API via
__buildApi(), which exposesapi.cms.*methods that register handlers in the global registry
Entrypoint Resolution and Execution
Once the bootstrap completes, Instatic resolves the plugin's exported lifecycle object and executes registered hooks through the dispatcher layer.
Resolving Plugin Exports
The __resolvePluginModule() function in pluginRuntime.ts normalizes the module structure, supporting both named exports (a lifecycle object with activate, deactivate methods) and default exports. This allows the host to locate specific hooks like activate or install regardless of the plugin's export style.
// From pluginRuntime.ts
globalThis.__resolvePluginModule = function resolvePluginModule() {
const exports = globalThis.__plugin_exports;
// Supports both: export const lifecycle = { activate: ... }
// and: export default { activate: ... }
return exports?.lifecycle || exports?.default || exports;
};
Dispatching Lifecycle Hooks
The host triggers plugin code execution by posting run-lifecycle messages to the worker, which invokes the corresponding dispatcher inside the VM. The __runLifecycle function serializes arguments to JSON, calls the plugin's hook with the constructed API, and returns the result.
// Host requests activation
await host.postMessage({
kind: 'run-lifecycle',
correlationId: 'def456',
pluginId: 'my-plugin',
hook: 'activate'
});
// Inside the VM (pluginRuntime.ts)
globalThis.__runLifecycle = async function runLifecycle(hook) {
const mod = __resolvePluginModule();
const fn = mod && mod[hook];
if (typeof fn !== 'function') return;
await fn(globalThis.__buildApi());
};
Handling Route Requests
For HTTP routes registered via api.cms.routes.register, the host dispatches through __runRoute. This function deserializes the request context, executes the registered handler, and encodes the response for transmission back to the main process.
globalThis.__runRoute = async function runRoute(routeKey, ctxJson) {
const handler = globalThis.__plugin_handlers.routes[routeKey];
if (!handler) throw new Error('Route handler not registered: ' + routeKey);
const ctx = fromJson(ctxJson);
const result = await handler({ req, body, user: ctx.user });
if (result && typeof result === 'object' && result.__response === true) {
const encoded = __encodeResponseBody(result.body);
return toJson({
__response: true,
status: result.status ?? 200,
headers: result.headers ?? {},
body: encoded.body,
bodyEncoding: encoded.bodyEncoding
});
}
return toJson(result, { ok: true });
};
Security Boundaries and Host Isolation
The sandbox design eliminates Remote Code Execution (RCE) risks by enforcing capability restrictions at multiple layers.
Per-Plugin Resource Limits
Each QuickJS VM created by createPluginVm operates under configurable memory and stack constraints. If a plugin exceeds these limits, the VM termination is handled by the worker without affecting other plugins or the host process.
The Host Call Protocol
All privileged operations (file system access, database queries, HTTP requests) route through __hostCall, which implements the typed protocol defined in server/plugins/protocol/messages.ts. The worker validates permissions against the grantedPermissions array from the plugin manifest before executing any host-side API calls, ensuring that capabilities are explicitly granted rather than implicitly available.
Summary
- Worker Isolation:
server/plugins/pluginWorker.tsruns each plugin in a separate Bun Worker thread, preventing crashes from affecting the main server. - VM Creation:
createPluginVminstantiates a fresh QuickJS context with memory limits and no access to Node.js or Bun globals. - Bootstrap Initialization: The runtime evaluates
server/plugins/quickjs/bootstrap/generated/pluginBootstrap.tsto install dispatcher functions and theapi.cms.*handler registry. - Entrypoint Resolution:
__resolvePluginModule()normalizes exports to locate lifecycle hooks likeactivateanddeactivate. - Message-Based Dispatch: Host invokes plugin code via
__runLifecycle,__runRoute, and other dispatchers that serialize arguments and responses as JSON. - Capability Security: The
__hostCallmechanism andserver/plugins/protocol/messages.tsenforce per-plugin permission grants, blocking unauthorized system access.
Frequently Asked Questions
How does Instatic prevent plugins from accessing the file system directly?
Plugins execute inside a QuickJS VM that lacks any built-in file system APIs. The bootstrap runtime does not expose fs or Bun.read to the global scope. If a plugin requires file operations, it must invoke api.cms.* methods that translate to __hostCall messages, which the worker validates against the plugin's grantedPermissions manifest before executing the request on the host.
What happens if a plugin exceeds its memory or stack limits?
The createPluginVm function applies per-context memory and stack constraints to the QuickJS runtime. If a plugin exceeds these limits during execution (such as during an infinite loop or memory leak), the QuickJS engine triggers an out-of-memory error that halts the specific VM. The worker captures this exception, terminates the plugin context, and reports the failure to the host without impacting other sandboxed plugins or the main Bun process.
Can plugins import external npm modules within their sandboxed contexts?
No. The sandboxed QuickJS context does not have a module resolution system or access to node_modules. Plugins must be bundled as self-contained JavaScript files (processed via wrapEsmAsGlobal) that include all dependencies at build time. The VM evaluates the bundled string directly, and any dynamic import attempts will fail because the runtime lacks a loader for external packages.
How are HTTP responses returned from sandboxed route handlers?
When a plugin registers a route handler via api.cms.routes.register, the callback is stored in globalThis.__plugin_handlers.routes. The host later invokes __runRoute(routeKey, ctxJson) inside the VM, which executes the handler and serializes the result. If the handler returns a response object marked with __response: true, the bootstrap encodes the body and headers into JSON via toJson(), transmits this back through the worker's postMessage channel, and the host reconstructs the data into a real HTTP Response object.
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 →