b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
6.2 KiB
6.2 KiB
API Routes
Plugin routes work in both standard and native plugins, and in both trusted and sandboxed modes. Sandboxed plugin routes are invoked via the sandbox runner's invokeRoute() RPC.
Plugin routes expose REST endpoints at /_emdash/api/plugins/<plugin-id>/<route-name>.
Defining Routes
import { definePlugin } from "emdash";
import { z } from "astro/zod";
definePlugin({
id: "forms",
version: "1.0.0",
routes: {
// Simple route
status: {
handler: async (ctx) => {
return { ok: true };
},
},
// Route with input validation
submissions: {
input: z.object({
formId: z.string().optional(),
limit: z.number().default(50),
cursor: z.string().optional(),
}),
handler: async (ctx) => {
const { formId, limit, cursor } = ctx.input;
const result = await ctx.storage.submissions!.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return {
items: result.items,
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
// Nested path
"settings/save": {
input: z.object({
enabled: z.boolean().optional(),
apiKey: z.string().optional(),
}),
handler: async (ctx) => {
for (const [key, value] of Object.entries(ctx.input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
},
},
},
});
Route URLs
| Plugin ID | Route Name | URL |
|---|---|---|
forms |
status |
/_emdash/api/plugins/forms/status |
forms |
submissions |
/_emdash/api/plugins/forms/submissions |
seo |
settings/save |
/_emdash/api/plugins/seo/settings/save |
Handler Context
interface RouteContext<TInput = unknown> extends PluginContext {
input: TInput; // Validated input
request: Request; // Original request
plugin: { id: string; version: string };
storage: Record<string, StorageCollection>;
kv: KVAccess;
content?: ContentAccess; // If capability declared
media?: MediaAccess;
http?: HttpAccess;
log: LogAccess;
}
Input Validation
Use Zod schemas. Invalid input returns 400.
routes: {
create: {
input: z.object({
title: z.string().min(1).max(200),
email: z.string().email(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
tags: z.array(z.string()).optional(),
}),
handler: async (ctx) => {
// ctx.input is typed and validated
const { title, email, priority } = ctx.input;
// ...
},
},
}
Input sources:
- POST/PUT/PATCH — Request body (JSON)
- GET/DELETE — URL query parameters
Return Values
Return any JSON-serializable value. Response is always Content-Type: application/json.
return { success: true, data: items }; // Object
return items; // Array
return 42; // Primitive
Errors
Throw to return error response:
throw new Error("Item not found"); // 500 with { error: "Item not found" }
// Custom status code
throw new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
HTTP Methods
Routes respond to all methods. Check ctx.request.method:
handler: async (ctx) => {
switch (ctx.request.method) {
case "GET":
return await ctx.storage.items!.get(ctx.input.id);
case "DELETE":
await ctx.storage.items!.delete(ctx.input.id);
return { deleted: true };
default:
throw new Response("Method not allowed", { status: 405 });
}
};
Common Patterns
Settings CRUD
routes: {
settings: {
handler: async (ctx) => {
const settings = await ctx.kv.list("settings:");
const result: Record<string, unknown> = {};
for (const entry of settings) {
result[entry.key.replace("settings:", "")] = entry.value;
}
return result;
},
},
"settings/save": {
handler: async (ctx) => {
// EmDash parses the request body once and exposes it as ctx.input.
// Don't call ctx.request.json() — the body stream is already consumed.
const input = ctx.input as Record<string, unknown>;
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) await ctx.kv.set(`settings:${key}`, value);
}
return { success: true };
},
},
}
Paginated List
routes: {
list: {
input: z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
status: z.string().optional(),
}),
handler: async (ctx) => {
const { limit, cursor, status } = ctx.input;
const result = await ctx.storage.items!.query({
where: status ? { status } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return {
items: result.items.map((item) => ({ id: item.id, ...item.data })),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
}
External API Proxy
Requires network:request capability and allowedHosts:
definePlugin({
capabilities: ["network:request"],
allowedHosts: ["api.weather.example.com"],
routes: {
forecast: {
input: z.object({ city: z.string() }),
handler: async (ctx) => {
const apiKey = await ctx.kv.get<string>("settings:apiKey");
if (!apiKey) throw new Error("API key not configured");
const response = await ctx.http!.fetch(
`https://api.weather.example.com/forecast?city=${ctx.input.city}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
},
},
});
Calling from Admin UI
import { usePluginAPI } from "@emdash-cms/admin";
const api = usePluginAPI();
const data = await api.get("status");
await api.post("settings/save", { enabled: true });
Calling Externally
curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Content-Type: application/json" \
-d '{"title": "Hello"}'
Plugin routes don't have built-in auth. Admin-only routes are protected by the admin session middleware.