Skip to content

feat(plugins): implement event bus foundation for plugin system#134

Merged
sasagar merged 1 commit intodevfrom
feature/plugin-event-bus
Jan 28, 2026
Merged

feat(plugins): implement event bus foundation for plugin system#134
sasagar merged 1 commit intodevfrom
feature/plugin-event-bus

Conversation

@sasagar
Copy link
Collaborator

@sasagar sasagar commented Jan 27, 2026

Summary

This PR implements Phase 1 of the plugin system: the EventBus foundation for plugin event hooks.

Changes

  • EventBus class (packages/backend/src/lib/events.ts)

    • Type-safe event handling with on() / emit() for post-action events
    • onBefore() / emitBefore() for pre-action events with cancellation support
    • Priority-based handler execution (higher priority runs first)
    • Error isolation - one handler's failure doesn't affect others
    • Plugin-scoped cleanup via offPlugin()
  • Plugin type definitions (packages/backend/src/plugins/types.ts)

    • RoxPlugin interface for plugin registration
    • PluginContext for plugins to access core services
    • Sanitized types (PluginUser, PluginNote, PluginFollow) that exclude sensitive data
    • Event payload interfaces for all supported events
  • Event hooks integrated into services

    • NoteService: note:beforeCreate, note:afterCreate, note:beforeDelete, note:afterDelete
    • AuthService: user:beforeRegister, user:afterRegister, user:beforeLogin, user:afterLogin
    • FollowService: follow:afterCreate, follow:afterDelete
  • DI container integration

    • EventBus instantiated in DI container
    • Registered in Hono context via middleware

Architecture

Plugin Registration → EventBus → Service Hooks
                          ↓
         Plugin handlers (prioritized, isolated)
  • Before events can cancel/modify operations
  • After events are fire-and-forget (non-blocking)
  • Handlers are called in priority order (highest first)

Test plan

  • All 11 EventBus unit tests passing
  • All 986 existing backend tests passing
  • TypeScript type checking passes
  • Manual testing of note creation/deletion with plugin hooks
  • Manual testing of user registration/login with plugin hooks

Summary by CodeRabbit

  • 新機能

    • プラグインフックを大幅追加:ユーザー登録・ログイン(IP/UA情報含む)・ログアウト、ノート作成・削除、フォロー操作、配信などの前後イベントをプラグインで拡張可能(前処理はキャンセル/ペイロード修正、後処理は非ブロッキング)
    • アプリ全体で利用できるグローバルなイベントバスを導入。リクエストコンテキストからも利用可能に
  • テスト

    • イベントシステムの包括的なユニットテストを追加
  • ツール

    • プラグイン向け型定義、データ変換ユーティリティ、テストヘルパーを追加

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

EventBus を導入して DI コンテナへ配線し、型安全なプラグインイベント(before/after)を追加。AuthService、NoteService、FollowService のライフサイクルでイベント発行を組み込み、プラグイン型定義、ユーティリティ、テスト、ミドルウェア注入を追加しました。

Changes

Cohort / File(s) 変更内容
DI とエントリ
packages/backend/src/di/container.ts, packages/backend/src/index.ts
DI コンテナに eventBus: EventBus を追加し、コンテナ経由で各サービスへ渡すように更新。
ミドルウェア
packages/backend/src/middleware/di.ts
Hono コンテキストへ eventBus を注入(c.set("eventBus", container.eventBus))。
EventBus 実装 + テスト
packages/backend/src/lib/events.ts, packages/backend/src/tests/unit/EventBus.test.ts
新規 EventBus クラス追加(on/onBefore/emit/emitBefore/offPlugin 等、優先度・キャンセル・payload 変更対応)、グローバル eventBus をエクスポート。包括的ユニットテスト追加。
プラグイン型定義 & re-export
packages/backend/src/plugins/types.ts, packages/backend/src/plugins/index.ts
PluginUser/PluginNote 等の豊富な型定義を追加し、plugins モジュールから型を再エクスポート。
プラグインユーティリティ
packages/backend/src/plugins/utils.ts
toPluginUser / toPluginNote の変換ヘルパーを追加(プラグイン向け安全な表現)。
テストヘルパー
packages/backend/src/tests/helpers/pluginTestHelpers.ts
プラグイン用テストデータ生成ヘルパーを追加(PluginUser/PluginNote/ペイロード)。
サービス統合:AuthService
packages/backend/src/services/AuthService.ts, packages/backend/src/routes/auth.ts, packages/backend/src/routes/onboarding.ts
コンストラクタに optional eventBus を追加。register/login/logout に user:before*(キャンセル可能)と user:after*(非同期発行)を組み込み。login は IP / userAgent をイベントメタに渡す。
サービス統合:NoteService / ルート
packages/backend/src/services/NoteService.ts, packages/backend/src/routes/notes.ts, packages/backend/src/routes/lists.ts
NoteService に optional eventBus を追加。note:beforeCreate/afterCreate、note:beforeDelete/afterDelete を統合し、プラグイン向け変換ヘルパーを導入。ルートで eventBus を注入。
サービス統合:FollowService / ルート
packages/backend/src/services/FollowService.ts, packages/backend/src/routes/following.ts
FollowService に optional eventBus を追加し、follow:afterCreate / follow:afterDelete を非同期発行。PluginFollow 変換ヘルパー追加。
呼び出し側の更新(各ルート/初期化)
packages/backend/src/routes/*.ts (auth, notes, lists, following, onboarding など)
各ルートで DI から eventBus を取得し、サービスコンストラクタ呼び出しに渡すよう更新。

Sequence Diagrams

sequenceDiagram
    participant Client
    participant Route as Note Route
    participant NoteService
    participant EventBus
    participant BeforePlugins as Before Hooks (Plugins)
    participant Repository
    participant AfterPlugins as After Hooks (Plugins)

    Client->>Route: POST /notes (create)
    Route->>NoteService: create(payload)
    NoteService->>EventBus: emitBefore("note:beforeCreate", { author, note })
    EventBus->>BeforePlugins: 呼び出し(優先度順)
    BeforePlugins-->>EventBus: { cancelled?, cancelReason?, modifiedPayload? }
    alt cancelled
        EventBus-->>NoteService: { cancelled: true }
        NoteService-->>Route: throw Error (中止)
        Route-->>Client: 4xx
    else proceed
        EventBus-->>NoteService: { cancelled: false, modifiedPayload? }
        NoteService->>Repository: save(modified or original note)
        Repository-->>NoteService: saved note
        NoteService->>EventBus: emit("note:afterCreate", { note, author }) (async)
        EventBus->>AfterPlugins: 呼び出し(fire-and-forget)
        NoteService-->>Route: return note
        Route-->>Client: 2xx
    end
Loading
sequenceDiagram
    participant Client
    participant Route as Auth Route
    participant AuthService
    participant EventBus
    participant BeforePlugins as Before Hooks (Plugins)
    participant Repository
    participant AfterPlugins as After Hooks (Plugins)

    Client->>Route: POST /auth/register
    Route->>AuthService: register(input)
    AuthService->>EventBus: emitBefore("user:beforeRegister", { user })
    EventBus->>BeforePlugins: 呼び出し(優先度順)
    BeforePlugins-->>EventBus: { cancelled?, cancelReason?, modifiedPayload? }
    alt cancelled
        EventBus-->>AuthService: { cancelled: true }
        AuthService-->>Route: throw Error (中止)
        Route-->>Client: 4xx
    else proceed
        AuthService->>Repository: create user
        Repository-->>AuthService: created user
        AuthService->>EventBus: emit("user:afterRegister", { user }) (async)
        EventBus->>AfterPlugins: 呼び出し(fire-and-forget)
        AuthService-->>Route: { user, session }
        Route-->>Client: 2xx
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

enhancement, foundation

Poem

🐰 ぴょんと跳ねてイベント来た、
フックが奏でる小さな舞台。
Beforeで止め、Afterで歌う、
プラグインの輪が静かに広がる。
新しい春に耳を澄ませて。

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(plugins): implement event bus foundation for plugin system' directly and clearly summarizes the main change: implementing an event bus as the foundation for a plugin system, which aligns perfectly with the core changes across the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/backend/src/routes/auth.ts (1)

311-312: ログアウト時のAuthService初期化の一貫性を統一してください。

登録・ログインフローではeventBusを渡していますが、ログアウトでは渡していません。現在、logout()メソッドはセッションの削除のみを行い、イベントを発火していないため機能的には問題ありませんが、将来user:logoutイベントが必要になった場合に修正を要します。一貫性のため、次のいずれかの対応を検討してください:

  • eventBusを常に渡すようにする(将来のイベント対応に備える)
  • イベント発火の必要性が明確になるまで、eventBusはイベント発火時のみ渡すパターンに統一する
🧹 Nitpick comments (5)
packages/backend/src/lib/events.ts (2)

10-10: 未使用の EventEmitter を削除してください。

EventEmitter をインポートしてインスタンス化していますが、実際には使用されていません。ハンドラの登録と呼び出しはすべて Map を使って独自に実装されています。setMaxListeners(100) の設定も効果がありません。

♻️ 提案する修正
-import { EventEmitter } from "events";
 import type { PluginEvents, PluginEventPayload } from "../plugins/types.js";
 import { logger } from "./logger.js";
 export class EventBus {
-  private emitter: EventEmitter;
   private handlers: Map<string, HandlerEntry<unknown>[]>;
   private beforeHandlers: Map<string, HandlerEntry<unknown>[]>;

   constructor() {
-    this.emitter = new EventEmitter();
-    this.emitter.setMaxListeners(100);
     this.handlers = new Map();
     this.beforeHandlers = new Map();
   }

Also applies to: 79-87


212-252: emitBefore の戻り値の仕様を確認してください。

ハンドラが存在するが変更がなかった場合でも { modifiedPayload: currentPayload } を返しています。これは意図的な設計と思われますが、呼び出し側が「変更なし」と「同じ値に変更」を区別できません。

現在の設計で問題ない場合は、TSDocコメントに「ハンドラが存在する場合は常に modifiedPayload を返す」旨を明記すると、利用者にとって分かりやすくなります。

packages/backend/src/tests/unit/EventBus.test.ts (1)

25-52: テストペイロードのヘルパー関数を検討してください。

同じ構造のペイロードオブジェクトが複数のテストで繰り返されています。テストヘルパー関数やファクトリを作成すると、コードの重複を減らし、変更時のメンテナンスが容易になります。

♻️ 例:ヘルパー関数の作成
// テストファイルの先頭に追加
function createTestNotePayload(overrides?: Partial<NoteAfterCreatePayload>): NoteAfterCreatePayload {
  return {
    note: {
      id: "note1",
      userId: "user1",
      text: "Hello",
      cw: null,
      visibility: "public",
      localOnly: false,
      replyId: null,
      renoteId: null,
      fileIds: [],
      mentions: [],
      uri: null,
      createdAt: new Date(),
    },
    author: {
      id: "user1",
      username: "alice",
      displayName: null,
      avatarUrl: null,
      host: null,
      isAdmin: false,
      isSuspended: false,
      isSystemUser: false,
      uri: null,
      createdAt: new Date(),
    },
    ...overrides,
  };
}
packages/backend/src/services/FollowService.ts (1)

214-227: followIdの複合ID形式について確認してください。

削除済みのフォロー関係では元のIDが利用できないため、${followerId}:${followeeId}という複合IDを使用していますが、プラグイン開発者がこの形式を期待するか確認が必要です。

プラグイン型定義(PluginEvents)でこのIDフォーマットがドキュメント化されているか確認することをお勧めします。

#!/bin/bash
# follow:afterDelete イベントのペイロード型定義を確認
rg -n -A 10 "follow:afterDelete" packages/backend/src/plugins/types.ts
packages/backend/src/services/NoteService.ts (1)

113-149: toPluginUsertoPluginNoteヘルパーの重複について検討してください。

これらの変換ヘルパーはAuthService(そしておそらくFollowService)にも存在する可能性があります。共通のユーティリティに抽出することで、変換ロジックの一貫性を保ち、メンテナンス性を向上させることができます。

♻️ 提案: 共通ユーティリティへの抽出

packages/backend/src/plugins/utils.tsのような共有ファイルを作成することを検討してください:

import type { User } from "shared";
import type { Note } from "shared";
import type { PluginUser, PluginNote } from "./types.js";

export function toPluginUser(user: User): PluginUser {
  return {
    id: user.id,
    username: user.username,
    displayName: user.displayName,
    avatarUrl: user.avatarUrl,
    host: user.host,
    isAdmin: user.isAdmin,
    isSuspended: user.isSuspended,
    isSystemUser: user.isSystemUser,
    uri: user.uri,
    createdAt: user.createdAt,
  };
}

export function toPluginNote(note: Note): PluginNote {
  return {
    id: note.id,
    userId: note.userId,
    text: note.text,
    cw: note.cw,
    visibility: note.visibility,
    localOnly: note.localOnly,
    replyId: note.replyId,
    renoteId: note.renoteId,
    fileIds: note.fileIds,
    mentions: note.mentions,
    uri: note.uri,
    createdAt: note.createdAt,
  };
}

@sasagar sasagar force-pushed the feature/plugin-event-bus branch 2 times, most recently from ad0b799 to 1979651 Compare January 27, 2026 22:15
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/backend/src/services/AuthService.ts (1)

212-262: beforeLogin の modifiedPayload を無視している
Line 215-219 で modifiedPayload を受け取れる設計ですが、input.usernamecontext をそのまま使っており変更が反映されません。プラグインで補正できる仕様なら、修正後の値を認証・afterLogin の payload に適用してください。

🐛 修正案(例)
-    if (this.eventBus) {
-      const beforeResult = await this.eventBus.emitBefore("user:beforeLogin", {
-        usernameOrEmail: input.username,
-        ipAddress: context?.ipAddress || null,
-        userAgent: context?.userAgent || null,
-      });
+    let usernameOrEmail = input.username;
+    let ipAddress = context?.ipAddress || null;
+    let userAgent = context?.userAgent || null;
+    if (this.eventBus) {
+      const beforeResult = await this.eventBus.emitBefore("user:beforeLogin", {
+        usernameOrEmail,
+        ipAddress,
+        userAgent,
+      });
 
       if (beforeResult.cancelled) {
         throw new Error(beforeResult.cancelReason || "Login cancelled by plugin");
       }
+      if (beforeResult.modifiedPayload) {
+        usernameOrEmail = beforeResult.modifiedPayload.usernameOrEmail ?? usernameOrEmail;
+        ipAddress = beforeResult.modifiedPayload.ipAddress ?? ipAddress;
+        userAgent = beforeResult.modifiedPayload.userAgent ?? userAgent;
+      }
     }
@@
-    const user = await this.userRepository.findByUsername(input.username);
+    const user = await this.userRepository.findByUsername(usernameOrEmail);
@@
-          ipAddress: context?.ipAddress || null,
-          userAgent: context?.userAgent || null,
+          ipAddress,
+          userAgent,
🤖 Fix all issues with AI agents
In `@packages/backend/src/plugins/types.ts`:
- Around line 140-147: The UserBeforeLoginPayload currently exposes possible PII
via usernameOrEmail, ipAddress, and userAgent; update the payload to match the
UserBeforeRegisterPayload policy by either (a) removing/renaming usernameOrEmail
to username (and enforce that it cannot contain an email) in the
UserBeforeLoginPayload interface, or (b) move ipAddress and userAgent out of
UserBeforeLoginPayload into a separate context/event interface (e.g.,
UserBeforeLoginContext) that is only emitted to authorized listeners; modify
references to UserBeforeLoginPayload accordingly and add validation/typing to
ensure emails are not accepted in the username field if you choose option (a).

In `@packages/backend/src/services/AuthService.ts`:
- Around line 108-119: The eventBus.emitBefore call returns
beforeResult.modifiedPayload but the code still continues using the original
input for duplicate checks and user creation; update the AuthService flow so
that after calling this.eventBus.emitBefore("user:beforeRegister", ...) you
merge/apply beforeResult.modifiedPayload (when present) into the local
registration data (e.g., replace or merge into the input or a new
registrationPayload variable) and use that modified payload for the subsequent
duplicate checks and the user creation routine (the logic that performs
uniqueness validation and calls whatever creates the user record), while
preserving the existing cancellation handling via beforeResult.cancelled.

In `@packages/backend/src/services/NoteService.ts`:
- Around line 189-211: Change the immutable input variables to mutable and apply
any plugin modifications returned by emitBefore: when calling
this.eventBus.emitBefore("note:beforeCreate", ...) capture
beforeResult.modifiedPayload and, if present, overwrite the local variables used
to create the note (e.g., text, cw, visibility, localOnly, replyId, renoteId,
fileIds) before proceeding; ensure you change their declarations from const to
let so they can be updated, and if fileIds is changed re-run the file
ownership/permission checks (same logic used elsewhere in NoteService) to
validate the new file IDs; keep the existing cancellation handling using
beforeResult.cancelled and return beforeResult.cancelReason when cancelled.
🧹 Nitpick comments (6)
packages/backend/src/routes/auth.ts (1)

245-259: x-forwarded-for を先頭 IP に正規化したい
Line 257 で x-forwarded-for をそのまま渡すと複数 IP がカンマ区切りで入る可能性があり、プラグイン/監査側の扱いが不安定になります。先頭 IP のみ抽出して渡すのが安全です。

♻️ 提案修正
-        ipAddress: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
+        ipAddress: (() => {
+          const forwarded = c.req.header("x-forwarded-for");
+          if (forwarded) return forwarded.split(",")[0].trim();
+          return c.req.header("x-real-ip") || undefined;
+        })(),
packages/backend/src/tests/unit/EventBus.test.ts (1)

108-126: ペイロード変更テストの型安全性を確認してください。

Line 114で payload.text?.toUpperCase() を使用していますが、NoteBeforeCreatePayloadtextstring | null型です。オプショナルチェーンは適切ですが、戻り値がnullの場合の処理を確認してください。

🔧 より明示的な null ハンドリングの提案
       eventBus.onBefore(
         "note:beforeCreate",
         async (payload) => ({
           modifiedPayload: {
             ...payload,
-            text: payload.text?.toUpperCase() || null,
+            text: payload.text !== null ? payload.text.toUpperCase() : null,
           },
         }),
         "modifier-plugin"
       );
packages/backend/src/services/FollowService.ts (1)

197-210: follow:afterDeleteの複合IDについてドキュメント化を検討してください。

Line 203で ${followerId}:${followeeId} という複合IDを使用していますが、プラグイン開発者がこの形式を期待していない可能性があります。PluginFollowidフィールドとは異なる形式になるため、プラグインAPIドキュメントでこの動作を明記することを推奨します。

📝 コメントの追加提案
     // Emit follow:afterDelete event (async, non-blocking)
     // Only emit if the relationship existed and we have valid user info
     if (this.eventBus && exists && follower && followee) {
       this.eventBus
         .emit("follow:afterDelete", {
-          followId: `${followerId}:${followeeId}`, // Composite ID since we don't have the original
+          // Note: Using composite ID format "followerId:followeeId" since the original
+          // follow record ID is not available after deletion. Plugin developers should
+          // be aware that this differs from the `follow.id` in afterCreate events.
+          followId: `${followerId}:${followeeId}`,
           follower: toPluginUser(follower),
           followee: toPluginUser(followee),
         })
packages/backend/src/tests/helpers/pluginTestHelpers.ts (1)

53-107: 他のイベントペイロード用のファクトリ追加を検討してください。

現在、Note関連のbeforeCreateafterCreateのペイロードヘルパーのみ実装されています。将来的に以下のヘルパーも追加すると、テストの一貫性が向上します:

  • createNoteBeforeDeletePayload
  • createNoteAfterDeletePayload
  • createFollowAfterCreatePayload
  • createFollowAfterDeletePayload
packages/backend/src/lib/events.ts (1)

211-251: emitBefore()のエラーハンドリングについて確認してください。

現在の実装では、beforeハンドラーでエラーが発生してもキャンセルせずに処理を続行します。これは意図的な設計かもしれませんが、セキュリティ関連のプラグイン(スパムフィルターなど)がエラーで失敗した場合、操作が続行されるリスクがあります。

この動作が意図的であれば、JSDocコメントにその旨を明記することを推奨します。

📝 エラー動作のドキュメント化
   /**
    * Emit a "before" event and collect cancellation/modification results
    *
    * Handlers are executed in priority order. If any handler cancels,
    * the operation should be aborted.
+   *
+   * `@remarks`
+   * If a handler throws an error, it is logged but processing continues.
+   * This means security-critical plugins should handle their own errors
+   * and return `{ cancelled: true }` explicitly rather than throwing.
    *
    * `@param` event - Before event name
packages/backend/src/plugins/types.ts (1)

35-38: 可視性フィールドをユニオン型にして型安全性を強化する

PluginNote.visibilityNoteBeforeCreatePayload.visibilitystring だと無効値が混入しやすいです。既存の Visibility 型(shared/src/types/common.js)が "public" | "home" | "followers" | "specified" として定義されており、NoteServiceScheduledNoteService で既に使用されています。このプラグイン型でも同じ Visibility 型を import して使用することで、型安全性と一貫性が向上します。

@sasagar sasagar force-pushed the feature/plugin-event-bus branch from 1979651 to d83663b Compare January 28, 2026 01:07
- Add typed EventBus class with before/after event patterns
- Define plugin type interfaces (PluginUser, PluginNote, PluginFollow, PluginActivity)
- Use Visibility union type for type safety in note visibility
- Implement event payload types for all lifecycle events
- Add note lifecycle events: beforeCreate, afterCreate, beforeDelete, afterDelete
- Add user lifecycle events: beforeRegister, afterRegister, beforeLogin, afterLogin, afterLogout
- Add follow lifecycle events: afterCreate, afterDelete
- Add ActivityPub events: beforeInbox, afterInbox, beforeDelivery, afterDelivery
- Add moderation events: userSuspended, noteDeleted
- Integrate EventBus into DI container and middleware
- Emit events in NoteService, AuthService, FollowService
- Apply modifiedPayload from beforeCreate/beforeRegister events
- Remove PII (email) from user:beforeRegister payload
- Remove PII (ipAddress, userAgent) from user:beforeLogin payload
- Add shared toPluginUser/toPluginNote utility functions
- Add test helper factory functions
- Document composite followId format for delete events
- Document emitBefore error handling behavior
@sasagar sasagar force-pushed the feature/plugin-event-bus branch from d83663b to e1b6e9d Compare January 28, 2026 01:09
@sasagar sasagar merged commit 5730b64 into dev Jan 28, 2026
5 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Jan 28, 2026
2 tasks
@sasagar sasagar mentioned this pull request Feb 1, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments