Rutas de API

En esta página

Los plugins pueden exponer rutas de API para su UI de administración e integraciones externas. Las rutas se montan bajo /_emdash/api/plugins/<slug>/<route-name> (el <slug> es el campo slug del plugin en emdash-plugin.jsonc — expuesto en tiempo de ejecución como ctx.plugin.id) y se ejecutan dentro del runtime del sandbox con el mismo PluginContext que reciben los hooks.

Esta página cubre plugins aislados (sandboxed). Los plugins nativos usan las mismas opciones de ruta, autenticación y estructura de URL, pero sus handlers reciben un único objeto de contexto combinado. Consulte Your first native plugin para esa firma.

Definir rutas

Declare las rutas en la exportación predeterminada de src/plugin.ts. Añada zod como dependencia de runtime cuando una ruta valide la entrada o se exponga como herramienta MCP:

pnpm add zod

El siguiente ejemplo valida una solicitud de envíos y consulta el almacenamiento 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;

La anotación SandboxedPlugin infiere los tipos de contexto de ruta y de plugin, de modo que los parámetros no necesitan anotaciones. Los handlers de rutas sandboxed toman dos argumentos: (routeCtx, ctx).

  • routeCtx lleva datos con forma de solicitud: { input, request, requestMeta }. Su input permanece como unknown, así que valídelo antes de usarlo.
  • ctx es el mismo PluginContext que obtiene dentro de los hooks — ctx.storage, ctx.settings, ctx.kv, ctx.content, ctx.http y ctx.log.

Filtrar campos de contenido indexados

Los plugins con la capacidad content:read pueden filtrar campos personalizados que una colección marca como indexed. Los filtros se ejecutan en la base de datos y se combinan con semántica AND:

const result = await ctx.content.list("items", {
	where: {
		fieldFilters: {
			priority: { in: ["urgent", "high"] },
			score: { gte: 80 },
			resolved: false,
		},
	},
});

Los valores escalares usan coincidencia exacta. Use null para coincidencia nula, { in: [...] } para un conjunto de valores exactos, o gt, gte, lt y lte para comparaciones de rango. EmDash rechaza filtros para campos que no están indexados, valores que no coinciden con el tipo de campo y más de 20 filtros de campo por consulta. Un filtro in acepta como máximo 50 valores, y todos los valores exactos, límites de rango y miembros in juntos tienen un presupuesto de 50 operandos por consulta. Las coincidencias nulas no consumen ese presupuesto.

URLs de ruta

Las rutas se montan en /_emdash/api/plugins/<slug>/<route-name>. Los nombres de ruta pueden incluir barras para rutas anidadas.

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

Autenticación y CSRF

Las rutas de plugin están autenticadas por defecto. El despachador exige una sesión (o un token con el alcance admin) antes de llamar a su handler. Las rutas privadas usan por defecto el permiso plugins:manage por compatibilidad hacia atrás. Establezca permission en un permiso RBAC de EmDash más estrecho cuando la operación pertenezca a una capacidad existente de contenido, medios, esquema o ajustes:

routes: {
	create: {
		permission: "content:create",
		handler: async (routeCtx, ctx) => {
			// Validate routeCtx.input, then create content through ctx.
		},
	},
},

Las rutas privadas requieren su permiso declarado para cada método HTTP. También requieren el encabezado CSRF X-EmDash-Request: 1 para solicitudes autenticadas por cookie, incluidos GET y HEAD, porque una ruta de plugin puede ejecutar el mismo handler para cualquier método. La UI de administración envía el encabezado automáticamente. Las solicitudes autenticadas por token están exentas del encabezado pero siguen necesitando el alcance de token admin y el permiso de la ruta.

Para excluir una ruta de la autenticación, márquela 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 };
		},
	},
},

La exposición de rutas públicas forma parte del acceso revisado del plugin. Instalar un plugin con rutas públicas requiere consentimiento. Añadir una ruta pública, o cambiar una ruta privada a pública, requiere consentimiento de nuevo cuando se actualiza el plugin.

El llamante autenticado

En rutas privadas, routeCtx.user es el usuario autenticado que realiza la solicitud — resuelto y autorizado por EmDash antes de que se ejecute su handler, de modo que puede confiar en él para lógica por usuario (claves API por usuario, conexiones OAuth, preferencias gestionadas por el 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 es undefined en rutas públicas (omiten la autenticación, así que no hay llamante vinculado — incluso cuando el visitante tiene una sesión de administración) y para solicitudes autenticadas por token donde el token no está vinculado a un usuario (tokens de máquina). La forma coincide con el UserInfo devuelto por ctx.users: { id, email, name, role, createdAt } — sin campos sensibles.

Tenga en cuenta que la identidad del llamante es independiente de la capacidad users:read: routeCtx.user le dice quién está llamando y siempre está disponible en rutas privadas, mientras que ctx.users es una consulta al directorio de usuarios que requiere la capacidad.

Exponer una ruta como herramienta MCP

Los plugins pueden exponer explícitamente rutas privadas seleccionadas a través del servidor MCP de EmDash. La exposición MCP nunca se infiere de la lista de rutas:

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 expone como <pluginId>__createEvent. La ruta referenciada debe ser privada y declarar permission. Los esquemas de entrada son obligatorios; los de salida son opcionales. Establezca destructive: true para herramientas que eliminan, sobrescriben, publican, cobran o realizan de otro modo una acción difícil de revertir.

Un administrador debe habilitar por separado las herramientas MCP de un plugin tras revisar sus nombres, descripciones, rutas, permisos y flags destructivos. Llamar a la herramienta requiere entonces tanto el permiso de la ruta como el alcance de token mcp:tools o mcp:tools:<pluginId>.

Una herramienta MCP no puede referenciar una ruta con response: "raw". Las herramientas MCP usan el contrato de ruta JSON.

Cuerpos de solicitud

Las rutas sin una declaración request conservan el comportamiento de entrada original. EmDash analiza cuerpos de solicitud JSON para POST, PUT y PATCH, y parámetros de consulta para GET, HEAD y DELETE. El valor analizado llega a un handler sandboxed como routeCtx.input: unknown.

Declare request.body cuando la ruta necesite otro formato de cuerpo o un límite de bytes específico. Los modos disponibles son none, json, text, bytes y form-data. Los cuerpos de solicitud se almacenan en búfer. El máximo predeterminado es 1 MiB, y una ruta puede elevar maxBytes hasta 8 MiB como máximo.

Use pluginRoute() para inferir el tipo de entrada a partir del modo de cuerpo declarado. El helper devuelve su argumento sin cambios en tiempo de ejecución:

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;

Para body: "none", routeCtx.input es el registro de cadena de consulta analizado. Una declaración json mantiene el tipo de entrada como unknown, así que valídelo antes de usarlo. Una declaración text produce una cadena, y bytes produce un Uint8Array.

form-data acepta multipart/form-data y application/x-www-form-urlencoded. Produce un array ordenado entries. Las entradas de texto contienen { name, kind: "text", value }; las de archivo contienen { name, kind: "file", filename, contentType, bytes }. EmDash acepta como máximo 100 partes, 1 MiB por parte y nombres de archivo de hasta 255 bytes UTF-8. Los nombres de archivo no pueden contener caracteres de control ni separadores de ruta. La solicitud codificada total también debe caber en el límite de cuerpo de la ruta.

Valide los valores analizados antes de leer campos o realizar efectos secundarios. Use safeParse cuando la entrada inválida sea un error esperado del llamante. Así la ruta puede devolver un resultado JSON estable en lugar de convertir la entrada inválida en una excepción 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 };
		},
	},
},

Entrada de cadena de consulta (GET/HEAD/DELETE)

Los métodos sin cuerpo no tienen cuerpo de solicitud, así que su entrada proviene de la cadena de consulta de la URL. Cada valor es una cadena. Las claves repetidas se convierten en arrays, de modo que ?tag=a&tag=b se convierte en { tag: ["a", "b"] }; un solo ?tag=a permanece como { tag: "a" }. Use z.coerce para números y otros valores que no sean cadenas:

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;
			// ...
		},
	},
},

Valores de retorno JSON

Las rutas usan el contrato de respuesta JSON a menos que declaren response: "raw". Devuelva cualquier valor serializable a JSON. El despachador lo envuelve en el sobre estándar de EmDash ({ success: true, data: <your value> }) y lo sirve como 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] }

Errores

Lance cuando una ruta sandboxed no pueda completarse. EmDash registra la excepción y devuelve un ROUTE_ERROR. El mensaje lanzado puede incluirse en esa respuesta, así que nunca ponga credenciales, datos personales, rutas internas ni stack traces en un mensaje de excepción:

handler: async (_routeCtx, ctx) => {
	try {
		return await refreshRemoteIndex(ctx);
	} catch {
		ctx.log.error("Remote index refresh failed");
		throw new Error("Remote index refresh failed");
	}
},

El código de plugin sandboxed no puede elegir un estado HTTP arbitrario lanzando una Response; una Response no cruza el límite de cada ejecutor de sandbox como error estructurado. EmDash asigna estados a fallos de autenticación, autorización, CSRF y ruta faltante antes de que se ejecute el handler. Devuelva un resultado JSON para resultados esperados de validación y de dominio, y reserve las excepciones para fallos inesperados.

Un error esperado devuelto como JSON sigue usando la respuesta HTTP exitosa de la ruta y aparece dentro del sobre externo { success: true, data: ... } de EmDash. Incluya un código estable a nivel de aplicación para que los clientes puedan distinguir ese resultado.

Métodos HTTP

El nombre de la ruta selecciona un handler. Declare methods para restringir qué métodos HTTP pueden invocarlo. EmDash devuelve 405 Method Not Allowed con un encabezado Allow antes de llamar al handler cuando el método de la solicitud no está declarado:

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 };
			}
		},
	},
},

Las rutas sin methods siguen siendo agnósticas al método por compatibilidad. Compruebe routeCtx.request.method dentro de una ruta legacy antes de realizar una mutación, o añada methods para que el host aplique la restricción.

Respuestas en bruto

Declare response: "raw" cuando una ruta deba devolver texto o bytes sin envolver con un estado personalizado y encabezados de respuesta seguros. Devuelva pluginResponse() de emdash/plugin; una Response WHATWG no cruza el límite 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;

El cuerpo de la respuesta es { kind: "text", value: string } o { kind: "bytes", value: Uint8Array } y se almacena en búfer hasta 8 MiB. Las respuestas en bruto pueden establecer Accept-Ranges, Content-Disposition, Content-Encoding, Content-Language, Content-Range, Content-Type, ETag, Last-Modified, Location y Retry-After; el host elimina cualquier otro encabezado suministrado por el plugin. Añade X-Content-Type-Options: nosniff, una política de seguridad de contenido de documento aislado y Referrer-Policy: no-referrer. Aplica el cacheControl de la ruta solo a respuestas públicas exitosas GET y HEAD. Otras respuestas usan private, no-store.

Las rutas en bruto no pueden servir contenido activo del mismo origen. EmDash rechaza HTML, JavaScript y ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related y tipos de medios multipart/x-mixed-replace. Use un plugin nativo o un origen separado cuando la respuesta deba ejecutar contenido activo del navegador.

Acceder a la solicitud

routeCtx.request es un SandboxedRequest: un registro portable { url, method, headers } que se comporta de forma idéntica en proceso y dentro de un isolate. headers es un Record<string, string> con claves en minúsculas — indexe por el nombre en minúsculas, o itere con Object.entries. url es una cadena, así que new URL(request.url) analiza los parámetros de consulta. routeCtx.requestMeta lleva IP, user agent y datos geo normalizados entre plataformas cuando están disponibles.

Para una ruta con una declaración request, solo los nombres en request.headers llegan al handler. EmDash rechaza declaraciones de credenciales, cookies, encabezados de Cloudflare Access, autorización de proxy, Set-Cookie y el encabezado CSRF X-EmDash-Request. Elimina esos encabezados de cada solicitud sandboxed, incluidas las rutas 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" };
},

Patrones comunes

Ajustes y datos paginados

Los ajustes del plugin usan rutas privadas, formularios Block Kit y ctx.settings. Settings proporciona el patrón completo de carga, validación, formulario y secretos cifrados.

Las rutas que listan datos del plugin deben devolver el cursor de ctx.storage.<collection>.query(). Storage pagination muestra cómo pasar un cursor y agotar varias páginas sin superar el máximo de 100 elementos por página.

Proxy de API externa

Proxy de una solicitud a un servicio externo a través de ctx.http (requiere la capacidad network:request y una entrada en 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() devuelve una Response WHATWG en búfer en ambos ejecutores de sandbox. Métodos binarios como arrayBuffer() y blob() conservan los bytes en Cloudflare Worker Loader y Node/workerd. Los cuerpos de solicitud y respuesta están limitados cada uno a 8 MiB de datos decodificados. Los destinos de redirección se comprueban antes de cada salto, y los encabezados de credenciales se eliminan cuando una redirección cruza orígenes.

Llamar rutas desde Block Kit

Los plugins sandboxed no envían código React a la administración. Declare una ruta admin y devuelva respuestas Block Kit. EmDash envía las interacciones page_load, block_action y form_submit a esa ruta privada con la URL y el encabezado CSRF correctos. Block Kit muestra el contrato de interacción y una ruta completa.

Llamar rutas desde handlers de cola y programados

Los handlers de eventos de plataforma (un consumidor de Cloudflare Queue, un handler scheduled() personalizado) no tienen solicitud HTTP y por tanto no tienen locals.emdash. Use withEmDashRuntime() de emdash/middleware para obtener el runtime directamente e invocar una ruta de plugin sin una solicitud:

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();
			}
		});
	},
};

Esto resuelve el mismo runtime en caché que usan los handlers de solicitud, de modo que el almacenamiento del plugin, los hooks y el acceso a medios se comportan exactamente como durante una solicitud. En adaptadores de base de datos basados en conexión (p. ej. Postgres sobre Hyperdrive) el callback se ejecuta bajo una conexión con ámbito de evento que se confirma y cierra cuando retorna.

Llamar rutas externamente

Las rutas públicas se pueden llamar directamente:

curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
  -H "Content-Type: application/json" \
  -d '{"event": "pageview"}'

Las rutas privadas necesitan credenciales de sesión más X-EmDash-Request: 1, o un token de API con el alcance admin. La siguiente solicitud de servidor a servidor 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"}'

Referencia del contexto de ruta

Las siguientes interfaces resumen los valores portables disponibles para un handler de ruta 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
}

Los plugins nativos reciben un único argumento RouteContext que combina ambos — consulte Creating native plugins si va por ese camino.