EmDash plugins run in one of two modes: sandboxed or native. Both use the same definePlugin() API and the same hooks, but they differ in how they’re installed, what enforcement they get, and what extra features are available.
Prefer sandboxed plugins. Sandboxed plugins can be installed from the marketplace with one click — no npm install, no rebuild, no redeploy. Native plugins require a code change, a dependency install, and a full rebuild. If your plugin can work within the sandbox, it should.
Quick Comparison
| Sandboxed | Native | |
|---|---|---|
| Install method | One-click from admin UI | Code change + npm install + deploy |
| Runs in | Isolated V8 isolate | Same process as your Astro site |
| Capabilities | Enforced by RPC bridge | Advisory only (not enforced) |
| Resource limits | CPU, memory, subrequests, wall-time | None |
| Network access | Via ctx.http with host allowlist | Unrestricted |
| Data isolation | Full — scoped storage and KV | None — shares the process |
| Platform | Cloudflare Workers | All platforms |
| Admin UI | Block Kit (JSON-based) | React components or Block Kit |
| PT block types | Not available | Astro components via componentsEntry |
| Page fragments | Not available | Available with page:inject capability |
What Both Modes Support
The core plugin API is the same regardless of execution mode. Both sandboxed and native plugins can use:
| Feature | Details |
|---|---|
| All 22 hooks | Content, media, email, comments, cron, page metadata, lifecycle |
| API routes | REST endpoints at /_emdash/api/plugins/<id>/<route> |
| Plugin storage | Document collections with indexes and queries |
| KV store | Key-value storage for settings and state |
| Content CRUD | Read, create, update, delete site content (with capability) |
| Media CRUD | Read, upload, delete media files (with capability) |
| HTTP fetch | External API calls (with capability and host allowlist) |
| User access | Read user info (with capability) |
| Send email (with capability and provider configured) | |
| Cron scheduling | Schedule recurring tasks |
| Page metadata | Contribute meta tags, OpenGraph, JSON-LD to <head> |
| Settings schema | Auto-generated admin settings UI |
| Admin pages & widgets | Via Block Kit |
What Only Native Plugins Can Do
Three features require build-time integration that sandboxed plugins can’t provide:
Custom React Admin Pages
Native plugins can ship React components that render full admin pages and dashboard widgets. Sandboxed plugins use Block Kit instead — a JSON-based UI that the admin renders on the plugin’s behalf.
Native (React)
export function SettingsPage({ api }) {
const [config, setConfig] = useState(null);
useEffect(() => {
api.get("settings").then(setConfig);
}, []);
return (
<form onSubmit={() => api.post("settings/save", config)}>
<input value={config?.apiKey} onChange={...} />
</form>
);
} Sandboxed (Block Kit)
routes: {
admin: {
handler: async (routeCtx, ctx) => {
const apiKey = await ctx.kv.get("settings:apiKey");
return {
blocks: [
{ type: "header", text: "Settings" },
{
type: "input",
element: {
type: "text_input",
action_id: "apiKey",
initial_value: apiKey ?? "",
},
label: "API Key",
},
{
type: "actions",
elements: [
{ type: "button", text: "Save", action_id: "save" },
],
},
],
};
},
},
} Portable Text Block Types
Plugins can add custom block types to the Portable Text editor (YouTube embeds, code snippets, etc.). The editing UI uses Block Kit fields, which works in both modes. But rendering those blocks on the public site requires Astro components loaded at build time from npm — so only native plugins can provide a componentsEntry.
Sandboxed plugins can still declare PT block types with editing fields. The site author just needs to provide their own rendering components or use a companion native package for rendering.
Page Fragment Injection
The page:fragments hook injects raw HTML, scripts, or stylesheets into public pages. Because these fragments execute as first-party code in the visitor’s browser — outside any sandbox boundary — this hook is restricted to native plugins. Sandboxed plugins can use page:metadata to contribute structured data (meta tags, OpenGraph, JSON-LD) instead.
How Sandboxing Works
Sandboxed plugins run in isolated V8 isolates provided by Cloudflare’s Dynamic Worker Loader. The plugin code never shares memory, globals, or bindings with your Astro site.
Architecture
┌─────────────────────┐ RPC ┌──────────────────────┐
│ Plugin Isolate │ <----------> │ PluginBridge │
│ (V8 Worker Loader) │ (binding) │ (WorkerEntrypoint) │
│ │ │ │
│ ctx.kv.get(k) │─────────────>│ kvGet(k) │
│ ctx.content.list() │─────────────>│ contentList() │
│ ctx.http.fetch(u) │─────────────>│ httpFetch(u) │
└─────────────────────┘ └──────────────────────┘
│
v
┌──────────────┐
│ D1 / R2 │
└──────────────┘
Every ctx method is a proxy to the bridge. The bridge validates capabilities, scopes storage, and enforces host allowlists before touching any real resources.
What the Sandbox Enforces
-
Capability enforcement
If a plugin declares
capabilities: ["read:content"], it can callctx.content.get()andctx.content.list()— nothing else. Attemptingctx.content.create()throws a permission error. The plugin cannot bypass this because it has no direct database access. -
Resource limits
Every hook or route invocation runs with hard limits:
Resource Default Enforced by CPU time 50ms Worker Loader (V8 isolate abort) Subrequests 10 per invocation Worker Loader (V8 isolate abort) Wall-clock time 30 seconds EmDash runner ( Promise.race)Memory ~128MB V8 platform ceiling Exceeding CPU or subrequest limits aborts the isolate. Exceeding wall-time rejects the invocation promise.
-
Network isolation
Direct
fetch()is blocked at the V8 level (globalOutbound: null). Plugins must usectx.http.fetch(), which proxies through the bridge and validates the target host against the plugin’sallowedHostslist. -
Storage scoping
All storage and KV operations are scoped to the plugin’s ID. A plugin cannot read another plugin’s data, and attempting to access undeclared storage collections throws an error.
-
No environment access
Sandboxed plugins have no access to environment variables, the filesystem, or any host bindings. The V8 isolate context is clean.
Wrangler Configuration
Sandboxing requires Dynamic Worker Loader. Add to your wrangler.jsonc:
{
"worker_loaders": [{ "binding": "LOADER" }]
}
How Native Plugins Work
Native plugins run in the same process as your Astro site. They’re loaded from npm packages or local files and configured in astro.config.mjs:
import myPlugin from "@emdash-cms/plugin-analytics";
export default defineConfig({
integrations: [
emdash({
plugins: [myPlugin()],
}),
],
});
In native mode:
- Capabilities are advisory. A plugin declaring
["read:content"]can still access anything in the process. Thecapabilitiesfield documents what the plugin intends to use, but nothing prevents it from importing modules, callingfetch()directly, or reading environment variables. - No resource limits. CPU, memory, and network usage are unbounded.
- Full process access. The plugin shares the runtime with your Astro site.
Native Plugin Formats
Native plugins can use either of two formats:
Standard format (recommended)
Standard format uses a simple { hooks, routes } structure. The same code can run in both native and sandboxed mode. Metadata (id, version, capabilities) comes from the plugin descriptor, not from definePlugin().
import { definePlugin } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
routes: {
status: {
handler: async (routeCtx, ctx) => {
return { ok: true };
},
},
},
}); Native format
Native format includes id, version, capabilities, and admin configuration directly in definePlugin(). This format can only run as a native plugin — it cannot be sandboxed or published to the marketplace.
import { definePlugin } from "emdash";
export default definePlugin({
id: "my-plugin",
version: "1.0.0",
capabilities: ["read:content"],
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
routes: {
status: {
handler: async (ctx) => {
return { ok: true };
},
},
},
admin: {
entry: "@my-org/my-plugin/admin",
settingsSchema: { /* ... */ },
},
}); Use standard format unless you need native-only features (React admin pages, PT components, page fragments).
Node.js Deployments
When deploying to Node.js (or any non-Cloudflare platform):
- The
NoopSandboxRunneris used —isAvailable()returnsfalse - Attempting to load sandboxed plugins throws
SandboxNotAvailableError - All plugins must be registered as native plugins in the
pluginsarray - Capability declarations are purely informational
Security Comparison by Platform
| Threat | Cloudflare (sandboxed) | Node.js (native only) |
|---|---|---|
| Plugin reads unauthorized data | Blocked by bridge capability checks | Not prevented — full DB access |
| Unauthorized network calls | Blocked (globalOutbound: null + host allowlist) | Not prevented — direct fetch() |
| CPU exhaustion | Isolate aborted by Worker Loader | Not prevented — blocks the event loop |
| Memory exhaustion | Isolate terminated | Not prevented — can crash the process |
| Env variable access | No access (isolated V8 context) | Not prevented — shares process.env |
| Filesystem access | No filesystem in Workers | Not prevented — full fs access |
For Node.js deployments, review native plugin source code before installing and use capability declarations as a review checklist.
Choosing the Right Mode
Start with sandboxed. If your plugin uses hooks, routes, storage, and the standard context API, it works in the sandbox. Most plugins fit this model.
Go native when you need to:
- Ship custom React admin pages (not Block Kit)
- Provide Astro components for rendering PT block types on the public site
- Inject scripts or HTML into public pages via
page:fragments - Access Node.js APIs or environment variables directly
- Use npm dependencies that aren’t bundleable into a single ES module
If you’re unsure, build with the standard format. You can always add native-only features later, but going the other direction — native to sandboxed — is harder because native-only features don’t have sandbox equivalents.
Same Code, Different Guarantees
A plugin written in standard format runs identically in both modes. What changes is the enforcement layer:
// This plugin works as both native and sandboxed
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx) => {
// Native mode: ctx.http is always present (capabilities not enforced)
// Sandboxed mode: ctx.http is present because the descriptor declares "network:fetch"
await ctx.http.fetch("https://api.analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ contentId: event.content.id }),
});
},
},
});
Develop locally as a native plugin for faster iteration. Publish to the marketplace as a sandboxed plugin for production. No code changes required.