外掛可以為其管理後台 UI 和外部整合公開 API 路由。路由掛載在 /_emdash/api/plugins/<slug>/<route-name> 下(<slug> 是 emdash-plugin.jsonc 中外掛的 slug 欄位,在執行階段以 ctx.plugin.id 的形式公開),並在沙箱執行階段內執行,使用與勾點所接收的相同的 PluginContext。
本頁介紹沙箱化外掛。原生外掛使用相同的路由選項、驗證和 URL 配置,但它們的處理函式接收的是一個合併後的內容物件。該簽章請參閱你的第一個原生外掛。
定義路由
在 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就是你在勾點內部取得的同一個PluginContext,包含ctx.storage、ctx.settings、ctx.kv、ctx.content、ctx.http和ctx.log。
篩選已建立索引的內容欄位
具有 content:read 能力的外掛可以篩選集合標記為 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>。路由名稱可以包含斜線,以表示巢狀路徑。
| 外掛 id | 路由名稱 | 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 |
驗證與 CSRF
外掛路由預設需要驗證。 分派器在呼叫你的處理函式之前,要求有工作階段(或具有 admin 範圍的權杖)。為了向後相容,私有路由預設使用 plugins:manage 權限。當該操作屬於既有的內容、媒體、結構描述或設定能力時,請把 permission 設定為更窄的 EmDash RBAC 權限:
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
私有路由對每種 HTTP 方法都要求其宣告的權限。對於以 Cookie 驗證的請求,它們還要求 X-EmDash-Request: 1 CSRF 標頭,包括 GET 和 HEAD,因為外掛路由可能對任何方法執行同一個處理函式。管理後台 UI 會自動傳送該標頭。以權杖驗證的請求無需該標頭,但仍然需要 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(它們會略過驗證,因此不會繫結呼叫者,即使訪客碰巧擁有管理員工作階段也是如此);對於權杖未繫結到某個使用者的以權杖驗證的請求(機器權杖),它同樣是 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。輸入結構描述是必要的;輸出結構描述是選用的。對於會刪除、覆寫、發佈、收費或以其他方式執行難以復原的操作的工具,請設定 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 並不能作為結構化錯誤穿過每一種沙箱執行器的邊界。對於驗證、授權、CSRF 和路由缺少這幾類失敗,EmDash 會在處理函式執行之前指派狀態碼。對於預期之內的驗證和業務結果,請傳回 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。設定提供了完整的載入、驗證、表單和加密密鑰的模式。
列出外掛資料的路由應當傳回 ctx.storage.<collection>.query() 給出的游標。儲存空間分頁展示了如何傳遞游標,並在不超過每頁最多 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 會使用正確的 URL 和 CSRF 標頭,把 page_load、block_action 和 form_submit 互動傳送到該私有路由。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();
}
});
},
};
它會解析出與請求處理函式所使用的相同的快取執行階段,因此外掛儲存空間、勾點和媒體存取的行為都與請求期間完全一致。在以連線為基礎的資料庫介接器上(例如透過 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": "[email protected]"}'
路由內容參考
下面的介面概括了沙箱化路由處理函式可用的可攜值:
// 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
}
原生外掛接收一個單一的 RouteContext 引數,它把這兩者合併在一起。如果你打算走這條路,請參閱你的第一個原生外掛。