I plugin possono esporre route API per la loro interfaccia di amministrazione e per integrazioni esterne. Le route sono montate sotto /_emdash/api/plugins/<slug>/<route-name> (lo <slug> è il campo slug del plugin in emdash-plugin.jsonc, esposto a runtime come ctx.plugin.id) e vengono eseguite all’interno del runtime sandbox con lo stesso PluginContext ricevuto dagli hook.
Questa pagina riguarda i plugin sandboxed. I plugin nativi usano le stesse opzioni di route, la stessa autenticazione e lo stesso layout degli URL, ma i loro handler ricevono un unico oggetto di contesto combinato. Per quella firma consulta Il tuo primo plugin nativo.
Definire le route
Dichiara le route nell’export predefinito di src/plugin.ts. Aggiungi zod come dipendenza di runtime quando una route valida l’input o viene esposta come strumento MCP:
pnpm add zod
L’esempio seguente valida una richiesta di invii e interroga lo storage del plugin:
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionsInput = z.object({
formId: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),
});
const plugin: SandboxedPlugin = {
routes: {
status: {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
submissions: {
handler: async (routeCtx, ctx) => {
const parsed = submissionsInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { formId, limit, cursor } = parsed.data;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
L’annotazione SandboxedPlugin deduce i tipi della route e del contesto del plugin, quindi i parametri non richiedono annotazioni. Gli handler di route sandboxed ricevono due argomenti: (routeCtx, ctx).
routeCtxcontiene i dati propri della richiesta:{ input, request, requestMeta }. Il suoinputrestaunknown, quindi validalo prima dell’uso.ctxè lo stessoPluginContextche ricevi negli hook —ctx.storage,ctx.settings,ctx.kv,ctx.content,ctx.httpectx.log.
Filtrare i campi di contenuto indicizzati
I plugin con la capability content:read possono filtrare i campi personalizzati che una collezione contrassegna come indexed. I filtri vengono eseguiti nel database e si combinano con semantica AND:
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
I valori scalari usano la corrispondenza esatta. Usa null per la corrispondenza con null, { in: [...] } per un insieme di valori esatti, oppure gt, gte, lt e lte per i confronti di intervallo. EmDash rifiuta i filtri su campi non indicizzati, i valori che non corrispondono al tipo del campo e più di 20 filtri di campo per query. Un filtro in accetta al massimo 50 valori, e tutti i valori esatti, i limiti di intervallo e i membri di in insieme hanno un budget di 50 operandi per query. Le corrispondenze con null non consumano tale budget.
URL delle route
Le route sono montate in /_emdash/api/plugins/<slug>/<route-name>. I nomi delle route possono includere barre per i 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 scope admin) prima di chiamare il tuo handler. Per retrocompatibilità, le route private usano per impostazione predefinita il permesso plugins:manage. Imposta permission su un permesso RBAC di EmDash più ristretto quando l’operazione appartiene a una capability esistente di contenuti, media, schema o impostazioni:
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
Le route private richiedono il permesso dichiarato per ogni metodo HTTP. Richiedono inoltre l’header CSRF X-EmDash-Request: 1 per le richieste autenticate tramite cookie, incluse GET e HEAD, perché una route di plugin può eseguire lo stesso handler per qualsiasi metodo. L’interfaccia di amministrazione invia l’header automaticamente. Le richieste autenticate tramite token sono esenti dall’header, ma richiedono comunque lo scope di token admin e il permesso della route.
Per escludere una route dall’autenticazione, contrassegnala con public: true:
routes: {
track: {
public: true,
handler: async (routeCtx, ctx) => {
const parsed = z.object({ event: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
ctx.log.info("Tracked", { event: parsed.data.event });
return { ok: true };
},
},
},
L’esposizione di route pubbliche fa parte dell’accesso rivisto di un plugin. L’installazione di un plugin con route pubbliche richiede il consenso. L’aggiunta di una route pubblica, o il passaggio di una route privata a pubblica, richiede un nuovo consenso quando il plugin viene aggiornato.
Il chiamante autenticato
Nelle route private, routeCtx.user è l’utente autenticato che effettua la richiesta — risolto e autorizzato da EmDash prima dell’esecuzione del tuo handler, quindi puoi fidarti di esso per la logica per singolo utente (chiavi API per utente, connessioni OAuth, preferenze gestite dal plugin):
routes: {
"connect/start": {
handler: async (routeCtx, ctx) => {
// Never read the acting user from the request body — any authenticated
// session could impersonate another user that way. Use routeCtx.user.
const caller = routeCtx.user;
if (!caller) throw new Error("No caller bound");
await ctx.kv.set(`user:${caller.id}:connection`, { startedAt: Date.now() });
return { userId: caller.id };
},
},
},
routeCtx.user è undefined nelle route pubbliche (saltano l’autenticazione, quindi non è associato alcun chiamante — anche quando il visitatore ha per caso una sessione di amministratore) e nelle richieste autenticate con token il cui token non è associato a un utente (token macchina). La struttura corrisponde a UserInfo restituito da ctx.users: { id, email, name, role, createdAt } — nessun campo sensibile.
Nota che l’identità del chiamante è separata dalla capability users:read: routeCtx.user ti dice chi sta chiamando ed è sempre disponibile nelle route private, mentre ctx.users è una ricerca nella directory degli utenti che richiede la capability.
Esporre una route come strumento MCP
I plugin possono esporre esplicitamente alcune route private tramite il server MCP di EmDash. L’esposizione MCP non viene mai dedotta dall’elenco delle route:
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
const plugin: SandboxedPlugin = {
routes: {
"events/create": {
permission: "content:create",
handler: async (routeCtx, ctx) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
const input = parsed.data;
return { id: await createEvent(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,
},
},
},
};
export default plugin;
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 facoltativi. Imposta destructive: true per gli strumenti che eliminano, sovrascrivono, pubblicano, addebitano o eseguono comunque un’azione difficile da annullare.
Un amministratore deve abilitare separatamente gli strumenti MCP di un plugin dopo averne esaminato nomi, descrizioni, route, permessi e flag distruttivi. La chiamata dello strumento richiede poi sia il permesso della route sia lo scope di token mcp:tools oppure mcp:tools:<pluginId>.
Uno strumento MCP non può referenziare una route con response: "raw". Gli strumenti MCP usano il contratto di route JSON.
Corpi delle richieste
Le route senza dichiarazione request mantengono il comportamento di input originale. EmDash analizza i corpi di richiesta JSON per POST, PUT e PATCH, e i parametri di query per GET, HEAD e DELETE. Il valore analizzato raggiunge un handler sandboxed come routeCtx.input: unknown.
Dichiara request.body quando la route richiede un altro formato di corpo o un limite di byte specifico. Le modalità disponibili sono none, json, text, bytes e form-data. I corpi delle richieste vengono messi in buffer. Il massimo predefinito è 1 MiB, e una route può portare maxBytes fino a un massimo di 8 MiB.
Usa pluginRoute() per dedurre il tipo di input dalla modalità di corpo dichiarata. A runtime l’helper restituisce il suo argomento invariato:
import { pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
import: pluginRoute({
methods: ["POST"],
request: {
body: "bytes",
maxBytes: 4 * 1024 * 1024,
headers: ["content-type", "x-import-signature"],
},
handler: async (routeCtx) => {
const bytes = routeCtx.input; // Uint8Array
const signature = routeCtx.request.headers["x-import-signature"];
return { accepted: bytes.byteLength, signature };
},
}),
},
};
export default plugin;
Con body: "none", routeCtx.input è il record della query string analizzata. Una dichiarazione json mantiene il tipo di input unknown, quindi validalo prima dell’uso. Una dichiarazione text produce una stringa e bytes produce un Uint8Array.
form-data accetta multipart/form-data e application/x-www-form-urlencoded. Produce un array entries ordinato. Le voci di testo contengono { name, kind: "text", value }; le voci di file contengono { name, kind: "file", filename, contentType, bytes }. EmDash accetta al massimo 100 parti, 1 MiB per parte e nomi di file fino a 255 byte UTF-8. I nomi di file non possono contenere caratteri di controllo o separatori di percorso. Anche l’intera richiesta codificata deve rientrare nel limite del corpo della route.
Valida i valori analizzati prima di leggere i campi o eseguire effetti collaterali. Usa safeParse quando un input non valido è un errore previsto del chiamante. In questo modo la route può restituire un risultato JSON stabile invece di trasformare l’input non valido in un’eccezione interna:
const createInput = 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(),
});
routes: {
create: {
handler: async (routeCtx, ctx) => {
const parsed = createInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { title, email, priority, tags } = parsed.data;
await ctx.storage.items.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString(),
});
return { ok: true };
},
},
},
Input da query string (GET/HEAD/DELETE)
I metodi senza corpo non hanno un corpo di richiesta, quindi il loro input proviene dalla query string dell’URL. Ogni valore è una stringa. Le chiavi ripetute diventano array, quindi ?tag=a&tag=b diventa { tag: ["a", "b"] }; un singolo ?tag=a resta { tag: "a" }. Usa z.coerce per numeri e altri valori che non sono stringhe:
const listInput = z.object({
status: z.enum(["open", "closed"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tag: z.union([z.string(), z.array(z.string())]).optional(),
});
routes: {
list: {
// GET /_emdash/api/plugins/<slug>/list?status=open&limit=20&tag=a&tag=b
handler: async (routeCtx, ctx) => {
const parsed = listInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_QUERY" };
const { status, limit, tag } = parsed.data;
// ...
},
},
},
Valori di ritorno JSON
Le route usano il contratto di risposta JSON a meno che non dichiarino response: "raw". Restituisci qualsiasi valore serializzabile in JSON. Il dispatcher lo avvolge nella busta standard di EmDash ({ success: true, data: <your value> }) e lo serve come 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] }
Errori
Genera un’eccezione quando una route sandboxed non può essere completata. EmDash registra l’eccezione e restituisce un ROUTE_ERROR. Il messaggio generato può essere incluso in quella risposta, quindi non inserire mai credenziali, dati personali, percorsi interni o stack trace nel messaggio di un’eccezione:
handler: async (_routeCtx, ctx) => {
try {
return await refreshRemoteIndex(ctx);
} catch {
ctx.log.error("Remote index refresh failed");
throw new Error("Remote index refresh failed");
}
},
Il codice di un plugin sandboxed non può selezionare uno stato HTTP arbitrario generando una Response; una Response non attraversa come errore strutturato il confine di ogni runner sandbox. EmDash assegna gli stati per i fallimenti di autenticazione, autorizzazione, CSRF e route inesistente prima dell’esecuzione dell’handler. Restituisci un risultato JSON per gli esiti previsti di validazione e di dominio, e riserva le eccezioni ai fallimenti imprevisti.
Un errore previsto restituito come JSON usa comunque la risposta HTTP di successo della route e compare all’interno della busta esterna { success: true, data: ... } di EmDash. Includi un codice stabile a livello di applicazione in modo che i client possano distinguere quell’esito.
Metodi HTTP
Il nome della route seleziona un solo handler. Dichiara methods per limitare quali metodi HTTP possono invocarlo. EmDash restituisce 405 Method Not Allowed con un header Allow prima di chiamare l’handler quando il metodo della richiesta non è dichiarato:
routes: {
item: {
methods: ["GET", "DELETE"],
handler: async (routeCtx, ctx) => {
const parsed = z.object({ id: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_ID" };
const { id } = parsed.data;
switch (routeCtx.request.method) {
case "GET":
return await ctx.storage.items.get(id);
case "DELETE":
await ctx.storage.items.delete(id);
return { deleted: true };
}
},
},
},
Le route senza methods restano indifferenti al metodo per compatibilità. Controlla routeCtx.request.method all’interno di una route legacy prima di eseguire una mutazione, oppure aggiungi methods in modo che l’host applichi la restrizione.
Risposte raw
Dichiara response: "raw" quando una route deve restituire testo o byte non avvolti, con uno stato personalizzato e header di risposta sicuri. Restituisci pluginResponse() da emdash/plugin; una Response WHATWG non attraversa il confine del sandbox:
import { pluginResponse, pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
download: pluginRoute({
public: true,
methods: ["GET"],
request: { body: "none" },
response: "raw",
cacheControl: "public, max-age=60",
handler: async () =>
pluginResponse({
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="report.csv"',
},
body: { kind: "text", value: "name,count\nPublished,12\n" },
}),
}),
},
};
export default plugin;
Il corpo della risposta è { kind: "text", value: string } oppure { kind: "bytes", value: Uint8Array } e viene messo in buffer fino a 8 MiB. Le risposte raw possono impostare Accept-Ranges, Content-Disposition, Content-Encoding, Content-Language, Content-Range, Content-Type, ETag, Last-Modified, Location e Retry-After; l’host rimuove ogni altro header fornito dal plugin. Aggiunge X-Content-Type-Options: nosniff, una content security policy per documenti sandboxed e Referrer-Policy: no-referrer. Applica il cacheControl della route solo alle risposte GET e HEAD pubbliche riuscite. Le altre risposte usano private, no-store.
Le route raw non possono servire contenuto attivo della stessa origine. EmDash rifiuta i tipi di media HTML, JavaScript ed ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related e multipart/x-mixed-replace. Usa un plugin nativo o un’origine separata quando la risposta deve eseguire contenuto attivo nel browser.
Accedere alla richiesta
routeCtx.request è una SandboxedRequest: un record portabile { url, method, headers } che si comporta in modo identico in-process e all’interno di un isolate. headers è un Record<string, string> indicizzato per nome di header in minuscolo — accedi con il nome in minuscolo, oppure 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 di geolocalizzazione normalizzati tra le piattaforme, quando disponibili.
Per una route con dichiarazione request, solo i nomi elencati in request.headers raggiungono l’handler. EmDash rifiuta le dichiarazioni per credenziali, cookie, header di Cloudflare Access, autorizzazione proxy, Set-Cookie e l’header CSRF X-EmDash-Request. Rimuove tali header da ogni richiesta sandboxed, comprese le route legacy.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const signature = request.headers["x-import-signature"]; // 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") return { error: "POST_REQUIRED" };
},
Pattern comuni
Impostazioni e dati paginati
Le impostazioni del plugin usano route private, form Block Kit e ctx.settings. Impostazioni fornisce il pattern completo di caricamento, validazione, form e segreti cifrati.
Le route che elencano i dati del plugin devono restituire il cursore proveniente da ctx.storage.<collection>.query(). Paginazione dello storage mostra come passare un cursore e scorrere più pagine senza superare il massimo di 100 elementi per pagina.
Proxy per API esterne
Inoltra una richiesta a un servizio esterno tramite ctx.http (richiede la capability network:request e una voce in allowedHosts):
routes: {
forecast: {
handler: async (routeCtx, ctx) => {
const parsed = z.object({ city: z.string().min(1) }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_CITY" };
if (!ctx.http) throw new Error("Network capability not granted");
const apiKey = await ctx.settings.get<string>("apiKey");
if (!apiKey) throw new Error("API key not configured");
const response = await ctx.http.fetch(
`https://api.weather.example.com/forecast?city=${encodeURIComponent(parsed.data.city)}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
ctx.http.fetch() restituisce una Response WHATWG in buffer in entrambi i runner sandbox. I metodi binari come arrayBuffer() e blob() preservano i byte tra Cloudflare Worker Loader e Node/workerd. I corpi di richiesta e di risposta sono ciascuno limitati a 8 MiB di dati decodificati. Le destinazioni dei reindirizzamenti vengono controllate prima di ogni salto, e gli header delle credenziali vengono rimossi quando un reindirizzamento attraversa origini diverse.
Chiamare le route da Block Kit
I plugin sandboxed non distribuiscono codice React all’amministrazione. Dichiara una route admin e restituisci risposte Block Kit. EmDash invia le interazioni page_load, block_action e form_submit a quella route privata con l’URL e l’header CSRF corretti. Block Kit mostra il contratto di interazione e una route completa.
Chiamare le route da handler di code e di attività pianificate
Gli handler di eventi della piattaforma (un consumer di Cloudflare Queue, un handler scheduled() personalizzato) non hanno una richiesta HTTP e quindi nemmeno locals.emdash. Usa withEmDashRuntime() da emdash/middleware per ottenere direttamente il runtime e invocare una route di plugin senza una richiesta:
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled from @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 in cache usato dagli handler di richiesta, quindi lo storage del plugin, gli hook e l’accesso ai media si comportano esattamente come durante una richiesta. Sugli adapter di database basati su connessione (ad esempio Postgres tramite Hyperdrive) la callback viene eseguita con una connessione limitata all’evento, che viene confermata e chiusa quando la callback ritorna.
Chiamare le route dall’esterno
Le route pubbliche possono essere chiamate direttamente:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
Le route private richiedono credenziali di sessione più X-EmDash-Request: 1, oppure un token API con scope admin. La seguente richiesta da server a server usa un token:
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]"}'
Riferimento del contesto di route
Le interfacce seguenti riassumono i valori portabili disponibili per un handler di route sandboxed:
// What sandboxed route handlers receive as their two arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}
interface SandboxedRouteContext {
input: unknown; // validate inside the handler before use
request: SandboxedRequest;
requestMeta?: unknown;
user?: UserInfo; // authenticated caller on private routes; undefined on public routes
}
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
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
schema?: SchemaAccess; // when schema:read declared
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
bylines?: BylineAccess; // when bylines:read declared
redirects?: RedirectAccess; // when redirects:read or redirects:write declared
media?: MediaAccess; // when any media capability is declared
http?: HttpAccess; // when network:request declared
users?: UserAccess; // when users:read declared
email?: EmailAccess; // when email:send declared and provider configured
}
I plugin nativi ricevono un unico argomento RouteContext che combina i due; consulta Il tuo primo plugin nativo se scegli questa strada.