プラグイン開発
@crowi/plugin-api を使って独自プラグインを作る
このページでは、@crowi/plugin-api を使って独自の Crowi プラグインを
作る手順を説明します。プラグインの仕組み全体は
プラグインアーキテクチャ を、設計の経緯は
RFC-0001
を参照してください。
注意: Crowi v2 は alpha 開発中であり、
@crowi/plugin-apiの バージョンは現時点で0.xです。2.xに到達して以降の API 安定保証 (v2 系のすべてのマイナーで動作) はその時点から有効になります。alpha 期間中は契約が変わる可能性があります。
プラグインの正体
Crowi プラグインは特別な形式ではなく、通常の npm パッケージ です。 要件はおおむね次の 2 点だけです。
@crowi/plugin-apiに (peer) 依存するCrowiPlugin型を満たすオブジェクトを デフォルトエクスポート する
ランタイムは起動時に await import('<plugin-name>') でこのデフォルト
エクスポートを読み込み、各 register* コールバックを呼び出します。
最小プラグインの骨子
次は、ストレージドライバを 1 つだけ提供する最小構成のプラグイン例です。
@crowi/plugin-api は型のみのパッケージなので、import するのは型だけです。
import type { CrowiPlugin } from '@crowi/plugin-api';
const myPlugin: CrowiPlugin = {
name: '@example/crowi-plugin-mystorage',
version: '0.1.0',
registerStorage: (registry, ctx) => {
ctx.log.info('registering mystorage driver');
registry.register('mystorage', {
put: async (key, body, meta) => {
// ... アップロード処理
return { key };
},
get: async (key) => {
// ... ダウンロード処理
throw new Error('not implemented');
},
delete: async (key) => {
// ... 削除処理
},
});
},
};
export default myPlugin;name は npm パッケージ名と一致させる必要があります。これは設定行
(plugin:<name>:*) とページメタデータの名前空間も兼ねます。
設定スキーマを足す
プラグインに設定値を持たせるには、configSchema に Zod スキーマを
宣言します。管理画面 /admin/plugins がこのスキーマを辿って設定フォーム
を自動生成します。
注意:
configSchemaはzodパッケージのトップレベル (v4 ネイティブ) API ではなく、必ずimport { z } from 'zod/v3'の互換サブパスから import してください。@crowi/plugin-apiのpeerDependenciesがzod: "^4"を要求するのは npm パッケージとしての話で、その v4 パッケージが同梱するzod/v3互換 shim の内部表現に設定スキーマの introspection (@sensitive/@actionマーカー検出、管理フォームの フィールド判定) が依存しているためです。トップレベルzod(v4) で 書いたスキーマは boot 時に明示的なエラーで起動が失敗します (詳細は@crowi/plugin-apiの README を参照)。
import { z } from 'zod/v3';
import type { CrowiPlugin } from '@crowi/plugin-api';
const myPlugin: CrowiPlugin = {
name: '@example/crowi-plugin-mystorage',
version: '0.1.0',
configSchema: z.object({
endpoint: z.string().url().describe('ストレージのエンドポイント URL'),
bucket: z.string().describe('バケット名'),
accessKey: z.string().describe('@sensitive アクセスキー'),
}),
registerStorage: (registry, ctx) => {
const config = ctx.config<{ endpoint: string; bucket: string; accessKey: string }>();
// config.accessKey は復号済みの値
// ... config を使ってドライバを構築
},
};
export default myPlugin;スキーマフィールドの説明文に付ける 2 つのマーカーが、管理 UI の挙動を 変えます。
| マーカー | 書式 | 効果 |
|---|---|---|
@sensitive | z.string().describe('@sensitive ...') | 保存時に自動暗号化。シークレット入力欄で描画。 |
@action | z.string().describe('@action "Test" POST /test') | フィールド横にアクションボタンを描画。registerRoutes のエンドポイントを呼ぶ。 |
サイドバーへの配置 (adminPlacement)
プラグインの設定リンクは管理サイドバーに表示されます。配置先は登録した
拡張フックから導出されます — registerStorage → ストレージ、
registerSearch → 検索、registerRenderer → レンダラー、
registerNotifier → 通知、registerMailSender → メール。フックを持たない
config のみの基盤プラグインは、「共通サービス」に出すために
adminPlacement: { section: 'shared' } を宣言します。導出された配置を
上書きしたり、ラベル / アイコンを明示することもできます。
adminPlacement: {
section: 'renderer', // settings | shared | storage | mail | notification | auth | search | renderer
label: 'PlantUML diagrams',
icon: 'diagram-3',
},サイドバーに表示されるのは configSchema を宣言したプラグインのみです
(設定する対象がある場合だけ)。
フィールドラベルのローカライズ (configI18n)
フィールドのラベルと説明は、既定では Zod の .describe() の文言 (英語) が
使われます。ローカライズするには、locale → フィールド名 でキーを持つ
configI18n カタログを同梱します。管理画面の表示言語に一致するエントリが
上書きされ、未対応の locale / フィールドは .describe() にフォールバック
します。
configSchema: z.object({
serverUrl: z.string().url().describe('Base URL of the PlantUML server.'),
}),
configI18n: {
ja: {
serverUrl: { label: 'サーバー URL', description: 'PlantUML サーバーのベース URL。' },
},
},PluginContext でできること
各 register* コールバックの第 2 引数 PluginContext が、プラグインが
コアと安全にやり取りするための唯一の窓口です。@crowi/server を直接
import してはいけません。
トラスト境界: プラグインが到達できるのは明示的に宣言したものだけであり、
プラグインは PluginContext 経由で他プラグインや core の secret に
到達できません。
model(name)はCrowiPlugin.modelAccessの allow-list でゲートされて おり(下記)、「任意のコアモデルに触れる」ような無条件アクセスはありません。 さらにConfig/PersonalAccessToken/ OAuth 系(client/token/grant)/Share/ShareAccessといった credential を保持するコアモデルは、 そもそもmodelAccessに宣言すること自体ができません(宣言すると起動 時に fail します。詳しくは下記「コアモデルへのアクセス」参照)。- 対称暗号化 / 復号の capability も意図的に存在しません — 自分自身の
secret を読む正当な経路は
config<T>()のみで、@sensitiveフィールド は既に復号済みの値として返ります。 dependencyConfig<T>(name)は、対象プラグインがCrowiPlugin.exposesConfigToDependents: trueを明示的に宣言している 場合のみ復号済み config(@sensitiveフィールド含む)を返します。 呼び出し側がrequiresに列挙しているだけでは読めません(詳しくは下記 「依存プラグイン」参照)。
唯一残る正直な caveat は User モデルです — modelAccess: ['User'] を
宣言したプラグインは password hash を含む生の Mongoose document を取得
できます。field 単位の projection は現時点では無く、post-2.0 の
repository/HTTP 層分離まで持ち越しています。User への modelAccess は
そのプラグインを信頼できる場合にのみ付与してください。
config<T>()— このプラグインの型付き設定を読むdependencyConfig<T>(name)—requiresに宣言し、かつ対象プラグインがexposesConfigToDependents: trueを宣言している依存プラグインの設定を読むsetConfig(key, value)— 設定フィールドを 1 つ書き込むpageMetadata— このプラグイン名前空間のページメタデータを読み書きmodel(name)—modelAccessに宣言したコアの Mongoose モデル (Page / User / Comment …) にのみフルアクセス。未宣言のモデル名を渡すと throw するlog— プラグイン名で自動プレフィックスされる構造化ロガーstate<T>(initial)— hot-reload 用のStateCell<T>を返す (下記「hot-reload な driver の書き方 (state-cell)」参照)
拡張ポイント一覧
CrowiPlugin の register* コールバックは、すべて任意です。プラグインが
貢献するものだけ実装します。
| コールバック | 拡張する対象 |
|---|---|
registerStorage | ファイルストレージドライバ |
registerSearch | 全文検索バックエンド |
registerAuth | 認証プロバイダ |
registerNotifier | 通知シンク |
registerRenderer | Markdown レンダラ拡張 |
registerRoutes | プラグイン独自の HTTP エンドポイント — 現在は no-op スタブ: RFC-0006 で旧 ts-rest ルート基盤が撤去され、プラグインの HTTP 提供は後続 RFC で Hono ベースに再設計予定。registerRoutes コールバックは受け付けますが、現状はマウントされません。 |
registerHooks | イベント購読 (v2.0 では内部利用のみ、まだ安定拡張ポイントではない) |
ライフサイクルフックも宣言できます。
onInstall— 初回有効化時に一度だけ実行 (v1 設定の移行などに使う、冪等)onUninstall— 削除時に実行 (既定では設定保持、purge 指定時のみ)reconfigure— 管理 UI で設定が変わったときに呼ばれ、キャッシュした接続などを更新
reconfigure を実装しない場合、そのプラグインは「設定変更にはサーバ
再起動が必要」として扱われます。
hot-reload な driver の書き方 (state-cell)
reconfigure を実装する driver プラグイン (storage / search / mail 送信など)
は、S3 クライアントや SMTP transport のような「差し替え可能なリソース」を
module スコープの let / const に手で持つのではなく、ctx.state<T>(initial)
が返す StateCell<T> を使います。
interface DriverState {
client: SomeClient;
}
registerStorage: (registry, ctx) => {
const cell = ctx.state<DriverState>(buildState(ctx));
registry.register('my-driver', createDriver(cell));
},
reconfigure: (ctx) => {
const next = buildState(ctx);
const cell = ctx.state<DriverState>(next);
cell.set(next, { dispose: (prev) => prev.client.destroy() });
},StateCell<T> は 3 つのメソッドを持ちます。
get()— 現在値の同期スナップショットwithValue(fn)—fnの実行中は現在値を「使用中」としてマークする。 driver の各メソッドはこれ経由で state にアクセスするのが基本形 (cell.withValue(async ({ client }) => { ... }))set(next, { dispose })— 値を差し替える。dispose(prev)は、swap の 瞬間に in-flight だったwithValue()呼び出しが全て解決するまで呼ばれない (in-flight が無ければ次の microtask で呼ばれる) — 使用中のリソースを 破棄してしまう事故を防ぐ。disposeが reject しても呼び出し元には伝播 しない (ランタイムが揉み消す) ので、エラーはdispose自身の中で catch してログするか、そもそも throw しない実装にすること
同一プラグインなら、activate 時 (registerStorage/registerSearch/
registerMailSender 等) の ctx と、後続の reconfigure の ctx
(別インスタンス) が返す state() は 同じ StateCell です — 2 回目
以降の呼び出しでは initial 引数は無視されます。packages/plugin-storage-aws-s3
/ packages/plugin-mail-smtp / packages/plugin-search-elasticsearch が
この実装例です。
依存プラグイン
他のプラグインに実行時依存する場合は requires に npm 名を列挙します。
PluginManager が依存グラフを解決し、requires を先に読み込みます
(循環は起動失敗)。
資格情報の共有が典型例です。@crowi/plugin-storage-aws-s3 は
@crowi/plugin-aws を requires に宣言し、ctx.dependencyConfig() で
region / accessKeyId / secretAccessKey を読み取ります。自前の
スキーマで重複定義する必要はありません。
requires に列挙するだけでは依存プラグインの config は読めません —
ctx.dependencyConfig(name) は対象プラグインが CrowiPlugin
の exposesConfigToDependents?: boolean を true で宣言している場合
のみ復号済み config を返し、宣言していなければ「呼び出し元プラグイン名 /
対象プラグイン名 / did not declare 'exposesConfigToDependents'」を
明記した throw になります。これは依存される側の明示的な opt-in です:
// @crowi/plugin-aws (依存される側): credential を他プラグインに
// 読ませることがこのプラグインの存在目的そのものなので opt-in する
const plugin: CrowiPlugin = {
name: '@crowi/plugin-aws',
configSchema: AwsConfigSchema,
exposesConfigToDependents: true,
};exposesConfigToDependents を宣言していないプラグインの config は、
requires にどれだけ列挙されても他プラグインから読めません。
一次プラグインの中で exposesConfigToDependents: true を宣言している
のは @crowi/plugin-aws のみです。opt-in はフィールド単位ではなく
プラグイン全体に対する all-or-nothing のフラグです。
コアモデルへのアクセス (modelAccess)
ctx.model(name) でコアの Mongoose モデルに触るプラグインは、requires
と同じ形状の modelAccess?: string[] に触るモデル名を宣言します。
modelAccess: ['Page', 'Bookmark'],modelAccessに含まれないモデル名でctx.model()を呼ぶとPlugin '<name>' called model('<requested>') but did not declare it in 'modelAccess'.で throw します。- 宣言した名前が実在するコアモデルかどうかは起動時 (
PluginManager.activate()) に検証され、存在しない名前を宣言すると起動エラーになります (configSchemaの zod v3/v4 チェックと同じ隔離ポリシーで、そのプラグインだけが無効化されます)。 - 宣言したモデルにはフルアクセス (read/write) が返ります。read-only に絞る 仕組みは現時点ではありません。
GET /admin/pluginsのレスポンスにも各プラグインのmodelAccessが含まれ、 管理者はどのプラグインがどのコアコレクションに触れるかを一覧で確認できます。
一次プラグインの実例: @crowi/plugin-search-elasticsearch /
@crowi/plugin-search-opensearch は ['Page', 'Bookmark', 'User']、
@crowi/plugin-search-mongo は ['Page', 'Revision']、@crowi/plugin-slack
は ['Page'] を宣言しており、いずれも読み取り専用の用途です。
credential-vault モデルの deny-list
以下の credential を保持するコアモデルは、実在するコアモデルであっても
modelAccess に宣言できません。宣言すると assertValidModelAccess() が
起動時に fail させます(メッセージ例: Plugin '<name>' declares modelAccess including '<model>', but credential-bearing core models cannot be granted to plugins.)。
Config— core / 他プラグインの@sensitive設定値(復号済み)を保持PersonalAccessTokenOAuthClient/OAuthAuthorizationCode/OAuthDeviceCode/OAuthRefreshTokenShare/ShareAccess— ページ共有トークン(準 credential)
これらのモデルを直接触る正当なプラグインユースケースは存在しません。
ctx.model() の呼び出し時にも同じ deny-list が再チェックされる
(defense-in-depth)ため、仮に boot 検証を回避する経路があっても
credential-vault モデルは返りません。
レンダラプラグインを作る
registerRenderer で Markdown レンダラを拡張できます。レジストリは
unified プラグインの追加、mdast ノードレンダラ、コードブロックレンダラ、
埋め込みタグレンダラ、URL インライン展開ルールの登録メソッドを提供します。
コードブロックレンダラは cacheVersion・任意の reservation (プレース
ホルダ形状)・render を持つオブジェクトで、PluginRenderCache 経由で
キャッシュされます。
具体的な実装例としては、リポジトリ内の
packages/plugin-renderer-plantuml (コードブロック + キャッシュ + 外部
I/O)、packages/plugin-renderer-katex (unified プラグイン + ノード変換)、
packages/plugin-renderer-emoji (no-I/O の最小例) が参考になります。
参考実装
リポジトリ同梱のファーストパーティプラグインは、そのまま参考実装として 読めます。
| パッケージ | 何の例として読めるか |
|---|---|
packages/plugin-storage-local | registerStorage の最小例 |
packages/plugin-storage-aws-s3 | requires による資格情報共有 + @sensitive |
packages/plugin-aws | register* を持たない設定のみのベースプラグイン |
packages/plugin-search-elasticsearch | registerSearch |
packages/plugin-renderer-* | registerRenderer の各バリエーション |
また packages/plugin-api/src/__fixtures__/example-plugin.ts は、
すべての拡張ポイントを 1 ファイルで網羅したサニティ用フィクスチャです
(実行用ではありませんが、契約の使い方の見本になります)。
関連ページ
- プラグインの仕組み → プラグインアーキテクチャ
- プラグインの導入と設定 → プラグインの導入と設定
- レンダラプラグインの一覧 → レンダラプラグイン
- 設計提案の一覧 → RFC インデックス