プラグインは、管理画面 UI や外部連携のために API ルートを公開できます。ルートは /_emdash/api/plugins/<slug>/<route-name> にマウントされます(<slug> は emdash-plugin.jsonc にあるプラグインの slug フィールドで、実行時には ctx.plugin.id として公開されます)。ルートはサンドボックスランタイム内で、フックが受け取るものと同じ PluginContext を使って実行されます。
このページではサンドボックス化されたプラグインを扱います。ネイティブプラグインも同じルートオプション、認証、URL 構成を使いますが、ハンドラーは 1 つにまとめられたコンテキストオブジェクトを受け取ります。そのシグネチャについては、最初のネイティブプラグインを参照してください。
ルートを定義する
ルートは 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 の型注釈によってルートとプラグインコンテキストの型が推論されるため、パラメーターに注釈は不要です。サンドボックス化されたルートハンドラーは、2 つの引数 (routeCtx, ctx) を受け取ります。
routeCtxは、リクエストに関するデータ{ input, request, requestMeta }を保持します。inputはunknownのままなので、使用する前に検証してください。ctxは、フック内で受け取るものと同じPluginContextです。ctx.storage、ctx.settings、ctx.kv、ctx.content、ctx.http、ctx.logが含まれます。
インデックス付きコンテンツフィールドのフィルタリング
content:read Capability を持つプラグインは、コレクションが indexed とマークしたカスタムフィールドでフィルタリングできます。フィルターはデータベース内で実行され、AND の意味で組み合わされます。
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
スカラー値は完全一致で比較されます。null との一致には null を、完全一致する値の集合には { in: [...] } を、範囲比較には gt、gte、lt、lte を使用します。EmDash は、インデックスされていないフィールドに対するフィルター、フィールドの型に合わない値、そして 1 つのクエリで 20 個を超えるフィールドフィルターを拒否します。in フィルターは最大 50 個の値を受け付け、すべての完全一致の値、範囲の境界値、in のメンバーを合わせて、クエリごとに 50 オペランドの予算があります。null との一致はこの予算を消費しません。
ルートの 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 です。操作が既存のコンテンツ、メディア、スキーマ、設定の Capability に属する場合は、permission をより限定的な EmDash RBAC 権限に設定してください。
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
プライベートルートは、すべての HTTP メソッドで、宣言された権限を要求します。Cookie 認証のリクエストでは、GET や HEAD を含めて X-EmDash-Request: 1 CSRF ヘッダーも必要です。プラグインルートは、どのメソッドに対しても同じハンドラーを実行できるためです。管理画面 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 になります。形は ctx.users が返す UserInfo({ id, email, name, role, createdAt })と同じで、機密フィールドは含まれません。
呼び出し元の識別は users:read Capability とは別のものであることに注意してください。routeCtx.user は誰が呼び出しているかを示し、プライベートルートでは常に利用できます。一方 ctx.users はユーザーディレクトリの検索であり、Capability が必要です。
ルートを 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 パートあたり 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 メソッド
ルート名で 1 つのハンドラーが選ばれます。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 を追加します。ルートの cacheControl が適用されるのは、成功したパブリックな GET と HEAD のレスポンスだけです。それ以外のレスポンスは private, no-store を使用します。
生のルートは、アクティブな同一オリジンのコンテンツを配信できません。EmDash は、HTML、JavaScript と ECMAScript、XHTML、SVG、XML、CSS、WebAssembly、multipart/related、multipart/x-mixed-replace のメディアタイプを拒否します。レスポンスでアクティブなブラウザーコンテンツを実行する必要がある場合は、ネイティブプラグインか、別のオリジンを使用してください。
リクエストへのアクセス
routeCtx.request は SandboxedRequest です。これは、プロセス内でも isolate 内でも同じように動作する、ポータブルな { url, method, headers } レコードです。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() が返すカーソルを返す必要があります。ストレージのページネーションでは、カーソルを渡し、1 ページあたり最大 100 件の制限を超えずに複数ページを取り切る方法を説明しています。
外部 API プロキシ
ctx.http を通じて、外部サービスへのリクエストをプロキシします(network:request Capability と 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();
}
});
},
};
これは、リクエストハンドラーが使うのと同じキャッシュ済みのランタイムを解決します。そのため、プラグインのストレージ、フック、メディアアクセスは、リクエスト中とまったく同じように動作します。接続ベースのデータベースアダプター(たとえば 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
}
ネイティブプラグインは、この 2 つを組み合わせた 1 つの RouteContext 引数を受け取ります。そちらを選ぶ場合は、最初のネイティブプラグインを参照してください。