Read OSS

チャンネルとイベント:Storybookを支えるコミュニケーションプロトコル

中級

前提知識

  • 第1回:アーキテクチャ概要
  • Event Emitterパターンの理解
  • PostMessageとWebSocket APIの基礎知識

チャンネルとイベント:Storybookを支えるコミュニケーションプロトコル

第1回では、StorybookがNode.jsサーバー・Manager UI・Previewのiframeという3つの独立した環境で動作することを確認しました。第2回では、presetシステムがサーバー上でどのように設定を組み立てるかを見ました。では、ブラウザが読み込まれた後、これらの環境はどうやって互いにやり取りするのでしょうか?サイドバーでストーリーをクリックしたとき、Previewのiframeはそれをどのようにして知るのでしょうか?

その答えが、Storybookのチャンネルシステムです。トランスポートをプラグインとして差し替えられるevent emitterで、通信の中核を担います。100種類を超えるイベントタイプを持ち、ストーリーの選択からargs変更、テレメトリのエラー報告まで、あらゆる処理を賄えるプロトコルになっています。

ChannelクラスとTransport抽象

Channelクラスの本質は、ブラウザ互換のevent emitterです。メッセージの配信は、1つまたは複数のトランスポートオブジェクトに委譲されます。

code/core/src/channels/main.ts#L22-L147

classDiagram
    class Channel {
        -sender: string
        -events: EventsKeyValue
        -data: Record~string, any~
        -transports: ChannelTransport[]
        +emit(eventName, ...args)
        +on(eventName, listener)
        +once(eventName, listener)
        +off(eventName, listener)
        +last(eventName): any
        +hasTransport: boolean
    }
    class ChannelTransport {
        <<interface>>
        +send(event, options)
        +setHandler(handler)
    }
    class PostMessageTransport {
        +send(event, options)
        +setHandler(handler)
    }
    class WebsocketTransport {
        +send(event, options)
        +setHandler(handler)
    }
    Channel --> "*" ChannelTransport
    ChannelTransport <|.. PostMessageTransport
    ChannelTransport <|.. WebsocketTransport

Channelインスタンスは、ランダムなsender IDを生成します(25行目)。emit()が呼び出されると、イベントタイプ・引数・sender IDを持つChannelEventを作成し、すべてのトランスポートとローカルリスナーの両方に配信します。この二重ディスパッチにより、Managerがemitしたイベントは、Previewにはトランスポート経由で届き、Managerのローカルリスナーにも同時に届きます。

last()メソッド(84行目)は、見逃しがちながら便利な機能です。イベントタイプごとに最後の引数をキャッシュするため、後から加わったリスナーが次のemissionを待たずに最新の状態を取得できます。

PostMessageとWebSocketのトランスポート

createBrowserChannel()ファクトリは、環境に応じて適切なトランスポートを組み立てます。

code/core/src/channels/index.ts#L32-L51

flowchart TB
    CBC["createBrowserChannel({ page })"] --> PM["PostMessageTransport"]
    CBC --> DevCheck{"CONFIG_TYPE === 'DEVELOPMENT'?"}
    DevCheck -->|Yes| WS["WebsocketTransport"]
    DevCheck -->|No| NoWS["(no WebSocket)"]

    PM --> Channel["Channel instance"]
    WS --> Channel

    subgraph "Communication Paths"
        direction LR
        ManagerFrame["Manager<br>(parent frame)"] <-->|PostMessage| PreviewFrame["Preview<br>(iframe)"]
        ManagerFrame <-->|WebSocket| Server["Node Server"]
        PreviewFrame <-->|WebSocket| Server
    end

PostMessageは常に存在します。iframeと親フレーム間の通信を担当し、ManagerとPreviewはそれぞれpage: 'manager'またはpage: 'preview'を指定して独自のチャンネルを作成します。

WebSocketは開発モードのみ追加されます(35行目)。サーバーのWebSocketエンドポイント/storybook-server-channel?token=<wsToken>に接続します。このトークンはサーバー側で生成されてHTMLページに埋め込まれるため、不正な接続を防ぐことができます。

本番(静的)ビルドにはサーバーがないため、PostMessageのみが利用可能です。つまり、ファイル検索やストーリー作成などサーバーに依存する機能は開発時限定になります。

Tip: サーバーサイドの機能を必要とするaddonを作る場合は、サーバーのイベントに依存する前に、WebSocketトランスポートが利用可能かどうかを必ず確認しましょう。静的デプロイ環境では、それらのイベントは一切届きません。

イベントの語彙

core-events/index.tsファイルに、イベントの全語彙が定義されています。

code/core/src/core-events/index.ts#L1-L103

これらのイベントはいくつかのカテゴリに分類されます。

flowchart TD
    Events["Core Events (~50 named)"] --> Lifecycle["Story Lifecycle"]
    Events --> State["State Mutation"]
    Events --> UI["UI Coordination"]
    Events --> ServerFeatures["Server Features"]

    Lifecycle --> L1["STORY_SPECIFIED"]
    Lifecycle --> L2["STORY_PREPARED"]
    Lifecycle --> L3["STORY_RENDERED"]
    Lifecycle --> L4["STORY_FINISHED"]
    Lifecycle --> L5["STORY_RENDER_PHASE_CHANGED"]

    State --> S1["UPDATE_STORY_ARGS / STORY_ARGS_UPDATED"]
    State --> S2["UPDATE_GLOBALS / GLOBALS_UPDATED"]
    State --> S3["SET_INDEX"]

    UI --> U1["SET_CURRENT_STORY / CURRENT_STORY_WAS_SET"]
    UI --> U2["SELECT_STORY"]
    UI --> U3["PREVIEW_KEYDOWN"]

    ServerFeatures --> SF1["FILE_COMPONENT_SEARCH_*"]
    ServerFeatures --> SF2["CREATE_NEW_STORYFILE_*"]
    ServerFeatures --> SF3["SAVE_STORY_*"]
    ServerFeatures --> SF4["OPEN_IN_EDITOR_*"]

命名規則に一定のパターンがあります。アクションを要求するイベントは命令形(SET_CURRENT_STORYUPDATE_STORY_ARGS)、アクションの完了を通知するイベントは過去形(CURRENT_STORY_WAS_SETSTORY_ARGS_UPDATED)が使われます。サーバー機能には、リクエスト/レスポンスのペアパターン(FILE_COMPONENT_SEARCH_REQUEST / FILE_COMPONENT_SEARCH_RESPONSE)が採用されています。

イベントフローの追跡:ストーリーを選択する

サイドバーで「Button/Primary」をクリックしたときの流れを追ってみましょう。

sequenceDiagram
    participant User
    participant Sidebar as Manager: Sidebar
    participant API as Manager: stories module
    participant Channel as Channel (PostMessage)
    participant PWS as Preview: PreviewWithSelection
    participant SR as Preview: StoryRender

    User->>Sidebar: Click "Button/Primary"
    Sidebar->>API: api.selectStory('button--primary')
    API->>API: Navigate URL, update state
    API->>Channel: emit(SET_CURRENT_STORY, { storyId, viewMode })
    Channel->>PWS: onSetCurrentStory({ storyId, viewMode })
    PWS->>PWS: Update selection, create StoryRender
    PWS->>SR: render.prepare()
    SR->>SR: Load story from StoryStore
    SR->>Channel: emit(STORY_PREPARED, { id, parameters, ... })
    Channel->>API: Handle STORY_PREPARED
    API->>API: Update story data in state
    SR->>SR: renderToElement(canvasElement)
    SR->>Channel: emit(STORY_RENDERED, storyId)
    Channel->>API: Handle STORY_RENDERED

ここで重要なのは、Managerがどう描画するかをPreviewに直接伝えていない点です。Managerはストーリー IDとともに宣言的なSET_CURRENT_STORYイベントを送るだけで、Previewは独立してストーリーをロード・準備・描画します。状態の更新はチャンネルを通じてManagerに返ってくるため、ManagerはUI(ローディング状態やaddonパネルのパラメータ表示など)を適切に更新できます。

この宣言的なパターンにより、ManagerとPreviewは独立して進化できます。Previewはサイドバーについて知る必要がなく、Managerは描画の内部実装について知る必要がありません。

サーバーチャンネル

ファイルシステムへのアクセスが必要な機能には、WebSocketトランスポートを使ったサーバーからブラウザへの通信が使われます。

共通presetがすべてのサーバーチャンネルハンドラーを初期化します。

code/core/src/core-server/presets/common-preset.ts#L271-L288

export const experimental_serverChannel = async (channel, options) => {
  initializeChecklist();
  initializeWhatsNew(channel, options, coreOptions);
  initializeSaveStory(channel, options, coreOptions);
  initFileSearchChannel(channel, options, coreOptions);
  initCreateNewStoryChannel(channel, options, coreOptions);
  initGhostStoriesChannel(channel, options, coreOptions);
  initOpenInEditorChannel(channel, options, coreOptions);
  initTelemetryChannel(channel, options);
  return channel;
};

init*関数はリクエストイベントのリスナーを登録し、レスポンスイベントをemitします。たとえばinitFileSearchChannelFILE_COMPONENT_SEARCH_REQUESTを待ち受け、プロジェクトのglobパターンでファイルシステムを検索して、結果をFILE_COMPONENT_SEARCH_RESPONSEとしてemitします。

サーバーチャンネルはSTORY_INDEX_INVALIDATEDイベントも処理します。ディスクのファイルが変更されると、サーバーはstoryインデックスのキャッシュを無効化し、イベントをブロードキャストします。ManagerとPreviewはこれを受け取り、インデックスを再取得します。

Universal Store:環境をまたいだ状態同期

チャンネルシステムの上に構築されたUniversalStoreは、環境間で状態を同期するための新しい抽象化です。

code/core/src/shared/universal-store/index.ts#L82-L120

UniversalStoreはリーダー/フォロワーパターンに従います。

sequenceDiagram
    participant Leader as Leader (Server)
    participant Channel as Channel
    participant Follower1 as Follower (Manager)
    participant Follower2 as Follower (Preview)

    Leader->>Channel: LEADER_CREATED
    Follower1->>Channel: FOLLOWER_CREATED
    Follower1->>Channel: EXISTING_STATE_REQUEST
    Leader->>Channel: EXISTING_STATE_RESPONSE { state }
    Follower1->>Follower1: Set state, resolve syncing

    Follower2->>Channel: FOLLOWER_CREATED
    Follower2->>Channel: EXISTING_STATE_REQUEST
    Leader->>Channel: EXISTING_STATE_RESPONSE { state }
    Follower2->>Follower2: Set state, resolve syncing

    Note over Leader,Follower2: Normal operation
    Follower1->>Channel: SET_STATE { newState }
    Leader->>Leader: Update local state
    Leader->>Channel: Forward SET_STATE to other followers
    Follower2->>Follower2: Update local state

リーダー(通常はサーバー)が正確な状態を保持します。ManagerとPreviewのフォロワーは、作成時に自動的に同期されます。EXISTING_STATE_REQUESTを送信し、最大1秒間レスポンスを待ちます。リーダーが応答しない場合、フォロワーはエラー状態に入ります。

フォロワーからの状態変更はリーダーを経由し、リーダーが他のすべてのフォロワーに転送します。このハブ&スポーク型のトポロジーにより、フォロワー同士が直接通信しなくても一貫性が保たれます。

UniversalStore.__prepare()静的メソッドはチャンネル作成時に呼び出され(channels/index.tsの45〜48行目)、ストアをチャンネルにバインドして環境タイプを設定します。この処理は自動的に行われるため、チャンネル準備前に作成されたストアインスタンスはキューに積まれ、準備完了後に初期化されます。

Tip: Universal Storeは、比較的小さくて頻繁に更新される状態(フィーチャーフラグやツールバーの設定など)向けに設計されています。storyインデックスのような大きなデータセットには、SET_INDEXを使う従来のイベントベースのパターンが引き続き適しています。

次回予告

チャンネルシステムを理解したことで、通信レイヤーの全体像が見えてきました。presetがビルド時に設定を組み立て、チャンネルがランタイムにイベントを運ぶ、この2つが揃っています。次回は、イベントがPreviewのiframeに届いてからの完全な描画パイプラインを追います。Viteが生成するvirtual moduleからCSFの処理、設定の合成、StoryRenderのライフサイクル、そしてフレームワークrendererの統合まで、一連の流れを丁寧に解説します。