When we set out to build EmDash's plugin system, we had a deceptively simple requirement: let third-party developers write plugins that run on our platform without compromising security, performance, or reliability. On Cloudflare Workers.

No virtual machines. No Docker containers. No traditional sandboxing. Just serverless JavaScript functions running at the edge — alongside our core application code. This is how we built it.

The Constraints

Cloudflare Workers provides an isolated V8 isolate per request, but that doesn't mean you can run arbitrary user code safely. Here's what we had to work with:

| Constraint | Runtime Limit | Impact |

|-----------|--------------|--------|

| CPU time | 30s (paid), 10ms (free) | No infinite loops |

| Memory | 128 MB | No memory bomb plugins |

| Subrequests | 1000 per request | Limit API call spam |

| Duration | 15 minutes (paid) | Long-running plugins need Durable Objects |

| Module system | ES modules only | No eval(), no require() abuse |

| Network | HTTP(S) only | No raw TCP/UDP from user code |

Architecture: The Plugin Runner

Every EmDash plugin runs inside a dedicated Worker bound to a specific site. The plugin system has three layers:

```

┌─────────────────────────────────┐

│ HTTP Request │

└──────────┬──────────────────────┘

┌──────────▼──────────────────────┐

│ 1. Gateway Worker │

│ - Auth & validation │

│ - Route to correct plugin │

│ - Rate limiting │

└──────────┬──────────────────────┘

┌──────────▼──────────────────────┐

│ 2. Plugin Sandbox Worker │

│ - Isolated Worker context │

│ - Controlled API surface │

│ - Timeout enforcement │

└──────────┬──────────────────────┘

┌──────────▼──────────────────────┐

│ 3. Plugin Code (user-provided) │

│ - ES module exports │

│ - Hook into lifecycle events │

│ - Limited fetch + KV access │

└─────────────────────────────────┘

```

Isolation Without Containers

Cloudflare Workers already provides process-level isolation via V8 isolates. But a plugin system needs more: you can't let one plugin access another plugin's data or D1 bindings. Our solution:

1. Service Bindings with Scoped Access

Each plugin gets its own Worker with scoped bindings:

```toml

wrangler.toml for a plugin Worker

name = "emdash-plugin-og-generator"

main = "plugin-entry.js"

[[d1_databases]]

binding = "PLUGIN_DB"

database_name = "emdash_plugin_og_gen"

database_id = "abc123"

[[kv_namespaces]]

binding = "PLUGIN_KV"

id = "def456"

[env]

PLUGIN_ID = "og-generator"

SITE_ID = "ai-kit-net"

```

The plugin only sees its own D1 namespace and KV store. It can't access the main EmDash database or other plugins' data.

2. Controlled API Surface

Instead of giving plugins raw access to the Request/Response objects, we provide a curated API:

```javascript

// What plugins CAN do

export default {

async hook({ context, data, api }) {

// Read-only access to page metadata

const page = await api.getPage(data.pageId);

// Modify content through sanitized transforms

const transformed = api.transformHtml(page.html, {

appendHead: '<meta name="my-plugin" content="..." />'

});

// Limited KV access

await api.kv.put('counter', '1');

return transformed;

}

}

```

```javascript

// What plugins CANNOT do

// ❌ Direct fetch() to arbitrary URLs (blocked)

// ❌ Access process.env (no Node.js globals)

// ❌ Import arbitrary npm packages

// ❌ Read/write filesystem

// ❌ eval() / new Function() (Workers blocks this)

```

Lifecycle Hooks

Plugins hook into specific events in the content lifecycle:

| Hook | When It Fires | Use Case |

|------|---------------|----------|

| `beforePublish` | Before post goes live | Auto-tagging, SEO checks |

| `afterPublish` | After post is published | Social sharing, webhook calls |

| `renderHtml` | During SSR/page render | OG image generation, ads |

| `onSchedule` | Cron-triggered (configurable) | Weekly sitemap generation |

| `onSearch` | During search query | Custom search ranking |

Example: SEO Check Plugin

```javascript

export default {

async beforePublish({ data, api }) {

const issues = [];

// Check title length

if (data.title.length < 10) {

issues.push('Title too short (< 10 chars)');

}

// Check description

if (!data.excerpt || data.excerpt.length < 50) {

issues.push('Missing or short excerpt');

}

// Check for OG image

const hasOgImage = data.html.includes('og:image');

if (!hasOgImage) {

issues.push('No OG image tag found');

}

// Log to plugin KV for dashboard

await api.kv.put(`seo-check:${data.slug}`, JSON.stringify({

issues,

passed: issues.length === 0,

timestamp: Date.now()

}));

return issues.length === 0;

}

}

```

Deployment Pipeline

When a developer submits a plugin, we run it through a multi-stage pipeline:

```bash

1. Static analysis

npx eslint plugin.js --rules-no-exit

→ Check for dangerous patterns: eval(), require(), process.env

2. AST whitelist check

→ Only allow whitelisted AST node types

3. Size limit

→ Reject plugins > 1MB (minified)

4. Metadata extraction

→ Parse hooks, dependencies, permissions

5. Sandbox deployment

wrangler deploy --env production

→ Deploys to its own isolated Worker

6. Health check

curl https://plugin-og-gen.ai-kit.workers.dev/health

→ 200 OK means it works

```

Lessons Learned

Do this

- **Use Service Bindings for strict isolation** — Each plugin gets its own Worker. No shared memory, no shared state.

- **Provide a curated API** — Don't expose raw platform primitives. Wrap them in controlled access patterns.

- **Set hard timeouts** — Workers has per-request CPU limits, but add your own plugin-level timeout (we use 5s for render hooks, 25s for publish hooks).

- **Version everything** — Plugin state in KV should be namespaced by version (`my-plugin:v1:counter`) so upgrades are safe.

Don't do this

- **Don't allow dynamic imports** — `import()` in plugin code lets users load arbitrary modules at runtime. Disable it.

- **Don't share databases** — Each plugin gets its own D1 namespace. If they share, a buggy plugin can corrupt another plugin's data.

- **Don't trust plugin metadata** — Always re-validate `name`, `version`, and `permissions` at deploy time, not just at submission time.

What's Next

We're working on plugin permissions (granular API scopes like "read:pages" / "write:kv"), a plugin marketplace with user ratings, and hot-reload for development. But the foundation — sandboxed Workers at the edge — is solid.

The full plugin SDK is available to all EmDash users. If you'd like to build a plugin, check the EmDash docs or just build one and submit it via the dashboard.