Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

6.2 KiB

API Routes

Plugin routes work in both standard and native plugins, and in both trusted and sandboxed modes. Sandboxed plugin routes are invoked via the sandbox runner's invokeRoute() RPC.

Plugin routes expose REST endpoints at /_emdash/api/plugins/<plugin-id>/<route-name>.

Defining Routes

import { definePlugin } from "emdash";
import { z } from "astro/zod";

definePlugin({
	id: "forms",
	version: "1.0.0",

	routes: {
		// Simple route
		status: {
			handler: async (ctx) => {
				return { ok: true };
			},
		},

		// Route with input validation
		submissions: {
			input: z.object({
				formId: z.string().optional(),
				limit: z.number().default(50),
				cursor: z.string().optional(),
			}),
			handler: async (ctx) => {
				const { formId, limit, cursor } = ctx.input;
				const result = await ctx.storage.submissions!.query({
					where: formId ? { formId } : undefined,
					orderBy: { createdAt: "desc" },
					limit,
					cursor,
				});
				return {
					items: result.items,
					cursor: result.cursor,
					hasMore: result.hasMore,
				};
			},
		},

		// Nested path
		"settings/save": {
			input: z.object({
				enabled: z.boolean().optional(),
				apiKey: z.string().optional(),
			}),
			handler: async (ctx) => {
				for (const [key, value] of Object.entries(ctx.input)) {
					if (value !== undefined) {
						await ctx.kv.set(`settings:${key}`, value);
					}
				}
				return { success: true };
			},
		},
	},
});

Route URLs

Plugin ID Route Name URL
forms status /_emdash/api/plugins/forms/status
forms submissions /_emdash/api/plugins/forms/submissions
seo settings/save /_emdash/api/plugins/seo/settings/save

Handler Context

interface RouteContext<TInput = unknown> extends PluginContext {
	input: TInput; // Validated input
	request: Request; // Original request
	plugin: { id: string; version: string };
	storage: Record<string, StorageCollection>;
	kv: KVAccess;
	content?: ContentAccess; // If capability declared
	media?: MediaAccess;
	http?: HttpAccess;
	log: LogAccess;
}

Input Validation

Use Zod schemas. Invalid input returns 400.

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 (ctx) => {
			// ctx.input is typed and validated
			const { title, email, priority } = ctx.input;
			// ...
		},
	},
}

Input sources:

  • POST/PUT/PATCH — Request body (JSON)
  • GET/DELETE — URL query parameters

Return Values

Return any JSON-serializable value. Response is always Content-Type: application/json.

return { success: true, data: items }; // Object
return items; // Array
return 42; // Primitive

Errors

Throw to return error response:

throw new Error("Item not found"); // 500 with { error: "Item not found" }

// Custom status code
throw new Response(JSON.stringify({ error: "Not found" }), {
	status: 404,
	headers: { "Content-Type": "application/json" },
});

HTTP Methods

Routes respond to all methods. Check ctx.request.method:

handler: async (ctx) => {
	switch (ctx.request.method) {
		case "GET":
			return await ctx.storage.items!.get(ctx.input.id);
		case "DELETE":
			await ctx.storage.items!.delete(ctx.input.id);
			return { deleted: true };
		default:
			throw new Response("Method not allowed", { status: 405 });
	}
};

Common Patterns

Settings CRUD

routes: {
	settings: {
		handler: async (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": {
		handler: async (ctx) => {
			// EmDash parses the request body once and exposes it as ctx.input.
			// Don't call ctx.request.json() — the body stream is already consumed.
			const input = ctx.input as Record<string, unknown>;
			for (const [key, value] of Object.entries(input)) {
				if (value !== undefined) await ctx.kv.set(`settings:${key}`, value);
			}
			return { success: true };
		},
	},
}

Paginated List

routes: {
	list: {
		input: z.object({
			limit: z.number().min(1).max(100).default(50),
			cursor: z.string().optional(),
			status: z.string().optional(),
		}),
		handler: async (ctx) => {
			const { limit, cursor, status } = ctx.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,
			};
		},
	},
}

External API Proxy

Requires network:request capability and allowedHosts:

definePlugin({
	capabilities: ["network:request"],
	allowedHosts: ["api.weather.example.com"],

	routes: {
		forecast: {
			input: z.object({ city: z.string() }),
			handler: async (ctx) => {
				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=${ctx.input.city}`,
					{ headers: { "X-API-Key": apiKey } },
				);

				if (!response.ok) throw new Error(`API error: ${response.status}`);
				return response.json();
			},
		},
	},
});

Calling from Admin UI

import { usePluginAPI } from "@emdash-cms/admin";

const api = usePluginAPI();
const data = await api.get("status");
await api.post("settings/save", { enabled: true });

Calling Externally

curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10

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

Plugin routes don't have built-in auth. Admin-only routes are protected by the admin session middleware.