Los plugins pueden exponer rutas API para su UI admin e integraciones externas. Las rutas se montan bajo /_emdash/api/plugins/<slug>/<route-name> (el <slug> es el campo slug del plugin de emdash-plugin.jsonc — expuesto en tiempo de ejecución como ctx.plugin.id) y se ejecutan dentro del runtime sandbox con el mismo PluginContext que reciben los hooks.
Esta página cubre plugins sandbox. La superficie API para plugins nativos es la misma; la única diferencia es la firma del handler — consulta la nota en Plugins nativos para más detalles.
Definir rutas
Declara rutas en la exportación por defecto de src/plugin.ts:
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "astro/zod";
export default {
routes: {
status: {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
submissions: {
input: z.object({
formId: z.string().optional(),
limit: z.number().default(50),
cursor: z.string().optional(),
}),
handler: async (routeCtx, ctx) => {
const { formId, limit, cursor } = routeCtx.input;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return result;
},
},
},
} satisfies SandboxedPlugin;
satisfies SandboxedPlugin infiere routeCtx y ctx — sin necesidad de anotaciones de parámetros. Los handlers de ruta sandbox toman dos argumentos: (routeCtx, ctx).
routeCtxcontiene datos relacionados con la solicitud:{ input, request, requestMeta }.ctxes el mismoPluginContextque obtienes en los hooks —ctx.storage,ctx.kv,ctx.content,ctx.http,ctx.log, etc.
URLs de rutas
Las rutas se montan en /_emdash/api/plugins/<slug>/<route-name>. Los nombres de ruta pueden incluir barras para rutas anidadas.
| ID del plugin | Nombre de ruta | URL |
|---|---|---|
forms | status | /_emdash/api/plugins/forms/status |
forms | submissions | /_emdash/api/plugins/forms/submissions |
seo | settings/save | /_emdash/api/plugins/seo/settings/save |
analytics | events/recent | /_emdash/api/plugins/analytics/events/recent |
Autenticación y CSRF
Las rutas de plugin están autenticadas por defecto. El dispatcher requiere una sesión (o un token con el scope admin) antes de llamar a tu handler. Las rutas privadas usan por defecto el permiso plugins:manage para compatibilidad retroactiva. Establece permission a un permiso RBAC de EmDash más específico cuando la operación pertenece a una capacidad existente de contenido, medios, esquema o configuración:
routes: {
create: {
permission: "content:create",
input: z.object({ title: z.string() }),
handler: async (routeCtx, ctx) => {
// ...
},
},
},
Las rutas privadas requieren el header CSRF X-EmDash-Request: 1 para solicitudes autenticadas con cookies. La UI admin lo envía automáticamente; las solicitudes autenticadas con token están exentas.
Para excluir una ruta de auth y CSRF, márcala con public: true:
routes: {
track: {
public: true,
input: z.object({ event: z.string() }),
handler: async (routeCtx, ctx) => {
ctx.log.info("Tracked", { event: routeCtx.input.event });
return { ok: true };
},
},
},
Cacheo de respuestas públicas
Las respuestas API usan por defecto Cache-Control: private, no-store. Para rutas públicas que sirven los mismos datos a todos — un catálogo de productos, un índice de búsqueda público — eso significa que cada vista de página paga un viaje completo al origen. Las rutas públicas pueden optar por el cacheo CDN/navegador con cacheControl:
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async (ctx) => listProducts(ctx),
},
},
El header se aplica solo a respuestas GET exitosas de rutas públicas. Los errores nunca se cachean, otros métodos mantienen el valor por defecto, y establecer cacheControl en una ruta privada no tiene efecto — las respuestas autenticadas siempre permanecen como private, no-store.
Exponer una ruta como herramienta MCP
Los plugins pueden exponer explícitamente rutas privadas seleccionadas a través del servidor MCP de EmDash. La exposición MCP nunca se infiere de la lista de rutas:
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
export default {
routes: {
"events/create": {
permission: "content:create",
input: createEventInput,
handler: async (routeCtx, ctx) => {
return { id: await createEvent(routeCtx.input, ctx) };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event when the user asks to add one.",
route: "events/create",
input: createEventInput,
output: z.object({ id: z.string() }),
destructive: false,
},
},
},
} satisfies SandboxedPlugin;
EmDash lo expone como <pluginId>__createEvent. La ruta referenciada debe ser privada y declarar permission. Los esquemas de entrada son obligatorios; los esquemas de salida son opcionales. Establece destructive: true para herramientas que eliminan, sobrescriben, publican, cobran o realizan acciones difíciles de revertir.
Un administrador debe habilitar por separado las herramientas MCP de un plugin después de revisar sus nombres, descripciones, rutas, permisos y flags destructivos. Llamar a la herramienta requiere tanto el permiso de ruta como el scope de token mcp:tools o mcp:tools:<pluginId>.
Validación de entrada
input acepta un esquema Zod. El dispatcher parsea el cuerpo de la solicitud (POST/PUT/PATCH) o la cadena de consulta (GET/DELETE), lo valida y pasa el resultado tipado a tu handler como routeCtx.input. Una entrada inválida devuelve un 400 antes de que tu handler se ejecute.
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 (routeCtx, ctx) => {
const { title, email, priority, tags } = routeCtx.input;
await ctx.storage.items.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString(),
});
return { success: true };
},
},
},
Valores de retorno
Devuelve cualquier valor serializable a JSON. El dispatcher lo envuelve en el envelope estándar de EmDash ({ success: true, data: <tu valor> }) y lo sirve como application/json.
return { id: "abc", count: 42 }; // envuelto a { success: true, data: { id, count } }
return [1, 2, 3]; // envuelto a { success: true, data: [1, 2, 3] }
Errores
Lanza una excepción para devolver una respuesta de error. Todo lo que no sea un error de plugin conocido devuelve un mensaje genérico — las excepciones internas se enmascaran en lugar de exponer stack traces o errores de base de datos:
handler: async (routeCtx, ctx) => {
const item = await ctx.storage.items.get(routeCtx.input.id);
if (!item) {
throw new Error("Item not found");
}
return item;
},
Para un código de estado específico, lanza una Response:
handler: async (routeCtx, ctx) => {
const item = await ctx.storage.items.get(routeCtx.input.id);
if (!item) {
throw new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return item;
},
Métodos HTTP
Las rutas responden a todos los métodos. Bifurca en routeCtx.request.method si necesitas comportamiento por método:
routes: {
item: {
input: z.object({ id: z.string() }),
handler: async (routeCtx, ctx) => {
const { id } = routeCtx.input;
switch (routeCtx.request.method) {
case "GET":
return await ctx.storage.items.get(id);
case "DELETE":
await ctx.storage.items.delete(id);
return { deleted: true };
default:
throw new Response("Method not allowed", { status: 405 });
}
},
},
},
Acceder a la solicitud
routeCtx.request es un SandboxedRequest: un registro portable { url, method, headers } que se comporta de forma idéntica en proceso y dentro de un isolate. headers es un Record<string, string> con claves en minúsculas — indexa por el nombre en minúsculas o itera con Object.entries. url es un string, así que new URL(request.url) parsea los parámetros de consulta. routeCtx.requestMeta contiene IP, user agent y datos geográficos normalizados entre plataformas cuando están disponibles.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const auth = request.headers["authorization"]; // clave en minúsculas, sin .get()
const url = new URL(request.url);
const page = url.searchParams.get("page");
ctx.log.info("Request", { meta: requestMeta });
if (request.method !== "POST") {
throw new Response("POST required", { status: 405 });
}
},
Patrones comunes
Configuración mediante KV
Los plugins sandbox leen y escriben configuraciones a través del almacén KV, convencionalmente bajo un prefijo settings:. El formulario auto-generado settingsSchema es solo para nativos — para plugins sandbox, expón la lectura/escritura a través de rutas y renderiza el formulario en Block Kit.
routes: {
settings: {
handler: async (_routeCtx, 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": {
input: z.object({
enabled: z.boolean().optional(),
apiKey: z.string().optional(),
maxItems: z.number().optional(),
}),
handler: async (routeCtx, ctx) => {
for (const [key, value] of Object.entries(routeCtx.input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
},
},
},
Lista paginada
Devuelve paginación basada en cursor desde una consulta de almacenamiento — la estructura de respuesta coincide con lo que usa el resto de EmDash:
routes: {
list: {
input: z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
status: z.string().optional(),
}),
handler: async (routeCtx, ctx) => {
const { limit, cursor, status } = routeCtx.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,
};
},
},
},
Proxy de API externa
Redirige una solicitud a un servicio externo a través de ctx.http (requiere la capacidad network:request y una entrada en allowedHosts):
routes: {
forecast: {
input: z.object({ city: z.string() }),
handler: async (routeCtx, ctx) => {
if (!ctx.http) throw new Error("Network capability not granted");
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=${routeCtx.input.city}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
Llamar rutas desde la UI admin
Usa usePluginAPI() del paquete admin — añade el header CSRF X-EmDash-Request y el prefijo del id del plugin automáticamente:
import { usePluginAPI } from "@emdash-cms/admin";
function SettingsPage() {
const api = usePluginAPI();
const handleSave = async (settings) => {
await api.post("settings/save", settings);
};
const loadSettings = async () => {
return api.get("settings");
};
}
Llamar rutas desde handlers de cola y programados
Los handlers de eventos de plataforma (un consumidor de Cloudflare Queue, un handler scheduled() personalizado) no tienen solicitud HTTP y por lo tanto no tienen locals.emdash. Usa withEmDashRuntime() de emdash/middleware para obtener el runtime directamente e invocar una ruta de plugin sin solicitud:
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled de @emdash-cms/cloudflare/worker
async queue(batch: MessageBatch) {
await withEmDashRuntime(async (runtime) => {
for (const message of batch.messages) {
const result = await runtime.handlePluginApiRoute(
"my-plugin",
"POST",
"/finishJob",
new Request("https://internal/", {
method: "POST",
body: JSON.stringify(message.body),
}),
);
if (result.success) message.ack();
else message.retry();
}
});
},
};
Esto resuelve el mismo runtime cacheado que usan los handlers de solicitud, por lo que el almacenamiento del plugin, hooks y acceso a medios funcionan exactamente como durante una solicitud. En adaptadores de base de datos basados en conexión (p.ej. Postgres sobre Hyperdrive) el callback se ejecuta bajo una conexión con scope de evento que se hace commit y se cierra al retornar.
Llamar rutas externamente
Las rutas públicas son directamente invocables:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
Las rutas privadas necesitan credenciales de sesión o un token API con el scope admin:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Hello", "email": "user@example.com"}'
Referencia del contexto de ruta
// Lo que los handlers de ruta sandbox reciben como sus dos argumentos
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // claves en minúsculas
}
interface SandboxedRouteContext {
input: unknown; // restringir con el esquema Zod `input` a nivel de ruta
request: SandboxedRequest;
requestMeta?: unknown;
}
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
log: LogAccess;
site: SiteInfo;
url(path: string): string;
cron?: CronAccess;
content?: ContentAccess; // cuando content:read o content:write declarado
taxonomies?: TaxonomyAccess; // cuando taxonomies:read declarado
media?: MediaAccess; // cuando media:read o media:write declarado
http?: HttpAccess; // cuando network:request declarado
users?: UserAccess; // cuando users:read declarado
email?: EmailAccess; // cuando email:send declarado y proveedor configurado
}
Los plugins nativos reciben un único argumento RouteContext que combina ambos — consulta Crear plugins nativos si vas por ese camino.