Plugins podem expor rotas API para sua UI admin e integrações externas. Rotas são montadas em /_emdash/api/plugins/<slug>/<route-name> (o <slug> é o campo slug do plugin de emdash-plugin.jsonc — exposto em runtime como ctx.plugin.id) e executam dentro do runtime sandbox com o mesmo PluginContext que os hooks recebem.
Esta página cobre plugins sandbox. A superfície API para plugins nativos é a mesma; a única diferença é a assinatura do handler — veja a nota em Plugins nativos para detalhes.
Definindo rotas
Declare rotas na exportação padrão 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 infere routeCtx e ctx — sem necessidade de anotações de parâmetros. Handlers de rota sandbox recebem dois argumentos: (routeCtx, ctx).
routeCtxcarrega dados relacionados à requisição:{ input, request, requestMeta }.ctxé o mesmoPluginContextque você obtém nos hooks —ctx.storage,ctx.kv,ctx.content,ctx.http,ctx.log, etc.
URLs de rotas
Rotas montam em /_emdash/api/plugins/<slug>/<route-name>. Nomes de rota podem incluir barras para caminhos aninhados.
| ID do plugin | Nome da rota | 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 |
Autenticação e CSRF
Rotas de plugin são autenticadas por padrão. O dispatcher requer uma sessão (ou um token com o escopo admin) antes de chamar seu handler. Rotas privadas usam a permissão plugins:manage por padrão para retrocompatibilidade. Defina permission para uma permissão RBAC EmDash mais específica quando a operação pertence a uma capacidade existente de conteúdo, mídia, esquema ou configurações:
routes: {
create: {
permission: "content:create",
input: z.object({ title: z.string() }),
handler: async (routeCtx, ctx) => {
// ...
},
},
},
Rotas privadas requerem o header CSRF X-EmDash-Request: 1 para requisições autenticadas por cookie. A UI admin o envia automaticamente; requisições autenticadas por token são isentas.
Para excluir uma rota de auth e CSRF, marque 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 };
},
},
},
Cache de respostas públicas
Respostas API usam Cache-Control: private, no-store por padrão. Para rotas públicas que servem os mesmos dados para todos — um catálogo de produtos, um índice de busca público — isso significa que cada visualização de página paga uma viagem completa ao origin. Rotas públicas podem optar pelo cache CDN/navegador com cacheControl:
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async (ctx) => listProducts(ctx),
},
},
O header é aplicado apenas a respostas GET bem-sucedidas de rotas públicas. Erros nunca são cacheados, outros métodos mantêm o padrão, e definir cacheControl em uma rota privada não tem efeito — respostas autenticadas sempre permanecem private, no-store.
Expondo uma rota como ferramenta MCP
Plugins podem expor explicitamente rotas privadas selecionadas através do servidor MCP do EmDash. A exposição MCP nunca é inferida da lista de rotas:
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;
O EmDash expõe isso como <pluginId>__createEvent. A rota referenciada deve ser privada e declarar permission. Esquemas de entrada são obrigatórios; esquemas de saída são opcionais. Defina destructive: true para ferramentas que excluem, sobrescrevem, publicam, cobram ou executam ações difíceis de reverter.
Um administrador deve habilitar separadamente as ferramentas MCP de um plugin após revisar seus nomes, descrições, rotas, permissões e flags destructivos. Chamar a ferramenta requer tanto a permissão da rota quanto o escopo de token mcp:tools ou mcp:tools:<pluginId>.
Validação de entrada
input aceita um esquema Zod. O dispatcher analisa o corpo da requisição (POST/PUT/PATCH) ou a string de consulta (GET/DELETE), valida e passa o resultado tipado ao seu handler como routeCtx.input. Entrada inválida retorna 400 antes do seu handler executar.
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
Retorne qualquer valor serializável em JSON. O dispatcher o envolve no envelope padrão do EmDash ({ success: true, data: <seu valor> }) e o serve como application/json.
return { id: "abc", count: 42 }; // envolvido em { success: true, data: { id, count } }
return [1, 2, 3]; // envolvido em { success: true, data: [1, 2, 3] }
Erros
Lance para retornar uma resposta de erro. Qualquer coisa que não seja um erro de plugin conhecido retorna uma mensagem genérica — exceções internas são mascaradas em vez de vazar stack traces ou erros de banco de dados:
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 um código de status específico, lance uma 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
Rotas respondem a todos os métodos. Ramifique em routeCtx.request.method se precisar de comportamento 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 });
}
},
},
},
Acessando a requisição
routeCtx.request é um SandboxedRequest: um registro portátil { url, method, headers } que se comporta de forma idêntica no processo e dentro de um isolate. headers é um Record<string, string> com chaves em minúsculas — indexe pelo nome em minúsculas ou itere com Object.entries. url é uma string, então new URL(request.url) analisa os parâmetros de consulta. routeCtx.requestMeta contém IP, user agent e dados geográficos normalizados entre plataformas quando disponíveis.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const auth = request.headers["authorization"]; // chave em minúsculas, sem .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 });
}
},
Padrões comuns
Configurações via KV
Plugins sandbox leem e escrevem configurações através do armazenamento KV, convencionalmente sob um prefixo settings:. O formulário auto-gerado settingsSchema é apenas para nativos — para plugins sandbox, exponha a leitura/escrita através de rotas e renderize o formulário em 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
Retorne paginação baseada em cursor de uma consulta de armazenamento — a forma da resposta corresponde ao que o resto do EmDash usa:
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
Redirecione uma requisição para um serviço externo através de ctx.http (requer a capacidade network:request e uma entrada em 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();
},
},
},
Chamando rotas da UI admin
Use usePluginAPI() do pacote admin — adiciona o header CSRF X-EmDash-Request e o prefixo do id do plugin automaticamente:
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");
};
}
Chamando rotas de handlers de fila e agendados
Handlers de eventos de plataforma (um consumidor de fila Cloudflare, um handler scheduled() personalizado) não têm requisição HTTP e portanto não têm locals.emdash. Use withEmDashRuntime() de emdash/middleware para obter o runtime diretamente e invocar uma rota de plugin sem requisição:
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();
}
});
},
};
Isso resolve o mesmo runtime cacheado que os handlers de requisição usam, então armazenamento de plugin, hooks e acesso a mídia funcionam exatamente como durante uma requisição. Em adaptadores de banco de dados baseados em conexão (ex. Postgres via Hyperdrive) o callback executa sob uma conexão com escopo de evento que é commitada e fechada ao retornar.
Chamando rotas externamente
Rotas públicas são diretamente invocáveis:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
Rotas privadas precisam de credenciais de sessão ou um token API com o escopo 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"}'
Referência do contexto de rota
// O que handlers de rota sandbox recebem como seus dois argumentos
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // chaves em minúsculas
}
interface SandboxedRouteContext {
input: unknown; // restringir com o esquema Zod `input` no nível da rota
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; // quando content:read ou content:write declarado
taxonomies?: TaxonomyAccess; // quando taxonomies:read declarado
media?: MediaAccess; // quando media:read ou media:write declarado
http?: HttpAccess; // quando network:request declarado
users?: UserAccess; // quando users:read declarado
email?: EmailAccess; // quando email:send declarado e provedor configurado
}
Plugins nativos recebem um único argumento RouteContext que combina ambos — veja Criando plugins nativos se estiver seguindo esse caminho.