I plugin possono esporre route API per la loro UI admin e le integrazioni esterne. Le route sono montate sotto /_emdash/api/plugins/<slug>/<route-name> (lo <slug> è il campo slug del plugin da emdash-plugin.jsonc — esposto a runtime come ctx.plugin.id) e vengono eseguite all’interno del runtime sandbox con lo stesso PluginContext che ricevono gli hook.
Questa pagina copre i plugin sandbox. La superficie API per i plugin nativi è identica; l’unica differenza è la firma del handler — vedi la nota in Plugin nativi per i dettagli.
Definire le route
Dichiara le route nell’export predefinito di 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 inferisce routeCtx e ctx — non servono annotazioni dei parametri. I gestori di route sandbox prendono due argomenti: (routeCtx, ctx).
routeCtxcontiene dati relativi alla richiesta:{ input, request, requestMeta }.ctxè lo stessoPluginContextche ottieni negli hook —ctx.storage,ctx.kv,ctx.content,ctx.http,ctx.log, ecc.
URL delle route
Le route si montano a /_emdash/api/plugins/<slug>/<route-name>. I nomi delle route possono includere barre per percorsi annidati.
| ID del plugin | Nome della route | 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 |
Autenticazione e CSRF
Le route dei plugin sono autenticate per impostazione predefinita. Il dispatcher richiede una sessione (o un token con lo scope admin) prima di chiamare il tuo handler. Le route private usano il permesso plugins:manage per retrocompatibilità. Imposta permission a un permesso RBAC EmDash più specifico quando l’operazione appartiene a una capacità esistente di contenuto, media, schema o impostazioni:
routes: {
create: {
permission: "content:create",
input: z.object({ title: z.string() }),
handler: async (routeCtx, ctx) => {
// ...
},
},
},
Le route private richiedono l’header CSRF X-EmDash-Request: 1 per le richieste autenticate con cookie. L’UI admin lo invia automaticamente; le richieste autenticate con token ne sono esenti.
Per escludere una route da auth e CSRF, contrassegnala 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 };
},
},
},
Caching delle risposte pubbliche
Le risposte API usano per default Cache-Control: private, no-store. Per le route pubbliche che servono gli stessi dati a tutti — un catalogo prodotti, un indice di ricerca pubblico — ciò significa che ogni visualizzazione di pagina paga un round-trip completo all’origine. Le route pubbliche possono optare per il caching CDN/browser con cacheControl:
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async (ctx) => listProducts(ctx),
},
},
L’header viene applicato solo alle risposte GET riuscite delle route pubbliche. Gli errori non vengono mai cachati, gli altri metodi mantengono il default, e impostare cacheControl su una route privata non ha effetto — le risposte autenticate restano sempre private, no-store.
Esporre una route come strumento MCP
I plugin possono esporre esplicitamente route private selezionate tramite il server MCP di EmDash. L’esposizione MCP non viene mai dedotta dalla lista delle route:
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 espone come <pluginId>__createEvent. La route referenziata deve essere privata e dichiarare permission. Gli schemi di input sono obbligatori; gli schemi di output sono opzionali. Imposta destructive: true per strumenti che eliminano, sovrascrivono, pubblicano, addebitano o eseguono azioni difficili da annullare.
Un amministratore deve abilitare separatamente gli strumenti MCP di un plugin dopo aver esaminato i loro nomi, descrizioni, route, permessi e flag destructivi. Chiamare lo strumento richiede sia il permesso della route che lo scope del token mcp:tools o mcp:tools:<pluginId>.
Validazione dell’input
input accetta uno schema Zod. Il dispatcher analizza il corpo della richiesta (POST/PUT/PATCH) o la stringa di query (GET/DELETE), lo valida e passa il risultato tipizzato al tuo handler come routeCtx.input. Un input non valido restituisce un 400 prima che il tuo handler venga eseguito.
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 };
},
},
},
Valori di ritorno
Restituisci qualsiasi valore serializzabile in JSON. Il dispatcher lo avvolge nell’envelope standard di EmDash ({ success: true, data: <il tuo valore> }) e lo serve come application/json.
return { id: "abc", count: 42 }; // avvolto in { success: true, data: { id, count } }
return [1, 2, 3]; // avvolto in { success: true, data: [1, 2, 3] }
Errori
Lancia un’eccezione per restituire una risposta di errore. Tutto ciò che non è un errore di plugin conosciuto restituisce un messaggio generico — le eccezioni interne vengono mascherate anziché esporre stack trace o errori del database:
handler: async (routeCtx, ctx) => {
const item = await ctx.storage.items.get(routeCtx.input.id);
if (!item) {
throw new Error("Item not found");
}
return item;
},
Per un codice di stato specifico, lancia 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;
},
Metodi HTTP
Le route rispondono a tutti i metodi. Biforca su routeCtx.request.method se hai bisogno di un comportamento per metodo:
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 });
}
},
},
},
Accedere alla richiesta
routeCtx.request è un SandboxedRequest: un record portabile { url, method, headers } che si comporta in modo identico nel processo e all’interno di un isolate. headers è un Record<string, string> con chiavi in minuscolo — indicizza per il nome in minuscolo o itera con Object.entries. url è una stringa, quindi new URL(request.url) analizza i parametri di query. routeCtx.requestMeta contiene IP, user agent e dati geografici normalizzati tra piattaforme quando disponibili.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const auth = request.headers["authorization"]; // chiave in minuscolo, nessun .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 });
}
},
Pattern comuni
Impostazioni tramite KV
I plugin sandbox leggono e scrivono impostazioni tramite lo store KV, convenzionalmente sotto un prefisso settings:. Il modulo auto-generato settingsSchema è solo per i nativi — per i plugin sandbox, esponi la lettura/scrittura tramite route e renderizza il modulo in 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 paginata
Restituisci paginazione basata su cursore da una query di storage — la forma della risposta corrisponde a quella usata dal resto di 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 API esterno
Inoltra una richiesta a un servizio esterno tramite ctx.http (richiede la capacità network:request e una voce in 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();
},
},
},
Chiamare route dall’UI admin
Usa usePluginAPI() dal pacchetto admin — aggiunge l’header CSRF X-EmDash-Request e il prefisso dell’id del 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");
};
}
Chiamare route da handler di coda e pianificati
I handler di eventi della piattaforma (un consumatore di coda Cloudflare, un handler scheduled() personalizzato) non hanno richiesta HTTP e quindi non hanno locals.emdash. Usa withEmDashRuntime() da emdash/middleware per ottenere il runtime direttamente e invocare una route di plugin senza richiesta:
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled da @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();
}
});
},
};
Questo risolve lo stesso runtime cachato che usano i handler delle richieste, quindi lo storage del plugin, gli hook e l’accesso ai media funzionano esattamente come durante una richiesta. Su adattatori di database basati su connessione (es. Postgres via Hyperdrive) il callback viene eseguito sotto una connessione con scope dell’evento che viene committata e chiusa al ritorno.
Chiamare route esternamente
Le route pubbliche sono direttamente invocabili:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
Le route private necessitano di credenziali di sessione o un token API con lo 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"}'
Riferimento del contesto di route
// Cosa ricevono i handler di route sandbox come loro due argomenti
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // chiavi in minuscolo
}
interface SandboxedRouteContext {
input: unknown; // restringere con lo schema Zod `input` a livello di route
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 o content:write dichiarato
taxonomies?: TaxonomyAccess; // quando taxonomies:read dichiarato
media?: MediaAccess; // quando media:read o media:write dichiarato
http?: HttpAccess; // quando network:request dichiarato
users?: UserAccess; // quando users:read dichiarato
email?: EmailAccess; // quando email:send dichiarato e provider configurato
}
I plugin nativi ricevono un singolo argomento RouteContext che combina i due — vedi Creare plugin nativi se percorri quella strada.