Native vs. Sandboxed Plugins

On this page

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

SandboxedNative
Install methodOne-click from admin UICode change + npm install + deploy
Runs inIsolated V8 isolateSame process as your Astro site
CapabilitiesEnforced by RPC bridgeAdvisory only (not enforced)
Resource limitsCPU, memory, subrequests, wall-timeNone
Network accessVia ctx.http with host allowlistUnrestricted
Data isolationFull — scoped storage and KVNone — shares the process
PlatformCloudflare WorkersAll platforms
Admin UIBlock Kit (JSON-based)React components or Block Kit
PT block typesNot availableAstro components via componentsEntry
Page fragmentsNot availableAvailable 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:

FeatureDetails
All 22 hooksContent, media, email, comments, cron, page metadata, lifecycle
API routesREST endpoints at /_emdash/api/plugins/<id>/<route>
Plugin storageDocument collections with indexes and queries
KV storeKey-value storage for settings and state
Content CRUDRead, create, update, delete site content (with capability)
Media CRUDRead, upload, delete media files (with capability)
HTTP fetchExternal API calls (with capability and host allowlist)
User accessRead user info (with capability)
EmailSend email (with capability and provider configured)
Cron schedulingSchedule recurring tasks
Page metadataContribute meta tags, OpenGraph, JSON-LD to <head>
Settings schemaAuto-generated admin settings UI
Admin pages & widgetsVia 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

  1. Capability enforcement

    If a plugin declares capabilities: ["read:content"], it can call ctx.content.get() and ctx.content.list() — nothing else. Attempting ctx.content.create() throws a permission error. The plugin cannot bypass this because it has no direct database access.

  2. Resource limits

    Every hook or route invocation runs with hard limits:

    ResourceDefaultEnforced by
    CPU time50msWorker Loader (V8 isolate abort)
    Subrequests10 per invocationWorker Loader (V8 isolate abort)
    Wall-clock time30 secondsEmDash runner (Promise.race)
    Memory~128MBV8 platform ceiling

    Exceeding CPU or subrequest limits aborts the isolate. Exceeding wall-time rejects the invocation promise.

  3. Network isolation

    Direct fetch() is blocked at the V8 level (globalOutbound: null). Plugins must use ctx.http.fetch(), which proxies through the bridge and validates the target host against the plugin’s allowedHosts list.

  4. 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.

  5. 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. The capabilities field documents what the plugin intends to use, but nothing prevents it from importing modules, calling fetch() 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 NoopSandboxRunner is used — isAvailable() returns false
  • Attempting to load sandboxed plugins throws SandboxNotAvailableError
  • All plugins must be registered as native plugins in the plugins array
  • Capability declarations are purely informational

Security Comparison by Platform

ThreatCloudflare (sandboxed)Node.js (native only)
Plugin reads unauthorized dataBlocked by bridge capability checksNot prevented — full DB access
Unauthorized network callsBlocked (globalOutbound: null + host allowlist)Not prevented — direct fetch()
CPU exhaustionIsolate aborted by Worker LoaderNot prevented — blocks the event loop
Memory exhaustionIsolate terminatedNot prevented — can crash the process
Env variable accessNo access (isolated V8 context)Not prevented — shares process.env
Filesystem accessNo filesystem in WorkersNot 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.