2026年9月28日、Cloudflare製のAstroネイティブCMS「EmDash」の1.0が正式リリースされました。その後10月2日に1.1、10月7日に1.2と、2週間足らずで立て続けにアップデートが出ています。
社内ブログ「ゆるろぐ」を0.22から1.2に上げた話はこちらの記事に書きました。今回は、1.0でいちばん大きく変わった「プラグインの仕組み」を整理してみます。
EmDashがどういうCMSかは最初の記事を見てもらえれば。
ざっくり「WordPressをAstro + Cloudflareで作り直したもの」です。
1.0でプラグインはどう変わったのか
0.x時代のプラグインは簡単に言うと「Astroの設定ファイルに書くnpmパッケージ」だったので、サイトと同じプロセスで動くためプラグインはサイトのコードと同じ権限を持っており、WordPressと同じようにデータベースにも環境変数にもアクセスできてしまいました。
1.0ではここにサンドボックスと分散型レジストリが入りました。
ここがとても多きな改善点です。
- プラグインは既定で隔離された環境で動き、サイトのコンテンツ・メディア・ユーザー・シークレット・ネットワークのどれにもアクセスできない。
- 必要な権限(Capability)はプラグイン側が宣言しサイト管理者がインストール時に承認する。
- 承認した権限は「書いてあるだけ」ではなくランタイムが実際に強制する。
- プラグインは管理画面からワンクリックで入れられる。配布元はAT Protocol上の分散型レジストリ
Cloudflareのブログでは、これをWordPressの「全プラグインが同じプロセスを共有する」モデルとの対比で説明しています。
WordPressのプラグイン起因のインシデントを知っている人ほど刺さる話だと思います。
プラグインには2つの「動かし方」がある
まず押さえておきたいのが、同じプラグインコードにTrusted(信頼済み)とSandboxed(サンドボックス)の2つの実行モードがあることです。
Trusted | Sandboxed | |
|---|---|---|
登録場所 |
|
|
実行環境 | サイトと同じプロセス | 隔離されたV8 isolate |
Capability | 宣言のみ(強制されない) | ランタイムが強制 |
ネットワーク | 制限なし | 遮断。 |
データアクセス | DBフルアクセス | 自分のstorage / KVと、承認された範囲のみ |
Node.js API | 使える | 使えない(Web APIのみ) |
リソース上限 | なし | CPU 50ms、サブリクエスト10、30秒、約128MB |
向いている用途 | 自社コード、レビュー済みのnpmパッケージ | サードパーティ、レジストリ配布 |
「隔離」の中身は、プラグインごとに専用の小さな実行環境を用意して、サイト本体とは別に動かすことです。
Cloudflare Workers上では、Dynamic Workersという仕組みでプラグイン用のWorkerをリクエスト時に起動します。
1.0からはNode.jsで動かす場合もWorkersと同じランタイム(workerd)を別プロセスで立ち上げてプラグインを載せるようになったので、「サンドボックスはCloudflare限定」ではなくなりました。
ポイントはプラグインのコードは両モードで同一ということでありローカルではTrustedで開発して本番ではサンドボックスに入れるという運用が普通にできます。
プラグインの構造:ビルド時と実行時でファイルを分ける
EmDashのプラグインは実行されるタイミングが違う2つの部分に分かれています。
- descriptor(記述子):
id・version・必要なCapability・storage定義などのメタデータで、astro.config.mjsからimportされるのでビルド時にViteの中で動きます。副作用を持ってはいけません - definition(定義):hooksやroutesの中身。リクエスト時にサーバーで動きます
my-plugin/
├── src/
│ ├── index.ts # descriptor。astro.config.mjs から import される
│ └── sandbox-entry.ts # definePlugin({ hooks, routes })。実行時にロードされる
└── package.json # "." と "./sandbox" を exports で分ける
// src/index.ts
import type { PluginDescriptor } from "emdash";
export function myPlugin(): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com"],
options: {},
};
}
// src/sandbox-entry.ts
import { definePlugin, type PluginContext } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx: PluginContext) => {
ctx.log.info(`saved ${event.collection}/${event.content.id}`);
},
},
});
descriptor側にentrypointでdefinitionのモジュールパスを書き、EmDashが実行時にそれをロードします。同じファイルにまとめたくなりますが、Viteのビルドに実行時コードが巻き込まれるので分けるのが正解です。
StandardとNative
もう一つの軸として、プラグインにはStandardとNativeの2フォーマットがあります。
- Standard:上の構造のもの。管理画面UIは後述のBlock Kit(JSON)で記述。TrustedでもSandboxedでも動き、レジストリに公開できる
- Native:React製の管理画面、Portable Text(本文エディタ)のカスタムブロック、サイト側描画用のAstroコンポーネントを同梱できる。その代わりTrustedでしか動かず、レジストリにも出せない
基本はStandardで書いて、「エディタに独自ブロックを足したい」「サイトのHTMLにコンポーネントを差し込みたい」ときだけNative、という使い分けです。
ゆるろぐではAmazon商品カードと動画埋め込みをNativeプラグインで自作しています。どちらもエディタに専用ブロックを追加してAstroコンポーネントで描画するもので、これはサンドボックスでは原理的にできません(実行時にインストールされるプラグインは、ビルド時に必要なAstroコンポーネントを持ち込めないため)。
Capability:権限の語彙
サンドボックスの核になるのがCapabilityです。プラグインが「何にアクセスしたいか」を文字列で宣言し、管理者がインストール時のダイアログで確認・承認します。
1.xでは語彙が**<対象>:<操作>の形に整理**されました。よく使うものを挙げるとこうです。
Capability | できること |
|---|---|
| 記事の読み取り / 作成・更新・削除 |
| 公開・非公開・予約 |
| メディアの読み取り / アップロードと削除 |
| 外部へのHTTPリクエスト。 |
| 編集中の未保存内容を読む / 修正を提案する |
| 内容を審査して公開をブロックする |
ほかにタクソノミー、コメント、リダイレクト、ユーザー、メール送信など、全部で27種類あります。
storageとKVは宣言不要で常に使えます。プラグインごとにスコープされていて、他のプラグインのデータは見えません。
0.xの名前(read:content、network:fetchなど)は非推奨になりましたが、エイリアスとして今も通ります。ランタイムが現行名に正規化してくれるので既存プラグインはそのまま動き、バンドル時に警告が出る、という扱いです。
Hooks:何に反応できるか
1.2.0のランタイムに定義されているフックは30種類あります。コンテンツの保存・公開・予約・削除、メディアのアップロード、メールの送信、コメントの投稿と承認、プラグイン自身のインストールと有効化、それにcronによる定期実行です。0.xからかなり増えていて、特に公開前後(content:beforePublish / afterPublish)が別のフックになったのが大きいです。
content:beforeSaveでthrowすれば保存をキャンセルできるので、バリデーション系のプラグインはここに書きます。公開そのものを止めたい場合は、hooks.content-policy:registerを宣言したうえで公開前のフックを使います。
地味に重要なのが1.2の変更で、アップグレード後の初回起動時に、既存プラグインのplugin:installとplugin:activateが再実行されるようになりました。installフックで初期データを投入しているプラグインは、二重投入しても壊れないように書いておく必要があります。
Block Kit:JSを動かさない管理画面
サンドボックスでは、プラグインのJavaScriptをブラウザで動かせません。そこで管理画面はBlock KitというJSONで記述します。プラグインは「ヘッダー、テーブル、フォーム、トグル」といったブロックの配列を返し、EmDash側がそれを描画します。
流れはこうです。ユーザーがプラグインのページを開くと、EmDashからプラグインに「ページが開かれた」というリクエストが届き、プラグインはブロックの配列を返します。ユーザーがフォームを送信すると、今度は入力値を伴ったリクエストが届き、プラグインはそれを保存して新しいブロックの配列を返します。リクエスト・レスポンスだけでUIが成立するので、サンドボックスの制約と相性がいい設計です。
管理画面のページ、ダッシュボードのウィジェットに加えて、1.xではエディタ横のパネル(editorPanels)もBlock Kitで作れるようになりました。記事ごとの状態やアクションを編集画面に出す用途向けです。たとえば翻訳の状態表示、SEOや文章のチェック、リンク切れの検査、本文からの抜粋生成など、「今書いている記事」を対象にした機能がここに収まります。
分散型レジストリ:中央マーケットプレイスを持たない設計
プラグインの配布の仕組みも1.0で変わりました。使う側から見えるのは「管理画面から探して、ワンクリックで入れられる」ことだけですが、裏側はBlueskyの基盤であるAT Protocolの上に作られていて、少し変わった構造をしています。
- プラグインの情報は作者のアカウントにある。 作者はBluesky系のアカウントでログインし、パッケージとリリースの情報に署名して、自分のアカウントのデータ領域に置く。レジストリ運営者のサーバーに預けるのではない
- Cloudflareのレジストリは「カタログの一つ」にすぎない。
plugins.emdashcms.comは各作者が公開した情報を集めて検索できるようにしているだけで、同じ情報を集めて別のカタログを作ることは誰でもできる - インストール時に中身を検証する。 EmDashはダウンロードしたプラグインが「作者が署名して公開したものと同じ中身か」を確かめる。ファイルのハッシュ、パッケージ名とバージョン、要求しているCapabilityが署名された情報と一致するか、さらにGitHub Actionsでビルドされたものならそのビルド記録も検証する。カタログ側が差し替えたものや改ざんされたものは、ここで弾かれる
- モデレーションは「隠す」だけ。 Cloudflareのカタログは、AIモデル(Clef)で説明文・リンク・画像を審査し、問題があればカタログに表示しない。ただしプラグインの中身を変えたり、作者から所有権を取り上げたりはできない
つまり「レジストリ運営者が消えても、作者のアカウントに情報が残っていれば配布は続く」「運営者はカタログから隠せるが、改ざんはできない」というモデルです。
npmやWordPress.orgのような中央マーケットプレイスを持たない前提で設計されているのが特徴です。
現時点では無料プラグインのみで、有料プラグインはAT Protocol側の進展を見ながら同じ分散モデルで対応する方針だそうです。
翻訳の仕組みはプラグインにできそう
ゆるろぐでは、日本語記事からLLMで英語版の下書きを作る仕組みを動かしています(EmDashで多言語ブログを作った話)。今はNodeのスクリプトで、EmDashのAPIトークンと翻訳APIのキーを.envから読み、日本語記事を取ってきて翻訳し、translationOfを付けた英語の下書きを作る、という流れです。
これを1.xのプラグイン仕様に当てはめると、ほぼそのままStandardプラグインになります。
- トリガー:
content:afterPublishフック。日本語記事が公開されたら動く - 記事の読み書き:
content:readとcontent:write。翻訳結果は下書きとして作る - 翻訳APIの呼び出し:
network:requestと、allowedHostsにAPIのホストを1つ - APIキー:
settingsSchemaのsecretフィールド。管理画面から入れて暗号化保存。.envもAPIトークンの発行も要らなくなる - 翻訳状態:storageに「元記事のどのリビジョンを翻訳したか」を持たせて、
editorPanelsで記事編集画面に「英語版あり / 元記事が更新されたので要再翻訳」を出す
スクリプトを手で実行していたものが、管理者がCapabilityを承認するだけで動くようになります。しかも翻訳API以外のホストには出ていけないことをランタイムが保証してくれる。「APIキーを扱うコードをどこに置くか」という悩みが構造的に消えるのが、この仕組みのいいところだと思います。
設計で気をつける点が一つ。サンドボックスのプラグインがホストに何かを頼む(設定を読む、記事を取る、storageに書く)たびに、それが1回のサブリクエストとして数えられ、既定の上限は10です。1記事を翻訳するだけなら収まりそうですが、「未翻訳の記事をまとめて」のような処理は、ループの中でctx.content.get()を回すとすぐ上限に当たります。cronフックで少しずつ処理する、といった設計が要りそうです。
あと、言語切替のようなサイト側のUIはサンドボックスからは出せないので、そこはテーマ側に書くことになります。
まとめ
- EmDash 1.0のプラグインはサンドボックス前提。Capabilityを宣言し、管理者が承認し、ランタイムが強制する
- 同じコードがTrusted / Sandboxedの両方で動く。開発はTrusted、本番はサンドボックス、が基本
- エディタのカスタムブロックやAstroコンポーネントが必要ならNative(Trusted限定)
- descriptor(ビルド時)とdefinition(実行時)は必ず別ファイルに
- Capabilityの名前は1.xで
content:read形式に変わった。旧名はエイリアスで動くが非推奨 - レジストリはAT Protocol上の分散型。カタログは隠せるが改ざんできない
- サンドボックスでは「ホスト呼び出し1回 = サブリクエスト1回」で上限10。回数を意識して設計する
WordPressではプラグインを一つ入れるたびに「このプラグインはサイトの全権限を持つ」ことを受け入れることになります。
脆弱性のあるプラグインが一つあればサイト全体が危険にさらされます。
EmDash 1.0はサンドボックス型のプラグインについてはプラグインが使える権限をインストール時に見せて、承認した範囲だけをランタイムが許す形になりました。
WordPressからの移行先を探している人にとってプラグイン周りの安全性はEmDashを選ぶ理由の一つになると思います。
プラグイン自体はまだ少なく、WordPressのように「探せば何かある」状態には遠いですが、ここからプラグインが増えていくのを期待したいですね。
既に運用実績も自社サイトでできたため、弊社のサービスに加えようと思います。
DTPからWebの世界へ飛び込み、気づけばマークアップもフロントエンドもディレクションもアクセシビリティもこなす"技の仙人"。リベロジック創業期からマルチに活躍し、今や社内の生き字引的存在。最近は「アクセシビリティ対応、もっとAIに頼れないかな?」と、プロンプトを駆使した効率化の探究にハマり中。技術も思考も、まだまだ進化中
二俣 歩
IAAP認定ウェブアクセシビリティスペシャリスト(WAS) / マークアップエンジニア / フロントエンドエンジニア / ウェブディレクター