Les plugins peuvent exposer des routes API pour leur interface d’administration et les intégrations externes. Les routes sont montées sous /_emdash/api/plugins/<plugin-id>/<route-name> et s’exécutent à l’intérieur de l’environnement sandbox avec le même PluginContext que reçoivent les hooks.
Cette page couvre les plugins en sandbox (format standard). La surface d’API pour les plugins natifs est la même ; la seule différence est la signature du handler — consultez la note dans Plugins natifs pour plus de détails.
Définir des routes
Déclarez les routes dans definePlugin() depuis votre 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;
},
},
},
});
Les handlers de routes au format standard prennent deux arguments : (routeCtx, ctx).
routeCtxcontient les 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 route
Les routes sont montées à /_emdash/api/plugins/<plugin-id>/<route-name>. Les noms de route peuvent inclure des barres obliques pour des chemins imbriqués.
| ID de 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 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 en sont exemptées).
Pour exclure une route de l’authentification et du CSRF, marquez-la comme 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 retourne 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: <votre valeur> }) et la sert comme application/json.
return { id: "abc", count: 42 }; // enveloppé à { success: true, data: { id, count } }
return [1, 2, 3]; // enveloppé à { success: true, data: [1, 2, 3] }
Erreurs
Levez une erreur 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 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 d’état spécifique, levez 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
L’objet Request complet est disponible comme routeCtx.request pour les en-têtes, l’accès brut au corps et l’analyse d’URL. routeCtx.requestMeta contient les données IP, user agent et géo normalisées entre les plateformes.
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 });
}
},
Modèles courants
Paramètres via KV
Les plugins en sandbox lisent et écrivent les paramètres via le magasin KV, conventionnellement sous un préfixe settings:. Le formulaire settingsSchema généré automatiquement est réservé aux natifs — pour les plugins en sandbox, 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 un curseur depuis une requête de stockage — la forme de 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
Proxez 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 de 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 peuvent être appelées 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 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": "[email protected]"}'
Référence du contexte de route
// Ce que les handlers au format standard reçoivent comme leurs deux arguments
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; // quand content:read ou content:write est déclaré
media?: MediaAccess; // quand media:read ou media:write est déclaré
http?: HttpAccess; // quand network:request est déclaré
users?: UserAccess; // quand users:read est déclaré
email?: EmailAccess; // quand email:send est déclaré et le fournisseur configuré
}
Les plugins natifs reçoivent un seul argument RouteContext qui combine les deux — consultez Créer des plugins natifs si vous prenez cette voie.