このサイトは非公式の日本語訳です。Cloudflare・EmDashプロジェクトが運営するサイトではありません。

このページで分かること

  • ルートの定義方法(src/plugin.ts の既定のエクスポート、ハンドラーが受け取る2つの引数)と、ルートのURLの決まり方
  • 認証とCSRF対策(既定で認証が必要なこと、public: true にした場合の注意点)、MCPツールとしての公開、入力の検証
  • 戻り値とエラーの扱い、HTTPメソッドの確認、リクエストの参照、よく使う書き方(外部APIの中継、Block Kitやキュー・スケジュール処理からの呼び出し)
難易度
上級
読む時間
8分
このページの目次

プラグインは、管理画面や外部との連携のためにAPIルートを公開できます。ルートは /_emdash/api/plugins/<slug>/<route-name> の下に配置されます(<slug>emdash-plugin.jsoncslug フィールドで、実行時には ctx.plugin.id として参照できます)。ルートはサンドボックスのランタイムの中で、フックが受け取るのと同じ PluginContext を使って実行されます。

このページでは、サンドボックス型プラグインについて説明します。ネイティブ型プラグインも、ルートのオプション、認証、URLの構成は同じですが、ハンドラーは1つにまとめられたコンテキストオブジェクトを受け取ります。そのシグネチャーは、はじめてのネイティブ型プラグインを参照します。

ルートの定義

ルートは src/plugin.ts の既定のエクスポートで宣言します。ルートで入力を検証する場合や、ルートをMCPツールとして公開する場合は、実行時の依存パッケージとして zod を追加します。

pnpm add zod

次の例は、送信データ(submissions)の取得リクエストを検証し、プラグインのストレージを検索します。

src/plugin.ts
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 } を持ちます。inputunknown のままのため、使う前に検証します。
  • ctx は、フックの中で受け取るのと同じ PluginContext です(ctx.storagectx.kvctx.contentctx.httpctx.log など)。
本サイトの補足 やさしい解説

プラグインは、管理画面や外部との連携のためにAPIルートを追加できます。ルートは src/plugin.tsroutes に名前を付けて書き、/_emdash/api/plugins/<slug>/<ルート名> というURLで呼び出されます。ハンドラーは、リクエストの内容を表す routeCtx と、ストレージやログなどを使うための ctx の2つを受け取ります。routeCtx.input は型が決まっていない(unknown)ため、zod などで検証してから使います。

インデックス付きのコンテンツのフィールドで絞り込む

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: [...] } を、範囲の比較には gtgteltlte を使います。EmDashは、インデックスのないフィールドへの絞り込み、フィールドの型に合わない値、1回の検索で20個を超えるフィールドの絞り込みを拒否します。in の絞り込みには最大50個の値を指定できます。また、完全一致の値、範囲の境界、in の要素は、合わせて1回の検索につき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 の権限を必要とします。処理が既存のコンテンツ、メディア、スキーマ、設定の権限に属する場合は、permission に、より範囲の狭いEmDashのRBACの権限を指定します。

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

非公開のルートは、すべてのHTTPメソッドで、宣言した権限を必要とします。プラグインのルートはどのメソッドでも同じハンドラーを実行できるため、Cookieで認証したリクエストには、GETHEAD を含めて、CSRF対策の X-EmDash-Request: 1 ヘッダーも必要です。管理画面はこのヘッダーを自動で送ります。トークンで認証したリクエストはこのヘッダーが免除されますが、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.userundefined です(公開ルートは認証を省くため、訪問者がたまたま管理者のセッションを持っていても、呼び出し元は結び付けられません)。ユーザーに結び付いていないトークン(マシン用のトークン)で認証したリクエストでも undefined です。形は、ctx.users が返す UserInfo と同じ { id, email, name, role, createdAt } で、機密性の高いフィールドは含みません。

呼び出し元の身元は、users:read の権限とは別のものです。routeCtx.user誰が呼び出しているかを示し、非公開のルートでは常に使えます。一方、ctx.users はユーザーの一覧を検索するためのもので、この権限が必要です。

本サイトの補足 やさしい解説

EmDashのプラグインのルートは、既定でログイン(セッション)または admin スコープのトークンが必要です。誰でも呼び出せるようにするには public: true を指定しますが、その場合は認証がなくなるため、入力の検証と、可能であれば署名やトークンの確認をプラグイン側でします。ログインが必要なルートでは、呼び出したユーザーを routeCtx.user で確かめられます。リクエストの本文に書かれたユーザーIDを信じず、routeCtx.user を使うのが、このページのコード例の前提です。

ルートを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ツールの名前、説明、ルート、権限、destructiveフラグを確認したうえで、別途それらを有効にする必要があります。有効にしたあと、ツールを呼び出すには、ルートの権限と、mcp:tools または mcp:tools:<pluginId> のトークンスコープの両方が必要です。

入力の検証

EmDashは、POSTPUTPATCH ではJSONのリクエスト本文を解析します。GETHEADDELETE ではクエリパラメーターを解析します。解析した値は、routeCtx.input: unknown としてサンドボックス型のハンドラーに渡されます。そのため、フィールドを読んだり副作用のある処理をしたりする前に、ハンドラーの中で検証します。

不正な入力が呼び出し元の想定内のエラーである場合は、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 が1つだけなら { 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にシリアライズできる値であれば、何でも返せます。ディスパッチャーはその値を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メソッド

ハンドラーを選ぶのはルート名で、HTTPメソッドではありません。ルートが状態を変更する場合は、変更の処理をする前に、想定したメソッドだけを受け付けるようにします。

routes: {
	item: {
		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 };
				default:
					return { error: "METHOD_NOT_ALLOWED", allowed: ["GET", "DELETE"] };
			}
		},
	},
},

リクエストの参照

routeCtx.requestSandboxedRequest です。これは、プロセス内でも分離された環境(isolate)の中でも同じように動く、持ち運び可能な { url, method, headers } のレコードです。headers は、小文字にしたヘッダー名をキーとする Record<string, string> です。小文字の名前で参照するか、Object.entries で順に処理します。url は文字列のため、new URL(request.url) でクエリパラメーターを解析できます。routeCtx.requestMeta には、利用できる場合、プラットフォームの違いをそろえたIPアドレス、ユーザーエージェント、地域のデータが入っています。

handler: async (routeCtx, ctx) => {
	const { request, requestMeta } = routeCtx;

	const auth = request.headers["authorization"]; // 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.kv を使います。読み込み、検証、フォーム、秘密情報の扱いまでの一通りの書き方は、設定で説明しています。

プラグインのデータを一覧で返すルートは、ctx.storage.<collection>.query() のカーソルを返します。カーソルの渡し方と、1ページ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.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=${encodeURIComponent(parsed.data.city)}`,
				{ headers: { "X-API-Key": apiKey } },
			);

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

Block Kitからルートを呼び出す

サンドボックス型プラグインは、管理画面にReactのコードを配布しません。admin ルートを宣言し、Block Kitのレスポンスを返します。EmDashは、page_loadblock_actionform_submit のやり取りを、正しいURLとCSRFのヘッダーを付けて、その非公開のルートに送ります。やり取りの取り決めと、ルートの完全な例は、Block Kitで説明しています。

キューとスケジュールのハンドラーからルートを呼び出す

プラットフォームのイベントのハンドラー(Cloudflare Queueのコンシューマー、独自の scheduled() ハンドラー)にはHTTPリクエストがないため、locals.emdash もありません。emdash/middlewarewithEmDashRuntime() を使ってランタイムを直接取得し、リクエストなしでプラグインのルートを呼び出します。

src/worker.ts
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": "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
	taxonomies?: TaxonomyAccess;   // when taxonomies:read declared
	media?: MediaAccess;           // when media:read or media:write 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 を引数として受け取ります。ネイティブ型で作る場合は、ネイティブ型プラグインの作成を参照します。