API 路由

本頁內容

外掛可以為管理介面與外部整合暴露 API 路由。路由掛載在 /_emdash/api/plugins/<slug>/<route-name> 下(<slug> 是 emdash-plugin.jsonc 中外掛的 slug 欄位——執行時期以 ctx.plugin.id 暴露),並在沙箱執行時期中執行,使用與 hooks 相同的 PluginContext。

本頁介紹沙箱外掛。原生外掛使用相同的路由選項、認證與 URL 配置,但其處理函式接收一個合併的上下文物件。該簽章見 Your first native plugin。

定義路由

在 src/plugin.ts 的預設匯出中宣告路由。當路由驗證輸入或作為 MCP 工具暴露時,將 zod 新增為執行時期依賴:

pnpm add zod

下列範例驗證提交請求並查詢外掛儲存:

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;

SandboxedPlugin 註解會推斷路由與外掛上下文類型,因此參數無需註解。沙箱路由處理函式接受 兩個參數:(routeCtx, ctx)。

  • routeCtx 攜帶請求形態的資料:{ input, request, requestMeta }。其 input 仍為 unknown,使用前請驗證。
  • ctx 與 hooks 中得到的 PluginContext 相同——ctx.storage、ctx.settings、ctx.kv、ctx.content、ctx.http 和 ctx.log。

過濾已索引的內容欄位

具有 content:read 能力的外掛可以過濾 collection 標記為 indexed 的自訂欄位。 過濾器在資料庫中執行,並以 AND 語意組合:

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

純量值使用精確比對。使用 null 進行空值比對,使用 { in: [...] } 比對一組精確值, 或使用 gt、gte、lt 和 lte 進行範圍比較。EmDash 會拒絕未索引欄位的過濾器、 與欄位類型不相符的值,以及每次查詢超過 20 個欄位過濾器。in 過濾器最多接受 50 個值, 且所有精確值、範圍邊界與 in 成員合計每次查詢有 50 個運算元預算。空值比對不消耗該預算。

路由 URL

路由掛載在 /_emdash/api/plugins/<slug>/<route-name>。路由名稱可包含斜線以表示巢狀路徑。

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

認證與 CSRF

外掛路由預設需要認證。 調度器在呼叫你的處理函式之前要求工作階段(或帶有 admin 作用域的權杖)。私有路由預設使用 plugins:manage 權限以保持向後相容。當操作屬於現有的內容、媒體、schema 或設定能力時,將 permission 設為更窄的 EmDash RBAC 權限:

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

私有路由對每個 HTTP 方法都要求其宣告的權限。它們還要求 cookie 認證請求攜帶 CSRF 標頭 X-EmDash-Request: 1,包括 GET 和 HEAD,因為外掛路由可以對任何方法執行同一處理函式。管理介面會自動傳送該標頭。權杖認證請求免於該標頭,但仍需要 admin 權杖作用域與路由權限。

若要將路由退出認證,將其標記為 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 };
		},
	},
},

公開路由的暴露屬於外掛經審核的存取範圍。安裝帶有公開路由的外掛需要同意。新增公開路由,或將私有路由改為公開,在更新外掛時需要再次同意。

已認證的呼叫者

在私有路由上,routeCtx.user 是發起請求的已認證使用者——由 EmDash 在你的處理函式執行前解析並授權,因此你可以信任它用於依使用者邏輯(依使用者 API 金鑰、OAuth 連線、外掛管理的偏好):

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(它們跳過認證,因此沒有繫結呼叫者——即使訪客恰好有管理工作階段),在權杖未繫結到使用者的權杖認證請求(機器權杖)上也是如此。形狀與 ctx.users 回傳的 UserInfo 一致:{ id, email, name, role, createdAt }——不含敏感欄位。

注意呼叫者身分與 users:read 能力是分開的:routeCtx.user 告訴你 誰在呼叫,在私有路由上始終可用;而 ctx.users 是需要該能力的使用者目錄 查詢。

將路由暴露為 MCP 工具

外掛可透過 EmDash 的 MCP 伺服器明確暴露選定的私有路由。MCP 暴露絕不會從路由清單推斷:

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 將其暴露為 <pluginId>__createEvent。被參照的路由必須是私有的並宣告 permission。輸入 schema 是必要的;輸出 schema 可選。對刪除、覆寫、發佈、收費或以其他方式執行難以還原的操作的工具,設定 destructive: true。

管理員必須在審閱名稱、描述、路由、權限與破壞性旗標後,單獨啟用外掛的 MCP 工具。隨後呼叫該工具需要路由權限,以及 mcp:tools 權杖作用域或 mcp:tools:<pluginId>。

MCP 工具不能參照帶有 response: "raw" 的路由。MCP 工具使用 JSON 路由慣例。

請求主體

沒有 request 宣告的路由保留原始輸入行為。EmDash 為 POST、PUT 和 PATCH 解析 JSON 請求主體, 為 GET、HEAD 和 DELETE 解析查詢參數。解析後的值以 routeCtx.input: unknown 到達沙箱處理函式。

當路由需要其他主體格式或特定位元組限制時,宣告 request.body。可用模式為 none、json、text、bytes 和 form-data。請求主體會被緩衝。 預設最大值為 1 MiB,路由可將 maxBytes 提高到最多 8 MiB。

使用 pluginRoute() 從宣告的主體模式推斷輸入類型。該輔助函式在執行時期原樣回傳其參數:

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;

對於 body: "none",routeCtx.input 是解析後的查詢字串記錄。json 宣告將輸入類型保持為 unknown,因此使用前請驗證。text 宣告產生字串,bytes 產生 Uint8Array。

form-data 接受 multipart/form-data 和 application/x-www-form-urlencoded。它產生有序的 entries 陣列。文字項目包含 { name, kind: "text", value };檔案項目包含 { name, kind: "file", filename, contentType, bytes }。EmDash 最多接受 100 個部分,每部分 1 MiB, 檔名最多 255 個 UTF-8 位元組。檔名不能包含控制字元或路徑分隔符。編碼後的請求總量也必須符合路由的主體限制。

在讀取欄位或執行副作用之前驗證解析後的值。當無效輸入是預期的呼叫者錯誤時,使用 safeParse。 這樣路由可以回傳穩定的 JSON 結果,而不是將無效輸入變成內部例外:

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

查詢字串輸入(GET/HEAD/DELETE)

無主體方法沒有請求主體,因此其輸入來自 URL 查詢字串。每個值都是字串。重複的鍵會變成陣列,因此 ?tag=a&tag=b 變成 { tag: ["a", "b"] };單個 ?tag=a 仍為 { tag: "a" }。對數字和其他非字串值使用 z.coerce:

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

JSON 回傳值

除非宣告 response: "raw",否則路由使用 JSON 回應慣例。回傳任何可 JSON 序列化的值。 調度器將其包裝在 EmDash 標準信封({ success: true, data: <your value> })中,並以 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] }

錯誤

當沙箱路由無法完成時拋出。EmDash 會記錄例外並回傳 ROUTE_ERROR。拋出的訊息可能包含在該回應中,因此切勿在例外訊息中放入憑證、個人資料、內部路徑或堆疊追蹤:

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

沙箱外掛程式碼不能透過拋出 Response 選擇任意 HTTP 狀態;Response 不會作為結構化錯誤跨越每個沙箱執行器的邊界。EmDash 在處理函式執行前為認證、授權、CSRF 與缺失路由失敗指派狀態。對預期的驗證與領域結果回傳 JSON 結果,並將例外保留給意外失敗。

作為 JSON 回傳的預期錯誤仍使用路由的成功 HTTP 回應,並出現在 EmDash 外層 { success: true, data: ... } 信封內。包含穩定的應用程式層代碼,以便用戶端區分該結果。

HTTP 方法

路由名稱選擇一個處理函式。宣告 methods 以限制哪些 HTTP 方法可以呼叫它。 當請求方法未宣告時,EmDash 在呼叫處理函式之前回傳帶有 Allow 標頭的 405 Method Not Allowed:

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

沒有 methods 的路由為相容性保持方法無關。在舊版路由中執行變更前檢查 routeCtx.request.method,或新增 methods 讓主機強制該限制。

原始回應

當路由必須回傳帶有自訂狀態與安全回應標頭的未包裝文字或位元組時,宣告 response: "raw"。 從 emdash/plugin 回傳 pluginResponse();WHATWG Response 不會跨越沙箱邊界:

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;

回應主體為 { kind: "text", value: string } 或 { kind: "bytes", value: Uint8Array },並緩衝至最多 8 MiB。原始回應可以設定 Accept-Ranges、Content-Disposition、Content-Encoding、Content-Language、Content-Range、 Content-Type、ETag、Last-Modified、Location 和 Retry-After;主機移除所有其他外掛提供的標頭。 它新增 X-Content-Type-Options: nosniff、沙箱文件內容安全原則,以及 Referrer-Policy: no-referrer。它僅對成功的公開 GET 和 HEAD 回應套用路由的 cacheControl。 其他回應使用 private, no-store。

原始路由不能提供活動的同源內容。EmDash 拒絕 HTML、JavaScript 與 ECMAScript、XHTML、SVG、XML、CSS、WebAssembly、multipart/related 以及 multipart/x-mixed-replace 媒體類型。當回應必須執行活動瀏覽器內容時,使用原生外掛或單獨的來源。

存取請求

routeCtx.request 是一個 SandboxedRequest:可攜式的 { url, method, headers } 記錄,在行程內與 isolate 中行為一致。headers 是以小寫標頭名為鍵的 Record<string, string>——用小寫名索引,或用 Object.entries 迭代。url 是字串,因此 new URL(request.url) 可解析查詢參數。可用時,routeCtx.requestMeta 攜帶跨平台正規化的 IP、使用者代理與地理資料。

對於帶有 request 宣告的路由,只有 request.headers 中的名稱會到達處理函式。EmDash 拒絕憑證、cookie、Cloudflare Access 標頭、代理授權、Set-Cookie 以及 X-EmDash-Request CSRF 標頭的宣告。 它會從每個沙箱請求(包括舊版路由)中剝離這些標頭。

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

常見模式

設定與分頁資料

外掛設定使用私有路由、Block Kit 表單和 ctx.settings。Settings 提供完整的載入、驗證、表單與加密密鑰模式。

列出外掛資料的路由應回傳 ctx.storage.<collection>.query() 的游標。Storage pagination 展示如何傳遞游標並在不超過每頁 100 項上限的情況下處理多頁。

外部 API 代理

透過 ctx.http 將請求代理到外部服務(需要 network:request 能力與 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() 在兩種沙箱執行器中都回傳緩衝的 WHATWG Response。arrayBuffer() 和 blob() 等二進位方法在 Cloudflare Worker Loader 與 Node/workerd 上保留位元組。請求與回應主體各自限制為 8 MiB 解碼資料。重新導向目標在每一跳之前檢查,當重新導向跨來源時會移除憑證標頭。

從 Block Kit 呼叫路由

沙箱外掛不會向管理介面傳送 React 程式碼。宣告一個 admin 路由並回傳 Block Kit 回應。EmDash 將 page_load、block_action 和 form_submit 互動以正確的 URL 與 CSRF 標頭傳送到該私有路由。Block Kit 展示互動慣例與完整路由。

從佇列與排程處理函式呼叫路由

平台事件處理函式(Cloudflare Queue 消費者、自訂 scheduled() 處理函式)沒有 HTTP 請求,因此沒有 locals.emdash。使用 emdash/middleware 中的 withEmDashRuntime() 直接取得執行時期,並在沒有請求的情況下呼叫外掛路由:

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

這會解析請求處理函式使用的同一快取執行時期,因此外掛儲存、hooks 與媒體存取的行為與請求期間完全一致。在基於連線的資料庫配接器上(例如透過 Hyperdrive 的 Postgres),回呼在事件範圍的連線下執行,回傳時認可並關閉。

從外部呼叫路由

公開路由可直接呼叫:

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

私有路由需要工作階段憑證加上 X-EmDash-Request: 1,或帶有 admin 作用域的 API 權杖。下列伺服器到伺服器請求使用權杖:

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"}'

路由上下文參考

下列介面彙總沙箱路由處理函式可用的可攜式值:

// 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
}

原生外掛接收一個將兩者合併的單一 RouteContext 參數——若走那條路,見 Creating native plugins。