Les plugins peuvent exposer des routes API pour leur UI admin et les intégrations externes. Les routes sont montées sous /_emdash/api/plugins/<slug>/<route-name> (le <slug> est le champ slug du plugin depuis emdash-plugin.jsonc — exposé au runtime comme ctx.plugin.id) et s’exécutent dans le runtime sandbox avec le même PluginContext que les hooks reçoivent.
Cette page couvre les plugins sandboxés. La surface API pour les plugins natifs est identique ; la seule différence est la signature du handler — voir la note dans Plugins natifs pour les détails.
Définir des routes
Déclarez les routes dans l’export par défaut 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 infère routeCtx et ctx — pas d’annotations de paramètres nécessaires. Les handlers de route sandbox prennent deux arguments : (routeCtx, ctx).
routeCtxporte les données liées à la requête :{ input, request, requestMeta }.ctxest le mêmePluginContextque vous obtenez dans les hooks —ctx.storage,ctx.kv,ctx.content,ctx.http,ctx.log, etc.
URLs des routes
Les routes se montent à /_emdash/api/plugins/<slug>/<route-name>. Les noms de route peuvent inclure des barres obliques pour les chemins imbriqués.
| ID du plugin | Nom de 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 |
Authentification et CSRF
Les routes de plugin sont authentifiées par défaut. Le dispatcher exige une session (ou un token avec le scope admin) avant d’appeler votre handler. Les routes privées utilisent par défaut la permission plugins:manage pour la rétrocompatibilité. Définissez permission à une permission RBAC EmDash plus étroite quand l’opération appartient à une capacité existante de contenu, média, schéma ou paramètres :
routes: {
create: {
permission: "content:create",
input: z.object({ title: z.string() }),
handler: async (routeCtx, ctx) => {
// ...
},
},
},
Les routes privées exigent l’en-tête CSRF X-EmDash-Request: 1 pour les requêtes authentifiées par cookie. L’UI admin l’envoie automatiquement ; les requêtes authentifiées par token en sont exemptées.
Pour exclure une route de l’auth et du CSRF, marquez-la 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 };
},
},
},
Mise en cache des réponses publiques
Les réponses API utilisent par défaut Cache-Control: private, no-store. Pour les routes publiques qui servent les mêmes données à tout le monde — un catalogue de produits, un index de recherche public — cela signifie que chaque vue de page paie un aller-retour complet vers l’origine. Les routes publiques peuvent opter pour la mise en cache CDN/navigateur avec cacheControl :
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async (ctx) => listProducts(ctx),
},
},
L’en-tête est appliqué uniquement aux réponses GET réussies des routes publiques. Les erreurs ne sont jamais mises en cache, les autres méthodes gardent la valeur par défaut, et définir cacheControl sur une route privée n’a aucun effet — les réponses authentifiées restent toujours private, no-store.
Exposer une route comme outil MCP
Les plugins peuvent exposer explicitement des routes privées sélectionnées via le serveur MCP d’EmDash. L’exposition MCP n’est jamais déduite de la liste des routes :
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 l’expose comme <pluginId>__createEvent. La route référencée doit être privée et déclarer permission. Les schémas d’entrée sont obligatoires ; les schémas de sortie sont optionnels. Définissez destructive: true pour les outils qui suppriment, écrasent, publient, facturent ou effectuent une action difficile à annuler.
Un administrateur doit activer séparément les outils MCP d’un plugin après avoir examiné leurs noms, descriptions, routes, permissions et flags destructifs. L’appel de l’outil nécessite alors à la fois la permission de route et soit le scope de token mcp:tools soit mcp:tools:<pluginId>.
Validation d’entrée
input accepte un schéma Zod. Le dispatcher analyse le corps de la requête (POST/PUT/PATCH) ou la chaîne de requête (GET/DELETE), le valide et passe le résultat typé à votre handler comme routeCtx.input. Une entrée invalide retourne un 400 avant l’exécution de votre 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 };
},
},
},
Valeurs de retour
Retournez n’importe quelle valeur sérialisable en JSON. Le dispatcher l’enveloppe dans l’envelope standard d’EmDash ({ success: true, data: <votre valeur> }) et la sert comme application/json.
return { id: "abc", count: 42 }; // enveloppé en { success: true, data: { id, count } }
return [1, 2, 3]; // enveloppé en { success: true, data: [1, 2, 3] }
Erreurs
Lancez une exception pour retourner une réponse d’erreur. Tout ce qui n’est pas une erreur de plugin connue retourne un message générique — les exceptions internes sont masquées plutôt que de divulguer des stack traces ou des erreurs de base de données :
handler: async (routeCtx, ctx) => {
const item = await ctx.storage.items.get(routeCtx.input.id);
if (!item) {
throw new Error("Item not found");
}
return item;
},
Pour un code de statut spécifique, lancez une 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éthodes HTTP
Les routes répondent à toutes les méthodes. Branchez sur routeCtx.request.method si vous avez besoin d’un comportement par méthode :
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 });
}
},
},
},
Accéder à la requête
routeCtx.request est un SandboxedRequest : un enregistrement portable { url, method, headers } qui se comporte de manière identique en processus et dans un isolate. headers est un Record<string, string> indexé par nom d’en-tête en minuscules — indexez par le nom en minuscules ou itérez avec Object.entries. url est une chaîne, donc new URL(request.url) analyse les paramètres de requête. routeCtx.requestMeta contient l’IP, l’agent utilisateur et les données géographiques normalisées entre plateformes quand disponibles.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const auth = request.headers["authorization"]; // clé en minuscules, pas de .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 });
}
},
Patterns courants
Paramètres via KV
Les plugins sandboxés lisent et écrivent les paramètres via le magasin KV, conventionnellement sous un préfixe settings:. Le formulaire auto-généré settingsSchema est uniquement pour les natifs — pour les plugins sandboxés, exposez la lecture/écriture via des routes et rendez le formulaire 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 };
},
},
},
Liste paginée
Retournez une pagination basée sur un curseur depuis une requête de stockage — la forme de la réponse correspond à ce que le reste d’EmDash utilise :
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 d’API externe
Redirigez une requête vers un service externe via ctx.http (nécessite la capacité network:request et une entrée dans 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();
},
},
},
Appeler des routes depuis l’UI admin
Utilisez usePluginAPI() du package admin — il ajoute l’en-tête CSRF X-EmDash-Request et le préfixe d’id du plugin automatiquement :
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");
};
}
Appeler des routes depuis des handlers de file d’attente et programmés
Les handlers d’événements de plateforme (un consommateur de file Cloudflare, un handler scheduled() personnalisé) n’ont pas de requête HTTP et donc pas de locals.emdash. Utilisez withEmDashRuntime() depuis emdash/middleware pour obtenir le runtime directement et invoquer une route de plugin sans requête :
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();
}
});
},
};
Cela résout le même runtime mis en cache que les handlers de requête utilisent, donc le stockage du plugin, les hooks et l’accès aux médias fonctionnent exactement comme pendant une requête. Sur les adaptateurs de base de données basés sur une connexion (ex. Postgres via Hyperdrive) le callback s’exécute sous une connexion scopée à l’événement qui est commitée et fermée au retour.
Appeler des routes externement
Les routes publiques sont directement appelables :
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
Les routes privées nécessitent des identifiants de session ou un token API avec le 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"}'
Référence du contexte de route
// Ce que les handlers de route sandbox reçoivent comme leurs deux arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // clés en minuscules
}
interface SandboxedRouteContext {
input: unknown; // restreindre avec le schéma Zod `input` au niveau de la 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; // quand content:read ou content:write déclaré
taxonomies?: TaxonomyAccess; // quand taxonomies:read déclaré
media?: MediaAccess; // quand media:read ou media:write déclaré
http?: HttpAccess; // quand network:request déclaré
users?: UserAccess; // quand users:read déclaré
email?: EmailAccess; // quand email:send déclaré et fournisseur configuré
}
Les plugins natifs reçoivent un unique argument RouteContext qui combine les deux — voir Créer des plugins natifs si vous empruntez cette voie.