Crowi
プラグイン

プラグイン開発

@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 がこのスキーマを辿って設定フォーム を自動生成します。

注意: configSchemazod パッケージのトップレベル (v4 ネイティブ) API ではなく、必ず import { z } from 'zod/v3' の互換サブパスから import してください。@crowi/plugin-apipeerDependencieszod: "^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 の挙動を 変えます。

マーカー書式効果
@sensitivez.string().describe('@sensitive ...')保存時に自動暗号化。シークレット入力欄で描画。
@actionz.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)」参照)

拡張ポイント一覧

CrowiPluginregister* コールバックは、すべて任意です。プラグインが 貢献するものだけ実装します。

コールバック拡張する対象
registerStorageファイルストレージドライバ
registerSearch全文検索バックエンド
registerAuth認証プロバイダ
registerNotifier通知シンク
registerRendererMarkdown レンダラ拡張
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 と、後続の reconfigurectx (別インスタンス) が返す 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-awsrequires に宣言し、ctx.dependencyConfig()region / accessKeyId / secretAccessKey を読み取ります。自前の スキーマで重複定義する必要はありません。

requires に列挙するだけでは依存プラグインの config は読めません — ctx.dependencyConfig(name) は対象プラグインが CrowiPluginexposesConfigToDependents?: booleantrue で宣言している場合 のみ復号済み 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 設定値(復号済み)を保持
  • PersonalAccessToken
  • OAuthClient / OAuthAuthorizationCode / OAuthDeviceCode / OAuthRefreshToken
  • Share / 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-localregisterStorage の最小例
packages/plugin-storage-aws-s3requires による資格情報共有 + @sensitive
packages/plugin-awsregister* を持たない設定のみのベースプラグイン
packages/plugin-search-elasticsearchregisterSearch
packages/plugin-renderer-*registerRenderer の各バリエーション

また packages/plugin-api/src/__fixtures__/example-plugin.ts は、 すべての拡張ポイントを 1 ファイルで網羅したサニティ用フィクスチャです (実行用ではありませんが、契約の使い方の見本になります)。

関連ページ

On this page