Los plugins pueden exponer rutas de API para su UI de administración e integraciones externas. Las rutas se montan bajo /_emdash/api/plugins/<plugin-id>/<route-name> y se ejecutan dentro del entorno de sandbox con el mismo PluginContext que reciben los hooks.
Esta página cubre plugins en sandbox (formato estándar). La superficie de API para plugins nativos es la misma; la única diferencia es la firma del handler — consulte la nota en Plugins nativos para más detalles.
Definir rutas
Declare rutas en definePlugin() desde su sandbox-entry.ts:
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
import { z } from "astro/zod";
export default definePlugin({
routes: {
status: {
handler: async (_routeCtx, ctx: PluginContext) => {
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: PluginContext) => {
const { formId, limit, cursor } = routeCtx.input;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return result;
},
},
},
});
Los handlers de rutas en formato estándar toman dos argumentos: (routeCtx, ctx).
routeCtxcontiene datos con forma de solicitud:{ input, request, requestMeta }.ctxes el mismoPluginContextque obtiene dentro de los hooks —ctx.storage,ctx.kv,ctx.content,ctx.http,ctx.log, etc.
URLs de ruta
Las rutas se montan en /_emdash/api/plugins/<plugin-id>/<route-name>. Los nombres de ruta pueden incluir barras para rutas anidadas.
| ID de 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 despachador requiere una sesión (o un token con el scope admin) antes de llamar a su handler:
- Los métodos de lectura (
GET,HEAD,OPTIONS) requieren el permisoplugins:read. - Los métodos de escritura (
POST,PUT,PATCH,DELETE) requierenplugins:manage. - Los métodos que cambian estado en rutas privadas también requieren el encabezado CSRF
X-EmDash-Request: 1(el hookusePluginAPI()de la UI de administración lo envía automáticamente; los llamadores externos autenticados por cookie deben configurarlo ellos mismos; las solicitudes autenticadas por token están exentas).
Para excluir una ruta de autenticación y CSRF, márquela como 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 };
},
},
},
Validación de entrada
input acepta un esquema Zod. El despachador analiza el cuerpo de la solicitud (POST/PUT/PATCH) o la cadena de consulta (GET/DELETE), lo valida y pasa el resultado tipado a su handler como routeCtx.input. Una entrada no válida devuelve un 400 antes de que se ejecute su handler.
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
Devuelva cualquier valor serializable en JSON. El despachador lo envuelve en el sobre estándar de EmDash ({ success: true, data: <su 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
Lance un error para devolver una respuesta de error. Cualquier cosa que no sea un error de plugin conocido devuelve un mensaje genérico — las excepciones internas se enmascaran en lugar de filtrar trazas de pila 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, lance 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. Ramifique según routeCtx.request.method si necesita 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
El objeto Request completo está disponible como routeCtx.request para encabezados, acceso al cuerpo en bruto y análisis de URL. routeCtx.requestMeta contiene IP, agente de usuario y datos geográficos normalizados entre plataformas.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const auth = request.headers.get("Authorization");
const url = new URL(request.url);
const page = url.searchParams.get("page");
ctx.log.info("Request", { ip: requestMeta.ip, ua: requestMeta.userAgent });
if (request.method !== "POST") {
throw new Response("POST required", { status: 405 });
}
},
Patrones comunes
Configuración mediante KV
Los plugins en sandbox leen y escriben configuraciones a través del almacén KV, convencionalmente bajo un prefijo settings:. El formulario settingsSchema generado automáticamente es solo para nativos — para plugins en sandbox, exponga la lectura/escritura a través de rutas y renderice 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
Devuelva paginación basada en cursor desde una consulta de almacenamiento — la forma 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
Proxee 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 de administración
Use usePluginAPI() del paquete de administración — agrega el encabezado CSRF X-EmDash-Request y el prefijo de ID de 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 externamente
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 o un token de 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": "[email protected]"}'
Referencia de contexto de ruta
// Lo que reciben los handlers de formato estándar como sus dos argumentos
interface StandardRouteContext<TInput = unknown> {
input: TInput;
request: Request;
requestMeta: { ip: string | null; userAgent: string | null; geo?: GeoData };
}
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
log: LogAccess;
site: SiteInfo;
url(path: string): string;
cron?: CronAccess;
content?: ContentAccess; // cuando se declara content:read o content:write
media?: MediaAccess; // cuando se declara media:read o media:write
http?: HttpAccess; // cuando se declara network:request
users?: UserAccess; // cuando se declara users:read
email?: EmailAccess; // cuando se declara email:send y se configura el proveedor
}
Los plugins nativos reciben un único argumento RouteContext que combina ambos — consulte Crear plugins nativos si va por ese camino.