チャンネルとイベント: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_STORY、UPDATE_STORY_ARGS)、アクションの完了を通知するイベントは過去形(CURRENT_STORY_WAS_SET、STORY_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します。たとえばinitFileSearchChannelはFILE_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の統合まで、一連の流れを丁寧に解説します。