最初のサンドボックスプラグイン

このページ

このガイドでは、最小限のサンドボックスプラグインをゼロから構築する方法を説明します。このプラグインは、すべてのコンテンツ保存をログに記録し、単一の API ルートを公開します。完了すると、設定されたサンドボックスランナーを介して分離されたランタイムで実行されるプラグインが完成します。サイト運営者が sandboxed: [] から plugins: [] に移動することを選択した場合、同じプラグインコードをインプロセスで実行することもできます。たとえば、サンドボックスランナーが利用できないプラットフォームなどです。

サンドボックスプラグインとネイティブプラグインのどちらが必要かまだ決めていない場合は、まずプラグイン形式の選択をお読みください。

2つのファイル

すべてのサンドボックスプラグインは2つの部分で構成されます:

  1. 記述子 — プラグインを説明する小さなオブジェクト(id、バージョン、機能、ストレージ、ランタイムエントリの場所)。ビルド時に astro.config.mjs によってインポートされます。
  2. サンドボックスエントリ — ランタイムコード:フック、ルート、ストレージアクセス。リクエスト時にサンドボックスランタイムにロードされます。

この2つのファイルは同じパッケージ内にありますが、まったく異なる環境で実行されます。記述子はランタイムコンテキストを見ることができません。エントリは astro.config.mjs を見ることができません。

my-plugin/
├── src/
│   ├── index.ts          # 記述子 — ビルド時の Vite で実行
│   └── sandbox-entry.ts  # フック、ルート、ストレージ — サンドボックスランタイムで実行
├── package.json
└── tsconfig.json

パッケージのセットアップ

  1. 新しいディレクトリを作成し、TypeScript ES モジュールパッケージとして初期化します。

    {
    	"name": "@my-org/plugin-hello",
    	"version": "0.1.0",
    	"type": "module",
    	"main": "dist/index.mjs",
    	"exports": {
    		".": {
    			"import": "./dist/index.mjs",
    			"types": "./dist/index.d.mts"
    		},
    		"./sandbox": "./dist/sandbox-entry.mjs"
    	},
    	"files": ["dist"],
    	"scripts": {
    		"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean"
    	},
    	"peerDependencies": {
    		"emdash": "*"
    	},
    	"devDependencies": {
    		"emdash": "*",
    		"tsdown": "^0.6.0",
    		"typescript": "^5.5.0"
    	}
    }

    "./sandbox" エクスポートは、記述子の entrypoint が指すものです。バンドラーは両方のファイルを dist/ にビルドします。

  2. tsconfig.json を追加します:

    {
    	"compilerOptions": {
    		"target": "ES2022",
    		"module": "preserve",
    		"moduleResolution": "bundler",
    		"strict": true,
    		"esModuleInterop": true,
    		"declaration": true,
    		"outDir": "./dist",
    		"rootDir": "./src"
    	},
    	"include": ["src/**/*"],
    	"exclude": ["node_modules", "dist"]
    }

記述子を書く

記述子は PluginDescriptor を返すファクトリー関数です。ビルド時の Vite で実行されるため、副作用がなく、ランタイム API(fetch、データベース、環境変数など、まだ存在しないもの)を使用できません。

import type { PluginDescriptor } from "emdash";

export function helloPlugin(): PluginDescriptor {
	return {
		id: "plugin-hello",
		version: "0.1.0",
		format: "standard",
		entrypoint: "@my-org/plugin-hello/sandbox",

		capabilities: [],
		storage: {
			events: { indexes: ["timestamp"] },
		},
	};
}

重要な詳細:

  • format: "standard" は必須です。 これがないと、EmDash はパッケージをネイティブプラグインとして扱い、異なる形状を探します。format フィールドのデフォルトは "native" です。
  • entrypoint はモジュール指定子です、ファイルパスではありません。import に渡すのと同じ文字列を使用します — 通常は "<package-name>/sandbox" です。パッケージ名はスコープ付き(@my-org/plugin-hello)にできますが、プラグイン id はできません。
  • id は URL セーフなスラグです、npm パッケージ名ではありません。 /^[a-z][a-z0-9_-]*$/ に一致する必要があります — 小文字で始まり、その後に文字、数字、ハイフン、またはアンダースコアが続きます。id は、プラグインルート URL の単一のパスセグメント(/_emdash/api/plugins/<id>/...)として、またプラグインストレージインデックス用に生成される SQL 識別子の一部として使用されるため、@/、先頭の数字、大文字はすべて実行時に失敗します。plugin-hello のようなスコープなしの id を、entrypoint のスコープ付き npm パッケージ名とペアにします。
  • 機能、allowedHosts、ストレージは記述子にあります。 サンドボックスエントリはそれらを宣言しません — 記述子が許可するものだけを使用できます。
  • ここにランタイムロジックを入れないでください。 トップレベルの await なし、モジュールレベルの fetch なし、ファイルの読み取りなし。記述子はメタデータです。

サンドボックスエントリを書く

ランタイム側。このファイルは、リクエスト時にサンドボックスランタイムにロードされ、ctx が提供するもの以外にはアクセスできません。

import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

interface ContentSaveEvent {
	collection: string;
	content: { id: string };
	isNew: boolean;
}

export default definePlugin({
	hooks: {
		"content:afterSave": {
			handler: async (event: ContentSaveEvent, ctx: PluginContext) => {
				ctx.log.info("Content saved", {
					collection: event.collection,
					id: event.content.id,
				});

				await ctx.storage.events.put(`save-${Date.now()}`, {
					timestamp: new Date().toISOString(),
					collection: event.collection,
					contentId: event.content.id,
				});
			},
		},
	},

	routes: {
		recent: {
			handler: async (_routeCtx, ctx: PluginContext) => {
				const result = await ctx.storage.events.query({ limit: 10 });
				return { events: result.items };
			},
		},
	},
});

知っておくべきこと:

  • サンドボックスエントリの definePlugin(){ hooks, routes } のみを受け取ります。 idversioncapabilities はありません — これらは記述子から来ます。ここでそれらを渡そうとすると、EmDash はビルド時にスローします。
  • フックハンドラーは (event, ctx) を受け取ります。 イベントの形状はフック名によって異なります。フックリファレンスを参照してください。
  • ルートハンドラーは (routeCtx, ctx) を受け取ります — 2つの引数。routeCtx には { input, request, requestMeta } があります。ctx はフックで取得するのと同じ PluginContext です。ルートは /_emdash/api/plugins/<plugin-id>/<route-name> で到達可能です。
  • ctx.storage.events が機能するのは、記述子で events が宣言されているためです。 宣言されていないコレクションにアクセスするとスローします。
  • ctx.kv は常に利用可能ですgetsetdeletelist(prefix) を持つプラグインごとのキーバリューストア。

プラグインを登録する

サイトの astro.config.mjs で、記述子ファクトリーをインポートし、EmDash 統合に渡します。サンドボックスプラグインは sandboxed: [] に入れます。インプロセスプラグインは plugins: [] に入れます。標準形式のプラグインは両方で機能します — sandboxed から始めます。

import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sandbox } from "@emdash-cms/cloudflare";
import { helloPlugin } from "@my-org/plugin-hello";

export default defineConfig({
	integrations: [
		emdash({
			sandboxed: [helloPlugin()],
			sandboxRunner: sandbox(),
		}),
	],
});

sandboxRunner はプラガブルな部分です。上記の例では、@emdash-cms/cloudflaresandbox() を使用しています。これは今日ほとんどのサイトで使用されているサンドボックスランナーです。他のプラットフォーム用のランナーは開発中です。ランナーが設定されていない場合(または設定されたランナーが現在のプラットフォームで利用不可と報告する場合)、sandboxed: [] プラグインは起動時にスキップされます — 同じプラグインをインプロセスで実行するには、sandboxed: [] から plugins: [] に移動します。

ビルドと実行

プラグインディレクトリから:

pnpm build

サイトディレクトリで、プラグインをリンクまたはインストールし(pnpm add @my-org/plugin-hello またはワークスペースリンク)、開発サーバーを起動します。次回管理画面でコンテンツを保存すると、ログに [hello] Content saved … が表示され、GET /_emdash/api/plugins/plugin-hello/recent は最後の10個の保存イベントを返すはずです。

次に読むもの