Instatic Plugin System Architecture: Complete Technical Guide to Manifests, SDK APIs, and Sandboxed Execution
Instatic's plugin system uses a manifest-driven package format with sandboxed server-side execution in QuickJS-WASM and unsandboxed admin/editor runtimes, mediated by typed SDK APIs with granular permission enforcement.
The Instatic plugin system architecture separates concerns across four distinct layers: package structure, sandboxed server runtime, unsandboxed admin runtime, and typed SDK APIs. This design enables third-party code to extend the CMS safely while preventing unauthorized access to system resources. The source code in CoreBunch/Instatic implements these boundaries through strict manifest validation, WebAssembly-based isolation, and runtime permission checks.
Package Structure and Manifest Contract
Every Instatic plugin is a zip archive generated from instatic-plugin.config.ts. The archive must contain a plugin.json manifest—parsed by parsePluginManifest in src/core/plugins/manifest.ts—that serves as the source of truth for installation, permissions, and bundled entrypoints.
Entrypoint Matrix
Plugins ship multiple entrypoints targeting different execution contexts:
| Entrypoint | Runtime Environment | Bundle Format |
|---|---|---|
server/index.js |
Bun Worker → QuickJS VM | IIFE assigning globalThis.__plugin_exports |
modules/index.js |
Canvas module pack (server QuickJS or browser) | ESM default export |
editor/index.js |
Unsandboxed admin window | ESM |
admin/*.js |
Unsandboxed admin app page | ESM |
frontend/*.js |
Published-page assets | ESM |
The manifest schema in src/core/plugins/manifest.ts validates all fields at build and install time, ensuring structural integrity before any code executes.
Sandboxed Server Runtime (QuickJS-WASM)
The server-side sandbox provides the strongest isolation guarantees. Each installed plugin receives its own Bun Worker defined in server/plugins/pluginWorker.ts, which instantiates a QuickJS VM from server/plugins/quickjs/vm.ts.
VM Boundary and Bootstrap
The VM executes with zero access to Node.js or Bun APIs, no filesystem privileges, and no environment variable access. Network capabilities require explicit network.outbound permission paired with an allowlist (networkAllowedHosts).
The runtime bootstrap is authored in TypeScript under server/plugins/quickjs/bootstrap/src/—notably pluginRuntime.ts—and compiled into a single string artifact at server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts. This single-source design ensures the VM boundary remains consistent while enabling rapid iteration via bun run bootstrap:sync.
Resource Limits
Strict budgets prevent resource exhaustion, configured in server/plugins/quickjs/limits.ts:
- Heap: 64 MiB
- Stack: 1 MiB
- Eval timeout: 5 seconds
- Scheduled job timeout: 5 minutes
- RPC timeout: 30 seconds
The host enforces these limits through requestFromWorker in the worker layer.
Unsandboxed Admin and Editor Runtimes
Entrypoints marked editor or admin-app pages (adminPages[].content.kind === "app") execute unsandboxed in the main admin window. These contexts have full browser privileges—React components, DOM manipulation, and unrestricted imports—gated by the editor.code permission.
Unlike the server sandbox, these modules import the browser-side SDK directly from src/core/plugin-sdk/ and operate without VM overhead.
Typed SDK and API Surface
All plugin code receives a unified api object exposing namespaced sub-APIs. Every namespace routes through TypeBox schemas for compile-time safety and runtime validation.
| Namespace | Capability | Required Permission |
|---|---|---|
api.plugin.* |
Metadata, logging, asset URLs | — |
api.cms.routes.* |
HTTP route registration (GET, POST, …) |
cms.routes (cms.routes.public for anonymous) |
api.cms.storage.* |
Per-plugin collection CRUD | cms.storage |
api.cms.hooks.* |
Event registration and emission | cms.hooks |
api.cms.loops.* |
Dynamic content loop sources | loops.register |
api.cms.content.* |
CMS table CRUD and tree mutation | cms.content.* (read/write/publish/delete) |
api.cms.schedule.* |
Periodic job registration | cms.schedule |
api.editor.* |
Commands, toolbar buttons, palettes, panels, canvas overlays | Various editor.* permissions |
api.dashboard.widgets.* |
Dashboard widget registration | dashboard.widgets.register |
api.cms.settings.* |
Plugin settings with encrypted secrets | cms.settings |
Example: Server-Side Route Registration
// server/index.js (sandboxed)
export async function activate(api) {
// Only available if manifest granted `cms.routes`
api.cms.routes.get('/status', 'plugins.read', async ({ req }) => {
return { ok: true, plugin: api.plugin.id };
});
}
The route dispatcher in server/plugins/host/routeIo.ts handles request routing and permission enforcement.
Example: Scheduled Job Registration
export function activate(api) {
api.cms.schedule.daily('cleanup', '03:00', async () => {
const items = await api.cms.storage.collection('temp').list();
await Promise.all(
items.records.map(r => api.cms.storage.collection('temp').delete(r.id))
);
});
}
Implementation resides in server/plugins/scheduler.ts.
Example: Editor Command Registration
// editor/index.js (unsandboxed)
export function activate(api) {
api.editor.commands.register({
id: 'acme.workflow.approve',
label: 'Approve current item',
iconName: 'check',
run: async () => {
await api.cms.storage.collection('approvals').create({
title: 'New',
approved: true
});
},
});
}
Editor SDK types are defined in src/core/plugin-sdk/types/editorApi.ts.
Plugin Lifecycle and State Management
Plugins export lifecycle hooks invoked by the host runtime in server/plugins/runtime.ts:
install— Initial setupactivate— Runtime initializationdeactivate— Graceful shutdownuninstall— Cleanup and removalmigrate— Schema or data migrations
Errors in any hook trigger automatic rollback and transition the plugin to the error state, with details recorded in lastError. The full lifecycle flow is documented in the plugin system documentation and enforced by the runtime dispatcher.
Event Broadcasting and Real-Time Updates
Lifecycle transitions, crashes, and recoveries propagate via Server-Sent Events. The broadcaster in server/plugins/eventBroadcaster.ts emits PluginEvent objects defined in src/core/plugins/events.ts.
The admin UI consumes this stream through src/admin/pages/plugins/utils/pluginEventStream.ts, with React components subscribing via usePluginEventBridge for live state synchronization.
Example: Custom Event Emission
export async function activate(api) {
api.cms.hooks.emit('my.custom.event', { foo: 'bar' });
}
Permission Enforcement Architecture
Permissions declared in plugin.json undergo validation at three distinct layers:
- Build-time lint —
instatic-plugin lintscans bundles for forbidden literals (e.g.,import 'node:fs',Bun.spawn) - Install-time validation —
assertSandboxSafere-scans the uploaded zip before activation - Runtime verification —
apimethods checkgrantedPermissionsbefore executing privileged operations
Forbidden APIs are cataloged in the documentation and continuously validated by plugin-sandbox-invariants.test.ts.
Module Packs and Frontend Assets
Canvas module packs load into a dedicated QuickJS VM via server/plugins/modulePackVm.ts, enabling server-side rendering of dynamic content.
Frontend assets declared in plugin.json.frontend.assets are injected into every published page by server/publish/frontendInjections.ts. Both inclusion and execution require the frontend.assets permission.
Secrets Management and Encryption
Secret settings (type: "password" with secret: true) are encrypted at rest in the plugin_secrets table, managed by server/repositories/pluginSecrets.ts. These values never leave the server process; browser-side code receives only masked placeholders (***). The plugin-secrets-never-leak architecture test enforces this invariant.
Summary
- Manifest-driven packages define entrypoints, permissions, and metadata through
plugin.json, validated bysrc/core/plugins/manifest.ts - Sandboxed server execution in QuickJS-WASM prevents system access, with strict resource limits in
server/plugins/quickjs/limits.ts - Unsandboxed admin/editor code runs with full browser privileges when
editor.codepermission is granted - Typed SDK APIs (
api.*) provide namespaced capabilities with compile-time and runtime validation - Three-layer permission enforcement spans build-time linting, install-time scanning, and runtime checks
- Lifecycle hooks (
install,activate,deactivate,uninstall,migrate) with automatic rollback on failure - Real-time event streaming via SSE notifies the admin UI of state changes
- Encrypted secrets never transit to browser contexts, backed by architectural tests
Frequently Asked Questions
What sandbox technology does Instatic use for server-side plugins?
Instatic uses QuickJS compiled to WebAssembly for server-side sandboxing. Each plugin runs in a dedicated Bun Worker that instantiates a QuickJS VM with no access to Node.js or Bun APIs. The bootstrap code is authored in TypeScript and compiled to a single string artifact, ensuring consistent VM boundaries while maintaining build flexibility.
How does Instatic prevent plugins from accessing unauthorized system resources?
Protection operates at three layers: build-time linting (instatic-plugin lint) blocks forbidden imports; install-time validation (assertSandboxSafe) re-scans bundles; and runtime checks verify grantedPermissions before executing any privileged SDK method. Additionally, the QuickJS VM has zero filesystem, environment, or network access without explicit manifest declarations and allowlists.
Can plugins run code in the browser with full DOM access?
Yes, but only through specific entrypoints. Code in editor/index.js and admin-app pages runs unsandboxed with full browser privileges, including React and DOM access. This requires the editor.code permission. All other plugin code—server, modules, and frontend entrypoints—either runs in QuickJS-WASM or as isolated scripts without direct DOM manipulation capabilities.
What happens when a plugin lifecycle hook fails?
The runtime in server/plugins/runtime.ts automatically rolls back the operation and transitions the plugin to the error state. The failure details are recorded in lastError, and an event is broadcast via Server-Sent Events to notify the admin UI. The plugin remains in this state until manually reactivated or uninstalled.
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 →