Guide/guides

プラグイン作成ガイド

このガイドでは、Gurenプラグインの作成、テスト、公開の手順を解説します。

プラグイン作成ガイド

このガイドでは、Gurenプラグインの作成、テスト、公開の手順を解説します。

プラグインとは?

Gurenプラグインは、ServiceProviderサブクラスをエクスポートするnpmパッケージです。ユーザーがプロバイダーをcreateApp({ providers })配列に追加すると、フレームワークがアプリケーション起動時にregister()boot()フックを呼び出します。

完全な仕様とルールについては、プラグインコントラクトを参照してください。

ステップ1: 新しいパッケージを作成する

mkdir guren-plugin-analytics
cd guren-plugin-analytics
bun init

package.jsonを設定します:

{
  "name": "guren-plugin-analytics",
  "version": "0.1.0",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "gurenPlugin": {
    "compatibility": ">=1.0.0"
  },
  "peerDependencies": {
    "@guren/core": ">=1.0.0"
  },
  "devDependencies": {
    "@guren/core": "^1.0.0",
    "@guren/testing": "^1.0.0",
    "typescript": "^5.0.0"
  }
}

ポイント:

  • @guren/corepeerDependency -- ホストアプリケーションが提供します。
  • @guren/core@guren/testingはビルドとテスト用のdevDependenciesです。
  • gurenPlugin.compatibilityフィールドでサポートするGurenバージョンを宣言します。

ステップ2: プラグインを定義する

@guren/coredefinePlugin()ヘルパーを使用します。設定はクロージャに捕捉され、呼び出しごとに独立したプロバイダークラスを生成するため、同じプラグインを異なる設定で複数回登録できます:

// src/plugin.ts
import { definePlugin } from '@guren/core'

export interface AnalyticsConfig {
  apiKey: string
  endpoint?: string
  batchSize?: number
}

export class AnalyticsClient {
  constructor(private config: AnalyticsConfig) {}

  track(event: string, properties?: Record<string, unknown>): void {
    // 設定されたエンドポイントにアナリティクスイベントを送信
    console.log(`[Analytics] ${event}`, properties)
  }
}

export const analyticsPlugin = definePlugin<AnalyticsConfig>({
  name: 'analytics',

  register(container, config) {
    container.singleton('analytics', () => new AnalyticsClient(config))
  },

  boot(container) {
    // 全プロバイダーの登録後にフレームワークイベントをサブスクライブ
    if (container.has('events')) {
      const events = container.make('events')
      const analytics = container.make<AnalyticsClient>('analytics')
      events.on('request.completed', (data: Record<string, unknown>) => {
        analytics.track('page_view', data)
      })
    }
  },
})

初期化コストの高いプラグインはdeferred: trueprovides: ['analytics']を併せて指定すると、提供するサービスが最初に解決されるまでプロバイダーの読み込みを遅延できます。

definePlugin()でカバーできないライフサイクル制御が必要な場合は、従来通りServiceProviderのサブクラスを直接エクスポートすることもできます。ただし設定をstaticプロパティに保存するのは避けてください。staticは共有されるため、プラグインを2回登録すると最初の設定が上書きされます。

ステップ3: プラグインをエクスポートする

// src/index.ts
export { analyticsPlugin, AnalyticsClient } from './plugin'
export type { AnalyticsConfig } from './plugin'

ステップ4: プラグインメタデータを追加する

package.jsongurenPluginフィールドを含める必要があります:

{
  "gurenPlugin": {
    "compatibility": ">=1.0.0",
    "provider": "AnalyticsServiceProvider",
    "env": [
      { "key": "ANALYTICS_API_KEY", "comment": "Analytics service API key" }
    ],
    "publishes": [
      { "from": "stubs/analytics.ts", "to": "config/analytics.ts" }
    ]
  }
}
フィールド 用途
compatibility サポートするGurenバージョンのsemver範囲。bunx guren pluginのインストール時とbunx guren doctorで検証されます。
provider bunx guren plugincreateApp({ providers })に登録する名前付きクラスエクスポート。definePlugin()ファクトリの場合は省略します(手動登録)。
env インストール時にアプリの.env.example.envが存在すればそちらにも)へ追記される環境変数キー。
publishes パッケージからアプリへコピーされるファイル(config/db/migrations/resources/のみ)。既存ファイルは--forceなしでは上書きされません。

マニフェストは純粋なデータです — CLIはインストール中にプラグインのコードを一切実行しません。

オプション: CLIコマンドを追加する

プラグインはマニフェストで宣言することでguren CLIにコマンドを追加できます:

{
  "gurenPlugin": {
    "commands": {
      "entry": "./dist/commands.js",
      "names": ["analytics:flush"]
    }
  }
}

エントリモジュールは、コマンド名をキーとするcittyコマンド定義のレコードをdefault exportします:

// src/commands.ts
import { defineCommand } from 'citty'

export default {
  'analytics:flush': defineCommand({
    meta: { name: 'analytics:flush', description: 'キューされたイベントをフラッシュ' },
    async run() {
      // ...
    },
  }),
}

プラグインをアプリにインストールすると、bunx guren analytics:flushでコマンドが実行でき、bunx guren --helpにも表示されます。コマンド名には:名前空間が必須で、ビルトインコマンド名が常に優先され、複数のプラグインが同じ名前を宣言した場合は警告とともに両方とも無効化されます。エントリモジュールがimportされるのは宣言したコマンドが実行される時(またはそのコマンド自身の--helpを表示する時)だけで、ルートの一覧表示では実行されません。

ステップ5: テストを書く

@guren/testingcreatePluginTestAppassertPluginRegistersを使用します:

// src/plugin.test.ts
import { describe, test, expect } from 'bun:test'
import { createPluginTestApp, assertPluginRegisters } from '@guren/testing'
import { analyticsPlugin, AnalyticsClient } from './plugin'

describe('analyticsPlugin', () => {
  test('analyticsサービスが登録されること', async () => {
    const app = await createPluginTestApp([analyticsPlugin({ apiKey: 'test-key' })])

    // サービスがバインドされていることを確認
    assertPluginRegisters(app, ['analytics'])
  })

  test('AnalyticsClientインスタンスが解決されること', async () => {
    const app = await createPluginTestApp([analyticsPlugin({ apiKey: 'test-key' })])

    const client = app.container.make<AnalyticsClient>('analytics')
    expect(client).toBeInstanceOf(AnalyticsClient)
  })

  test('シングルトンとして登録されること', async () => {
    const app = await createPluginTestApp([analyticsPlugin({ apiKey: 'test-key' })])

    const first = app.container.make<AnalyticsClient>('analytics')
    const second = app.container.make<AnalyticsClient>('analytics')
    expect(first).toBe(second)
  })
})

テストを実行:

bun test src/plugin.test.ts

ステップ6: ビルドする

tsupを使用したビルドスクリプトを追加します:

{
  "scripts": {
    "build": "tsup src/index.ts --format esm --dts",
    "test": "bun test"
  },
  "devDependencies": {
    "tsup": "^8.0.0"
  }
}

ステップ7: 公開前にローカルで動作確認する

公開する前に、実際のGurenアプリにプラグインをリンクしてエンドツーエンドで検証しましょう:

# アプリのディレクトリで実行
bun add file:../guren-plugin-analytics
bunx guren plugin guren-plugin-analytics

bun add file:(およびlink:workspace:プロトコル)は、パッケージをコピーするのではなく、プラグインのソースディレクトリへのシンボリックリンクとしてインストールします。プラグインのpackage.jsonに、ステップ1で@guren/coredevDependenciesとして追加した際のnode_modulesがまだ残っている場合、アプリは@guren/coreを2つの別々のコピーとして読み込んでしまうことがあります — 1つはアプリ自身のインストール、もう1つはプラグイン経由です。これはランタイムでの重複モジュール警告や、コンパイル時のProperty 'bindings' is protected but type 'Container' is not a class derived from 'Container'のようなTypeScriptエラーとして現れます。

この問題が発生した場合は、アプリにリンクする前にプラグインのパッケージディレクトリ内のnode_modulesを削除してください。プラグイン側に隠蔽するコピーがなくなれば、アプリ自身の@guren/coreインストールがプラグインのpeerDependenciesを満たすようになります。公開済みのプラグインはnode_modulesを同梱しないため、これは公開前のローカル検証にのみ影響します。

ステップ8: 公開する

bun run build
npm publish

プラグインのインストール

公式(@guren/plugin-*)・コミュニティ(guren-plugin-*)を問わず、プラグインはCLI経由でインストールできます:

bunx guren plugin @guren/plugin-vercel

pluginコマンドは、依存が未インストールならbun addでインストールし(--no-installでスキップ可能)、プラグインが宣言するGuren互換性を検証した上で(--ignore-compatibilityで無視して登録可能)、プロバイダーのimport追加とcreateApp({ providers })への登録、マニフェストのenvpublishesエントリの適用を行います。--forceは公開済みファイルの上書きに使います。

注意: 自動登録が現在対応しているのはクラスベースのプロバイダーエクスポートのみです。definePlugin()で作成したプラグインは設定を渡してファクトリを呼び出す必要があるため、下記のようにcreateApp({ providers })へ手動で登録してください。

Gurenアプリケーションでの使用方法

公開後、ユーザーはプラグインをインストールして登録します:

bun add guren-plugin-analytics
// src/app.ts
import { createApp } from '@guren/core'
import { analyticsPlugin } from 'guren-plugin-analytics'
import { registerWebRoutes } from '@/routes/web'

export const app = createApp({
  routes: registerWebRoutes,
  providers: [
    analyticsPlugin({
      apiKey: process.env.ANALYTICS_API_KEY!,
      endpoint: 'https://analytics.example.com',
    }),
  ],
})

完全な例: リクエストロガープラグイン

受信リクエストをすべてログに記録するシンプルなプラグイン:

// src/RequestLoggerProvider.ts
import { ServiceProvider } from '@guren/core'
import type { Hono, MiddlewareHandler } from 'hono'

export class RequestLoggerProvider extends ServiceProvider {
  register(): void {
    this.container.singleton('request-logger', () => {
      return {
        requests: [] as Array<{ method: string; path: string; timestamp: number }>,
      }
    })
  }

  boot(): void {
    const hono = this.container.make<Hono>('hono')
    const logger = this.container.make<{ requests: Array<{ method: string; path: string; timestamp: number }> }>('request-logger')

    const middleware: MiddlewareHandler = async (c, next) => {
      logger.requests.push({
        method: c.req.method,
        path: c.req.path,
        timestamp: Date.now(),
      })
      await next()
    }

    hono.use('*', middleware)
  }
}

テスト:

import { describe, test, expect } from 'bun:test'
import { createPluginTestApp, assertPluginRegisters } from '@guren/testing'
import { RequestLoggerProvider } from './RequestLoggerProvider'

describe('RequestLoggerProvider', () => {
  test('request-loggerサービスが登録されること', async () => {
    const app = await createPluginTestApp([RequestLoggerProvider])
    assertPluginRegisters(app, ['request-logger'])
  })

  test('空のリクエストログで初期化されること', async () => {
    const app = await createPluginTestApp([RequestLoggerProvider])
    const logger = app.container.make<{ requests: unknown[] }>('request-logger')
    expect(logger.requests).toHaveLength(0)
  })
})

ヒント

  • 可能な場合はregister()を同期的に保つ。 両フックはasyncをサポートしますが、同期的な登録の方が高速です。
  • 重い依存関係にはdeferredプロバイダーを使用する。 プラグインが大きなSDKを読み込む場合、必要な時にだけ初期化されるようにdeferredとしてマークしてください。
  • importではなくコンテナに依存する。 フレームワーク内部を直接importするのではなく、this.container.make()でサービスを解決してください。
  • 複数のGurenバージョンに対してテストする。 CIマトリクスを使用して、サポートする最小バージョンと最新バージョンに対してテストスイートを実行してください。
  • 登録するサービスをドキュメント化する。 ユーザーが自身のコードでサービスを解決できるよう、プラグインが提供するコンテナキーを明記してください。