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.