Les plugins peuvent exposer des routes API pour leur interface d’administration et leurs intégrations externes. Les routes sont montées sous /_emdash/api/plugins/<slug>/<route-name> (le <slug> est le champ slug de emdash-plugin.jsonc — exposé au runtime comme ctx.plugin.id) et s’exécutent dans le runtime sandbox avec le même PluginContext que reçoivent les hooks.
Cette page couvre les plugins sandboxés. La surface API pour les plugins natifs est la même ; la seule différence est la signature du handler — voir la note dans Plugins natifs pour plus de 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 — aucune annotation de paramètre nécessaire. Les handlers de routes sandboxés prennent deux arguments : (routeCtx, ctx).
routeCtxcontient des données en forme de 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 de routes
Les routes sont montées à /_emdash/api/plugins/<slug>/<route-name>. Les noms de routes peuvent inclure des barres obliques pour des chemins imbriqués.
| ID du plugin | Nom de la 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 nécessite une session (ou un token avec le scope admin) avant d’appeler votre handler :
- Les méthodes de lecture (
GET,HEAD,OPTIONS) nécessitent la permissionplugins:read. - Les méthodes d’écriture (
POST,PUT,PATCH,DELETE) nécessitentplugins:manage. - Les méthodes modifiant l’état sur les routes privées nécessitent également l’en-tête CSRF
X-EmDash-Request: 1(le hookusePluginAPI()de l’interface d’administration l’envoie automatiquement ; les appelants externes authentifiés par cookie doivent le définir eux-mêmes ; les requêtes authentifiées par token sont exemptées).
Pour exempter une route d’authentification et 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 };
},
},
},
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 renvoie un 400 avant que votre handler ne s’exécute.
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’enveloppe standard d’EmDash ({ success: true, data: <your value> }) et la sert comme 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] }
Erreurs
Lancez pour retourner une réponse d’erreur. Tout ce qui n’est pas une erreur de plugin connue renvoie un message générique — les exceptions internes sont masquées plutôt que de fuiter des traces de pile 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ès à la requête
routeCtx.request est un SandboxedRequest : un enregistrement portable { url, method, headers } qui se comporte de manière identique en processus et à l’intérieur d’un isolat. headers est un Record<string, string> indexé par nom d’en-tête en minuscules — indexez-le 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, le user agent et les données géo normalisées entre les plateformes lorsqu’elles sont disponibles.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const auth = request.headers["authorization"]; // 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") {
throw new Response("POST required", { status: 405 });
}
},
Modèles 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 settingsSchema autogénéré est réservé aux natifs — pour les plugins sandboxés, exposez la lecture/écriture via des routes et rendez le formulaire dans 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 le 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 API externe
Proxy 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’interface d’administration
Utilisez usePluginAPI() du package d’administration — il ajoute automatiquement l’en-tête CSRF X-EmDash-Request et le préfixe d’ID du plugin :
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 en externe
Les routes publiques sont appelables directement :
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 credentials 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és reçoivent comme leurs deux arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}
interface SandboxedRouteContext {
input: unknown; // narrow with the `input` Zod schema at the route level
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; // when content:read or content:write declared
media?: MediaAccess; // when media:read or media:write declared
http?: HttpAccess; // when network:request declared
users?: UserAccess; // when users:read declared
email?: EmailAccess; // when email:send declared and provider configured
}
Les plugins natifs reçoivent un seul argument RouteContext qui combine les deux — voir Créer des plugins natifs si vous prenez cette voie.