Route API

In questa pagina

I plugin possono esporre route API per la loro UI di amministrazione e le 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 nel runtime sandbox con lo stesso PluginContext che ricevono gli hook.

Questa pagina riguarda i plugin sandboxed. I plugin nativi usano le stesse opzioni di route, autenticazione e layout URL, ma i loro handler ricevono un unico oggetto di contesto combinato. Vedi Your first native plugin per quella firma.

Definire le route

Dichiara le route nell’export predefinito di src/plugin.ts. Aggiungi zod come dipendenza runtime quando una route valida l’input o è esposta come strumento MCP:

pnpm add zod

L’esempio seguente valida una richiesta di submissions 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 di contesto di route e plugin, quindi i parametri non necessitano di annotazioni. Gli handler di route sandboxed accettano due argomenti: (routeCtx, ctx).

  • routeCtx porta dati a forma di richiesta: { input, request, requestMeta }. Il suo input resta unknown, quindi validalo prima dell’uso.
  • ctx è lo stesso PluginContext che ottieni negli hook — ctx.storage, ctx.settings, ctx.kv, ctx.content, ctx.http e ctx.log.

Filtrare i campi di contenuto indicizzati

I plugin con la capability content:read possono filtrare campi personalizzati che una collection segna 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 corrispondenza esatta. Usa null per la corrispondenza null, { in: [...] } per un insieme di valori esatti, oppure gt, gte, lt e lte per confronti di intervallo. EmDash rifiuta i filtri per campi non indicizzati, valori che non corrispondono al tipo di 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 in insieme hanno un budget di 50 operandi per query. Le corrispondenze null non consumano quel budget.

URL delle route

Le route si montano su /_emdash/api/plugins/<slug>/<route-name>. I nomi delle route possono includere slash per percorsi annidati.

Plugin idRoute nameURL
formsstatus/_emdash/api/plugins/forms/status
formssubmissions/_emdash/api/plugins/forms/submissions
seosettings/save/_emdash/api/plugins/seo/settings/save
analyticsevents/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 per impostazione predefinita il permesso plugins:manage per la retrocompatibilità. Imposta permission su un permesso RBAC EmDash più ristretto quando l’operazione appartiene a una capability esistente di contenuto, 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 anche l’header CSRF X-EmDash-Request: 1 per le richieste autenticate con cookie, inclusi GET e HEAD, perché una route di plugin può eseguire lo stesso handler per qualsiasi metodo. L’UI di amministrazione invia l’header automaticamente. Le richieste autenticate con token sono esenti dall’header ma richiedono comunque lo scope 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 revisionato del plugin. Installare un plugin con route pubbliche richiede il consenso. Aggiungere una route pubblica, o cambiare una route privata in pubblica, richiede di nuovo il consenso quando il plugin viene aggiornato.

Il chiamante autenticato

Sulle route private, routeCtx.user è l’utente autenticato che effettua la richiesta — risolto e autorizzato da EmDash prima che il tuo handler venga eseguito, quindi puoi fidarti di esso per logica per 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 sulle route pubbliche (saltano l’auth, quindi nessun chiamante è vincolato — anche quando il visitatore ha una sessione di amministrazione) e per le richieste autenticate con token in cui il token non è vincolato a un utente (token macchina). La forma corrisponde al UserInfo restituito da ctx.users: { id, email, name, role, createdAt } — senza campi sensibili.

Nota che l’identità del chiamante è separata dalla capability users:read: routeCtx.user ti dice chi sta chiamando ed è sempre disponibile sulle route private, mentre ctx.users è una ricerca nella directory utenti che richiede la capability.

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 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; quelli di output sono opzionali. Imposta destructive: true per strumenti che eliminano, sovrascrivono, pubblicano, addebitano o eseguono altrimenti 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 destructivi. Chiamare lo strumento richiede quindi sia il permesso della route sia lo scope token mcp:tools oppure mcp:tools:<pluginId>.

Uno strumento MCP non può riferirsi a una route con response: "raw". Gli strumenti MCP usano il contratto di route JSON.

Corpi della richiesta

Le route senza una 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 necessita di un altro formato di corpo o di un limite di byte specifico. Le modalità disponibili sono none, json, text, bytes e form-data. I corpi della richiesta sono bufferizzati. Il massimo predefinito è 1 MiB, e una route può elevare maxBytes fino a 8 MiB al massimo.

Usa pluginRoute() per dedurre il tipo di input dalla modalità di corpo dichiarata. L’helper restituisce il suo argomento invariato a runtime:

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;

Per body: "none", routeCtx.input è il record della query string analizzato. Una dichiarazione json mantiene il tipo di input come 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 file contengono { name, kind: "file", filename, contentType, bytes }. EmDash accetta al massimo 100 parti, 1 MiB per parte e nomi file fino a 255 byte UTF-8. I nomi file non possono contenere caratteri di controllo o separatori di percorso. La richiesta codificata totale deve anche rientrare nel limite di corpo della route.

Valida i valori analizzati prima di leggere i campi o eseguire effetti collaterali. Usa safeParse quando l’input non valido è un errore atteso del chiamante. Così 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 della 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 rimane { tag: "a" }. Usa z.coerce per numeri e altri valori non stringa:

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 nell’envelope 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

Lancia quando una route sandboxed non può completarsi. EmDash registra l’eccezione e restituisce un ROUTE_ERROR. Il messaggio lanciato può essere incluso in quella risposta, quindi non mettere mai credenziali, dati personali, percorsi interni o stack trace in un messaggio di 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 del plugin sandboxed non può selezionare uno stato HTTP arbitrario lanciando una Response; una Response non attraversa il confine di ogni runner sandbox come errore strutturato. EmDash assegna gli stati agli errori di autenticazione, autorizzazione, CSRF e route mancante prima che l’handler venga eseguito. Restituisci un risultato JSON per gli esiti attesi di validazione e di dominio, e riserva le eccezioni per i fallimenti imprevisti.

Un errore atteso restituito come JSON usa comunque la risposta HTTP di successo della route e appare all’interno dell’envelope esterno { success: true, data: ... } di EmDash. Includi un codice stabile a livello applicazione così i client possono distinguere quell’esito.

Metodi HTTP

Il nome della route seleziona un 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 agnostiche rispetto al metodo per compatibilità. Controlla routeCtx.request.method all’interno di una route legacy prima di eseguire una mutazione, oppure aggiungi methods per far applicare la restrizione dall’host.

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 } o { kind: "bytes", value: Uint8Array } ed è bufferizzato 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 del documento sandboxed e Referrer-Policy: no-referrer. Applica il cacheControl della route solo alle risposte pubbliche di successo GET e HEAD. Le altre risposte usano private, no-store.

Le route raw non possono servire contenuto attivo same-origin. EmDash rifiuta HTML, JavaScript ed ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related e i tipi di media multipart/x-mixed-replace. Usa un plugin nativo o un’origine separata quando la risposta deve eseguire contenuto attivo del browser.

Accedere alla richiesta

routeCtx.request è un SandboxedRequest: un record portabile { url, method, headers } che si comporta in modo identico in-process e dentro un isolate. headers è un Record<string, string> con chiavi in minuscolo — indicizza 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 porta IP, user agent e dati geo normalizzati tra piattaforme quando disponibili.

Per una route con una dichiarazione request, solo i nomi in request.headers raggiungono l’handler. EmDash rifiuta le dichiarazioni per credenziali, cookie, header Cloudflare Access, autorizzazione proxy, Set-Cookie e l’header CSRF X-EmDash-Request. Rimuove quegli header da ogni richiesta sandboxed, incluse 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. Settings fornisce il pattern completo di caricamento, validazione, form e secret crittografati.

Le route che elencano dati del plugin dovrebbero restituire il cursor da ctx.storage.<collection>.query(). Storage pagination mostra come passare un cursor e svuotare più pagine senza superare il massimo di 100 elementi per pagina.

Proxy di API esterna

Fai da proxy a una richiesta verso 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 bufferizzata in entrambi i runner sandbox. Metodi binari come arrayBuffer() e blob() preservano i byte su Cloudflare Worker Loader e Node/workerd. I corpi di richiesta e risposta sono ciascuno limitati a 8 MiB di dati decodificati. Le destinazioni di redirect vengono controllate prima di ogni hop, e gli header di credenziali vengono rimossi quando un redirect attraversa le origini.

Chiamare le route da Block Kit

I plugin sandboxed non inviano 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 coda e schedulati

Gli handler di eventi di piattaforma (un consumer Cloudflare Queue, un handler scheduled() personalizzato) non hanno una 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 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 storage del plugin, hook e accesso ai media si comportano esattamente come durante una richiesta. Sugli adapter di database basati su connessione (es. Postgres su Hyperdrive) il callback viene eseguito sotto una connessione con scope di evento che viene committed e chiusa al ritorno.

Chiamare le route dall’esterno

Le route pubbliche sono chiamabili direttamente:

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 più X-EmDash-Request: 1, oppure un token API con lo scope admin. La seguente richiesta server-to-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": "user@example.com"}'

Riferimento del contesto di route

Le seguenti interfacce riepilogano 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
	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 singolo argomento RouteContext che combina i due — vedi Creating native plugins se stai seguendo quella strada.