Los plugins pueden exponer rutas de API para su interfaz de administración y para integraciones externas. Las rutas se montan bajo /_emdash/api/plugins/<slug>/<route-name> (el <slug> es el campo slug del plugin en emdash-plugin.jsonc, expuesto en tiempo de ejecución como ctx.plugin.id) y se ejecutan dentro del entorno de ejecución del sandbox con el mismo PluginContext que reciben los hooks.
Esta página trata sobre los plugins en sandbox. Los plugins nativos usan las mismas opciones de ruta, la misma autenticación y la misma estructura de URL, pero sus manejadores reciben un único objeto de contexto combinado. Consulta Tu primer plugin nativo para ver esa firma.
Definir rutas
Declara las rutas en la exportación por defecto de src/plugin.ts. Añade zod como dependencia de ejecución cuando una ruta valide la entrada o se exponga como herramienta MCP:
pnpm add zod
El siguiente ejemplo valida una petición de envíos y consulta el almacenamiento del plugin:
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionsInput = z.object({
formId: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),
});
const plugin: SandboxedPlugin = {
routes: {
status: {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
submissions: {
handler: async (routeCtx, ctx) => {
const parsed = submissionsInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { formId, limit, cursor } = parsed.data;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
La anotación SandboxedPlugin infiere los tipos de la ruta y del contexto del plugin, así que los parámetros no necesitan anotaciones. Los manejadores de ruta en sandbox reciben dos argumentos: (routeCtx, ctx).
routeCtxcontiene datos propios de la petición:{ input, request, requestMeta }. Suinputsigue siendounknown, así que valídalo antes de usarlo.ctxes el mismoPluginContextque recibes dentro de los hooks:ctx.storage,ctx.settings,ctx.kv,ctx.content,ctx.httpyctx.log.
Filtrar campos de contenido indexados
Los plugins con la capability content:read pueden filtrar los campos personalizados que una colección marca como indexed. Los filtros se ejecutan en la base de datos y se combinan con semántica AND:
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
Los valores escalares usan coincidencia exacta. Usa null para comparar con nulos, { in: [...] } para un conjunto de valores exactos, o gt, gte, lt y lte para comparaciones de rango. EmDash rechaza los filtros sobre campos que no están indexados, los valores que no coinciden con el tipo del campo y más de 20 filtros de campo por consulta. Un filtro in acepta como máximo 50 valores, y todos los valores exactos, límites de rango y miembros de in juntos tienen un presupuesto de 50 operandos por consulta. Las coincidencias con nulo no consumen ese presupuesto.
URL de las rutas
Las rutas se montan en /_emdash/api/plugins/<slug>/<route-name>. Los nombres de ruta pueden incluir barras para crear rutas anidadas.
| ID del plugin | Nombre de la 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 despachador exige una sesión (o un token con el scope admin) antes de llamar a tu manejador. Por compatibilidad con versiones anteriores, las rutas privadas usan por defecto el permiso plugins:manage. Establece permission con un permiso RBAC de EmDash más restringido cuando la operación pertenezca a una capability existente de contenido, medios, esquema o ajustes:
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
Las rutas privadas exigen su permiso declarado para cada método HTTP. También exigen la cabecera CSRF X-EmDash-Request: 1 en las peticiones autenticadas por cookie, incluidas GET y HEAD, porque una ruta de plugin puede ejecutar el mismo manejador para cualquier método. La interfaz de administración envía la cabecera automáticamente. Las peticiones autenticadas con token están exentas de la cabecera, pero siguen necesitando el scope de token admin y el permiso de la ruta.
Para excluir una ruta de la autenticación, márcala con public: true:
routes: {
track: {
public: true,
handler: async (routeCtx, ctx) => {
const parsed = z.object({ event: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
ctx.log.info("Tracked", { event: parsed.data.event });
return { ok: true };
},
},
},
La exposición de rutas públicas forma parte del acceso revisado de un plugin. Instalar un plugin con rutas públicas requiere consentimiento. Añadir una ruta pública, o cambiar una ruta privada a pública, requiere un nuevo consentimiento cuando se actualiza el plugin.
El llamante autenticado
En las rutas privadas, routeCtx.user es el usuario autenticado que realiza la petición: EmDash lo resuelve y autoriza antes de que se ejecute tu manejador, así que puedes confiar en él para la lógica por usuario (claves de API por usuario, conexiones OAuth, preferencias gestionadas por el plugin):
routes: {
"connect/start": {
handler: async (routeCtx, ctx) => {
// Never read the acting user from the request body — any authenticated
// session could impersonate another user that way. Use routeCtx.user.
const caller = routeCtx.user;
if (!caller) throw new Error("No caller bound");
await ctx.kv.set(`user:${caller.id}:connection`, { startedAt: Date.now() });
return { userId: caller.id };
},
},
},
routeCtx.user es undefined en las rutas públicas (omiten la autenticación, así que no hay ningún llamante asociado, incluso cuando el visitante tiene por casualidad una sesión de administrador) y en las peticiones autenticadas con un token que no está asociado a un usuario (tokens de máquina). Su forma coincide con la de UserInfo que devuelve ctx.users: { id, email, name, role, createdAt }, sin campos sensibles.
Ten en cuenta que la identidad del llamante es independiente de la capability users:read: routeCtx.user te dice quién llama y siempre está disponible en las rutas privadas, mientras que ctx.users es una consulta al directorio de usuarios que requiere la capability.
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(),
});
const plugin: SandboxedPlugin = {
routes: {
"events/create": {
permission: "content:create",
handler: async (routeCtx, ctx) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
const input = parsed.data;
return { id: await createEvent(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,
},
},
},
};
export default plugin;
EmDash expone esto como <pluginId>__createEvent. La ruta referenciada debe ser privada y declarar permission. Los esquemas de entrada son obligatorios; los de salida son opcionales. Establece destructive: true en las herramientas que eliminan, sobrescriben, publican, cobran o realizan de cualquier otro modo una acción difícil de revertir.
Un administrador debe habilitar por separado las herramientas MCP de un plugin tras revisar sus nombres, descripciones, rutas, permisos e indicadores destructivos. Para llamar a la herramienta se requieren entonces tanto el permiso de la ruta como el scope de token mcp:tools o mcp:tools:<pluginId>.
Una herramienta MCP no puede referenciar una ruta con response: "raw". Las herramientas MCP usan el contrato de ruta JSON.
Cuerpos de petición
Las rutas sin declaración request mantienen el comportamiento de entrada original. EmDash analiza los cuerpos de petición JSON para POST, PUT y PATCH, y los parámetros de consulta para GET, HEAD y DELETE. El valor analizado llega a un manejador en sandbox como routeCtx.input: unknown.
Declara request.body cuando la ruta necesite otro formato de cuerpo o un límite de bytes concreto. Los modos disponibles son none, json, text, bytes y form-data. Los cuerpos de petición se almacenan en búfer. El máximo por defecto es 1 MiB, y una ruta puede subir maxBytes hasta un máximo de 8 MiB.
Usa pluginRoute() para inferir el tipo de entrada a partir del modo de cuerpo declarado. En tiempo de ejecución, el helper devuelve su argumento sin cambios:
import { pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
import: pluginRoute({
methods: ["POST"],
request: {
body: "bytes",
maxBytes: 4 * 1024 * 1024,
headers: ["content-type", "x-import-signature"],
},
handler: async (routeCtx) => {
const bytes = routeCtx.input; // Uint8Array
const signature = routeCtx.request.headers["x-import-signature"];
return { accepted: bytes.byteLength, signature };
},
}),
},
};
export default plugin;
Con body: "none", routeCtx.input es el registro de la cadena de consulta analizada. Una declaración json mantiene el tipo de entrada como unknown, así que valídalo antes de usarlo. Una declaración text produce una cadena, y bytes produce un Uint8Array.
form-data acepta multipart/form-data y application/x-www-form-urlencoded. Produce un array entries ordenado. Las entradas de texto contienen { name, kind: "text", value }; las entradas de archivo contienen { name, kind: "file", filename, contentType, bytes }. EmDash acepta como máximo 100 partes, 1 MiB por parte y nombres de archivo de hasta 255 bytes UTF-8. Los nombres de archivo no pueden contener caracteres de control ni separadores de ruta. La petición codificada completa también debe caber en el límite de cuerpo de la ruta.
Valida los valores analizados antes de leer campos o realizar efectos secundarios. Usa safeParse cuando una entrada no válida sea un error esperado del llamante. Así la ruta puede devolver un resultado JSON estable en lugar de convertir la entrada no válida en una excepción interna:
const createInput = 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(),
});
routes: {
create: {
handler: async (routeCtx, ctx) => {
const parsed = createInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { title, email, priority, tags } = parsed.data;
await ctx.storage.items.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString(),
});
return { ok: true };
},
},
},
Entrada por cadena de consulta (GET/HEAD/DELETE)
Los métodos sin cuerpo no tienen cuerpo de petición, así que su entrada procede de la cadena de consulta de la URL. Cada valor es una cadena. Las claves repetidas se convierten en arrays, de modo que ?tag=a&tag=b pasa a ser { tag: ["a", "b"] }; un único ?tag=a sigue siendo { tag: "a" }. Usa z.coerce para números y otros valores que no sean cadenas:
const listInput = z.object({
status: z.enum(["open", "closed"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tag: z.union([z.string(), z.array(z.string())]).optional(),
});
routes: {
list: {
// GET /_emdash/api/plugins/<slug>/list?status=open&limit=20&tag=a&tag=b
handler: async (routeCtx, ctx) => {
const parsed = listInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_QUERY" };
const { status, limit, tag } = parsed.data;
// ...
},
},
},
Valores de retorno JSON
Las rutas usan el contrato de respuesta JSON salvo que declaren response: "raw". Devuelve cualquier valor serializable como JSON. El despachador lo envuelve en el sobre estándar de EmDash ({ success: true, data: <your value> }) y lo sirve como application/json.
return { id: "abc", count: 42 }; // wrapped to { success: true, data: { id, count } }
return [1, 2, 3]; // wrapped to { success: true, data: [1, 2, 3] }
Errores
Lanza una excepción cuando una ruta en sandbox no pueda completarse. EmDash registra la excepción y devuelve un ROUTE_ERROR. El mensaje lanzado puede incluirse en esa respuesta, así que nunca pongas credenciales, datos personales, rutas internas ni trazas de pila en el mensaje de una excepción:
handler: async (_routeCtx, ctx) => {
try {
return await refreshRemoteIndex(ctx);
} catch {
ctx.log.error("Remote index refresh failed");
throw new Error("Remote index refresh failed");
}
},
El código de un plugin en sandbox no puede elegir un estado HTTP arbitrario lanzando una Response; una Response no cruza como error estructurado el límite de todos los ejecutores de sandbox. EmDash asigna los estados de los fallos de autenticación, autorización, CSRF y ruta inexistente antes de que se ejecute el manejador. Devuelve un resultado JSON para los resultados esperados de validación y de dominio, y reserva las excepciones para los fallos inesperados.
Un error esperado devuelto como JSON sigue usando la respuesta HTTP correcta de la ruta y aparece dentro del sobre exterior { success: true, data: ... } de EmDash. Incluye un código estable a nivel de aplicación para que los clientes puedan distinguir ese resultado.
Métodos HTTP
El nombre de la ruta selecciona un único manejador. Declara methods para restringir qué métodos HTTP pueden invocarlo. EmDash devuelve 405 Method Not Allowed con una cabecera Allow antes de llamar al manejador cuando el método de la petición no está declarado:
routes: {
item: {
methods: ["GET", "DELETE"],
handler: async (routeCtx, ctx) => {
const parsed = z.object({ id: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_ID" };
const { id } = parsed.data;
switch (routeCtx.request.method) {
case "GET":
return await ctx.storage.items.get(id);
case "DELETE":
await ctx.storage.items.delete(id);
return { deleted: true };
}
},
},
},
Las rutas sin methods siguen siendo agnósticas respecto al método por compatibilidad. Comprueba routeCtx.request.method dentro de una ruta heredada antes de realizar una mutación, o añade methods para que el host aplique la restricción.
Respuestas sin procesar
Declara response: "raw" cuando una ruta deba devolver texto o bytes sin envolver, con un estado personalizado y cabeceras de respuesta seguras. Devuelve pluginResponse() desde emdash/plugin; una Response de WHATWG no cruza el límite del sandbox:
import { pluginResponse, pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
download: pluginRoute({
public: true,
methods: ["GET"],
request: { body: "none" },
response: "raw",
cacheControl: "public, max-age=60",
handler: async () =>
pluginResponse({
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="report.csv"',
},
body: { kind: "text", value: "name,count\nPublished,12\n" },
}),
}),
},
};
export default plugin;
El cuerpo de la respuesta es { kind: "text", value: string } o { kind: "bytes", value: Uint8Array } y se almacena en búfer hasta 8 MiB. Las respuestas sin procesar pueden establecer Accept-Ranges, Content-Disposition, Content-Encoding, Content-Language, Content-Range, Content-Type, ETag, Last-Modified, Location y Retry-After; el host elimina cualquier otra cabecera aportada por el plugin. Añade X-Content-Type-Options: nosniff, una política de seguridad de contenido para documentos en sandbox y Referrer-Policy: no-referrer. Aplica el cacheControl de la ruta solo a las respuestas públicas correctas de GET y HEAD. Las demás respuestas usan private, no-store.
Las rutas sin procesar no pueden servir contenido activo del mismo origen. EmDash rechaza los tipos de medio HTML, JavaScript y ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related y multipart/x-mixed-replace. Usa un plugin nativo o un origen separado cuando la respuesta deba ejecutar contenido activo en el navegador.
Acceder a la petición
routeCtx.request es una SandboxedRequest: un registro portable { url, method, headers } que se comporta igual en proceso y dentro de un isolate. headers es un Record<string, string> indexado por el nombre de cabecera en minúsculas: accede con el nombre en minúsculas o itera con Object.entries. url es una cadena, así que new URL(request.url) analiza los parámetros de consulta. routeCtx.requestMeta contiene la IP, el agente de usuario y los datos de geolocalización normalizados entre plataformas cuando están disponibles.
En una ruta con declaración request, solo los nombres incluidos en request.headers llegan al manejador. EmDash rechaza las declaraciones de credenciales, cookies, cabeceras de Cloudflare Access, autorización de proxy, Set-Cookie y la cabecera CSRF X-EmDash-Request. Elimina esas cabeceras de toda petición en sandbox, incluidas las rutas heredadas.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const signature = request.headers["x-import-signature"]; // lowercased key, no .get()
const url = new URL(request.url);
const page = url.searchParams.get("page");
ctx.log.info("Request", { meta: requestMeta });
if (request.method !== "POST") return { error: "POST_REQUIRED" };
},
Patrones comunes
Ajustes y datos paginados
Los ajustes del plugin usan rutas privadas, formularios de Block Kit y ctx.settings. Ajustes ofrece el patrón completo de carga, validación, formulario y secretos cifrados.
Las rutas que listan datos del plugin deben devolver el cursor de ctx.storage.<collection>.query(). Paginación del almacenamiento muestra cómo pasar un cursor y recorrer varias páginas sin superar el máximo de 100 elementos por página.
Proxy de API externa
Haz de proxy de una petición a un servicio externo mediante ctx.http (requiere la capability network:request y una entrada en allowedHosts):
routes: {
forecast: {
handler: async (routeCtx, ctx) => {
const parsed = z.object({ city: z.string().min(1) }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_CITY" };
if (!ctx.http) throw new Error("Network capability not granted");
const apiKey = await ctx.settings.get<string>("apiKey");
if (!apiKey) throw new Error("API key not configured");
const response = await ctx.http.fetch(
`https://api.weather.example.com/forecast?city=${encodeURIComponent(parsed.data.city)}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
ctx.http.fetch() devuelve una Response de WHATWG en búfer en ambos ejecutores de sandbox. Los métodos binarios como arrayBuffer() y blob() conservan los bytes entre Cloudflare Worker Loader y Node/workerd. Los cuerpos de petición y de respuesta están limitados cada uno a 8 MiB de datos decodificados. Los destinos de redirección se comprueban antes de cada salto, y las cabeceras de credenciales se eliminan cuando una redirección cambia de origen.
Llamar a rutas desde Block Kit
Los plugins en sandbox no incluyen código React en el panel de administración. Declara una ruta admin y devuelve respuestas de Block Kit. EmDash envía las interacciones page_load, block_action y form_submit a esa ruta privada con la URL y la cabecera CSRF correctas. Block Kit muestra el contrato de interacción y una ruta completa.
Llamar a rutas desde manejadores de colas y tareas programadas
Los manejadores de eventos de la plataforma (un consumidor de Cloudflare Queue, un manejador scheduled() personalizado) no tienen petición HTTP y, por tanto, no tienen locals.emdash. Usa withEmDashRuntime() de emdash/middleware para obtener directamente el entorno de ejecución e invocar una ruta de plugin sin una petición:
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled from @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 entorno de ejecución en caché que usan los manejadores de peticiones, así que el almacenamiento del plugin, los hooks y el acceso a medios se comportan exactamente igual que durante una petición. En los adaptadores de base de datos basados en conexión (por ejemplo, Postgres sobre Hyperdrive), el callback se ejecuta bajo una conexión limitada al evento que se confirma y se cierra cuando el callback termina.
Llamar a rutas desde el exterior
Las rutas públicas se pueden llamar directamente:
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 más X-EmDash-Request: 1, o un token de API con el scope admin. La siguiente petición de servidor a servidor usa un token:
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": "[email protected]"}'
Referencia del contexto de ruta
Las siguientes interfaces resumen los valores portables disponibles para un manejador de ruta en sandbox:
// What sandboxed route handlers receive as their two arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}
interface SandboxedRouteContext {
input: unknown; // validate inside the handler before use
request: SandboxedRequest;
requestMeta?: unknown;
user?: UserInfo; // authenticated caller on private routes; undefined on public routes
}
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
log: LogAccess;
site: SiteInfo;
url(path: string): string;
cron?: CronAccess;
content?: ContentAccess; // when content:read or content:write declared
schema?: SchemaAccess; // when schema:read declared
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
bylines?: BylineAccess; // when bylines:read declared
redirects?: RedirectAccess; // when redirects:read or redirects:write declared
media?: MediaAccess; // when any media capability is declared
http?: HttpAccess; // when network:request declared
users?: UserAccess; // when users:read declared
email?: EmailAccess; // when email:send declared and provider configured
}
Los plugins nativos reciben un único argumento RouteContext que combina ambos; consulta Tu primer plugin nativo si vas por ese camino.