← シリーズに戻る

postMessage の使い方と契約設計: 3世代を比較

Part 3 / 6 JavaScript SDK 設計: 実在5製品のバンドル分解

iframe 隔離と CSP nonce: 2.61MB を別オリジンへ で Safie の iframe 隔離を分解しました。iframe 内には自社の CSP と自社の JS が動いていて、親子は postMessage で会話しています。今回はその「会話の中身」を設計する話です。5製品のうち Vimeo・YouTube・Safie の3つが iframe 型なので、この3つのプロトコルを並べて読みます。

プロトコル早見表

2026-08-08 時点で各バンドルから抽出した実装です。

製品メッセージ形式型検証Origin検証ハンドシェイク暗号化
Vimeo{method, value, event, data}実装内 === "ready" 等vimeo.com 系ドメインの判定 + event.source 照合、初回の origin を固定"ping" → "ready" (ready まで呼び出しを待たせる)なし
YouTube{event, id, channel, data}内部 switch 分岐.origin === k.O 等値比較"listening" → "initialDelivery"なし
Safie{type, data, guid} + wrapped payloadJSON Schema (safie.link/schemas/...)origin !== this._targetOrigin return (子は ACK まで *)"syn" → "ack" → "connected" + 鍵交換AES-GCM (親 → 子のみ)

3世代あります。Vimeo が最小構成、YouTube が状態同期を加えたもの、Safie がハンドシェイク + 暗号化まで含む最重量。SDK が扱うデータの機密性と、親子で共有する状態の量で必要な世代が決まります。

iframe 型3製品で、親 (顧客サイトの SDK) と子 (iframe) が最初に交わすメッセージを並べた図。Vimeo (世代1 最小): 親から子へ ping、子から親へ ready。ready まで play() などを SDK の中で待たせ、以後は親から子へ {method, value}、子から親へ {event, data}。origin 検証は vimeo.com 系か + event.source で、初回の origin を固定。暗号化なし。YouTube (世代2 状態同期): 子から親へ listening、親から子へ initialDelivery で動画 ID・自動再生・字幕の初期状態を流し、以後は親から子へ command、子から親へ event。origin 検証は等値比較 event.origin === k.O。暗号化なし。Safie (世代3 暗号化まで): 子から親へ syn (鍵を渡す)、親から子へ暗号化した ack、子から親へ connected。以後は親から子の命令を AES-GCM で暗号化。origin 検証は等値比較で、子は ACK までは "*"。暗号化は AES-GCM (親から子のみ)

Vimeo: 最小構成

Vimeo のバンドル(24 KB / gzip 8 KB)から postMessage まわりを抜き出すと、驚くほど短いです。

// 抜粋 (整形済み)
postMessage(e, t.origin)

// 受信側
event.data.event === "ready"
event.data.event === "spacechange"
event.data.event === "drminitfailed"

{method, value} を投げて {event, data} を受け取る構造で、method 呼び出しがそのままメッセージになります。ハンドシェイクは軽いものが1つだけあります。new Vimeo.Player() が iframe に ping を送り、子から "ready" イベントか ping の応答が返るまで、play() などの呼び出しを SDK の中で待たせます。ready 待ちを Promise の中に隠しているので、顧客は callback を書かずに済みます。

Origin 検証は少し変わっています。

// 受信側 (整形済み)
if (isVimeoUrl(event.origin) && this.element.contentWindow === event.source) {
  if (this.origin === "*") this.origin = event.origin
  // ...
}

「初回メッセージで受け取った origin を以後の送信先に固定する」パターンで、ここでは first-message trust と呼びます。iframe の実際のオリジンが構築時点でわからない場合 (動的サブドメイン等) への実用的な妥協です。ただし無条件ではありません。origin が vimeo.com 系のドメインかを正規表現で確かめ、送り主が自分の iframe の contentWindow であることも照合してから固定します。別の iframe が先に喋っても event.source が合わないので、乗っ取られる余地は小さい作りです。宛先を絞っていないのは、origin が決まる前に送る最初の ping の1通だけです。

親がメッセージを受け取ったときに通す条件を Vimeo と YouTube で並べたフロー図。Vimeo (first-message trust): メッセージが届くと、origin は vimeo.com 系のドメインか (正規表現で判定) を見て違えば捨て、送り主は自分の iframe か (contentWindow === event.source) を見て違えば捨て、送信先がまだ "*" ならこの origin に固定してから処理する。YouTube (等値比較): event.origin === k.O (k.O は iframe を作ったときに覚えた自社オリジン) を見て、違えば捨て、一致すれば処理する。下に「Vimeo が宛先を "*" のまま送るのは、origin が決まる前の最初の ping 1通だけ」

YouTube: 状態同期を足したもの

YouTube widgetapi (27 KB) は Vimeo より一世代新しいプロトコルです。

// 語彙
"listening"      // 子から親へ「準備できた」
"initialDelivery" // 親から子へ「初期状態はこれ」
"command"        // 親から子へメソッド呼び出し
"event"          // 子から親へ状態変化通知

"listening" が実質的なハンドシェイクの片側で、iframe 側の JS 初期化が終わったタイミングで親に送られます。親はそれを見てから "initialDelivery" で動画 ID・自動再生・字幕設定を子に流し、以後 "command" で再生・停止を、"event" で状態変化を受け取ります。

Origin 検証は等値比較で厳密です。

event.origin === k.O

k.O は iframe を作った時点で覚えておいた自社オリジン(通常 https://www.youtube.com)で、リクエスト毎に検証されます。Vimeo の first-message trust と比べて、コードは短いのに硬い設計です。ハンドシェイクが片側だけなのは、YouTube が扱うデータ(動画メタデータ、再生位置)がそもそも公開情報だから、盗聴対策より状態同期の正確さが優先だからと読めます。

Safie: 最重量、機密データ前提

Safie は iframe 隔離と CSP nonce: 2.61MB を別オリジンへ で分解したとおりです。要点だけ抜き出します。

// 子 (iframe 本体) → 親: 秘密鍵と wrap 済みの AES 鍵を一緒に送る
postMessage("syn", { privateKey: a.privateKey, wrappedAesKey: c.wrappedAesKey })
// 親 → 子: AES-GCM で暗号化した ACK
postMessage("ack", { id, name })
// 子 → 親
postMessage("connected")
// origin 検証 (子は ACK まで "*")
if (this._targetOrigin !== "*" && event.origin !== this._targetOrigin) return
// 以後、親 → 子のペイロードは AES-GCM で暗号化

TCP 3-way handshake の postMessage 版に、非対称鍵経由の AES-GCM 鍵交換を接ぎ木した形です。ただし秘密鍵と wrap 済みの鍵が同じメッセージに乗るので、SYN を受け取れる相手は復号できます。傍受への防御というより、鍵を持たない相手のメッセージを子が捨てる整合性確認と、認証トークンを平文で流さない難読化に近いものです。

Safie がここまで組むのは、扱うデータが「録画映像へのアクセス権を含む」からだと私は読んでいます。動画プレイヤーの currentTime が漏れても実害は小さい。カメラ映像 SDK の認証トークンが漏れると、契約カメラを他人がライブ視聴できる状態が発生します。プロトコル世代の選択は、SDK が扱うデータの経済的・法的価値で決まります。

メソッド定義を JSON Schema にしてバンドルに同梱する

Safie の loader には safie.link/schemas/invoke-method/ の URL が並んでいます。

safie.link/schemas/invoke-method/auth/set-token
safie.link/schemas/invoke-method/devices/query-devices
safie.link/schemas/invoke-method/streaming/start-ptz

これらは loader に同梱された JSON Schema (draft-07) の $id で、postMessage で送るメッセージの構造を機械可読で定義しています。URL は名前として付いているだけで、開いても 404 が返ります (2026-10-01 に確認)。スキーマ本体はバンドルの中にあり、届いた invoke をランタイムに検証して、外れたら invalid_params のエラーを返します。想定外の型が来ても、その場で弾けます。TypeScript の .d.ts はエディタ支援に効きますが、親子で別ドメインの JS が動く SDK では両側で同じ型定義を確実に共有するのが難しく、ランタイム検証の JSON Schema が保険として効きます。

型の契約の置き方3つを、開発時と実行時のどちらで効くかで比べた表の図。題は「型の契約は、効く時点が違う」。親と子は別ドメインの JS で、iframe 越しに実際に届いた値を弾けるのは実行時の検証だけ。TypeScript の型 (.d.ts を配る) は、開発時 (エディタ・コンパイル) に補完と型エラーが出るが、親と子で同じ版を共有している前提で、実行時 (iframe 越しに値が届いたとき) には消えていて、何が届いても型では止まらない。JSON Schema (Safie、safie.link/schemas/…) は、開発時にはスキーマをバンドルに同梱して $id の URI で名前を付けており、実行時に届いた値をスキーマで検証し、外れたら invalid_params のエラーを返す。実装内の文字列判定 (Vimeo・YouTube) は、開発時は内部の TypeScript 型で、実行時は event === "ready" などで分岐し、知らない値はどの分岐にも入らない。結論は「第三者が独自のクライアントを書くなら、実行時の検証まで持ち込む (Safie の側)」。Safie のスキーマと検証は safie.link/sdk/js/api/v1/latest/ の loader (2026-10-01 取得、$id の URL は 404)、ほかは本文 (2026-08-08 の各バンドル)

Vimeo や YouTube はここまでやっていません。代わりに実装内の event === "ready" のような文字列判定と、内部 TypeScript 型で通しています。iframe 型の3製品を並べると、機密度が上がるほどランタイム検証まで持ち込む傾向が見えます。3例からの推論なので、傾向以上のことは言えません。

自作するときの決断ポイント

3世代のどこに位置を取るかは、以下で決められます。

  1. 親子で状態を共有するか
    共有しない (Vimeo 型: method 呼び出しと event 通知だけ) → 準備完了を待つ ping と "ready" 程度で足ります。
    共有する (YouTube 型: 初期状態を親から流す) → "listening" 相当の準備完了通知を受けてから、初期状態を流す手順が要ります。

  2. ペイロードの機密性はどの水準か
    公開情報のみ → 暗号化不要。
    受信側の window で動く別スクリプトに読まれたら困る → 暗号化を検討する。鍵は同じ postMessage に乗せない。

  3. メッセージ構造を機械可読で公開するか
    .d.ts 配布で十分な閉じたクライアント → TypeScript 型のみ。
    サードパーティが独自クライアントを書く可能性がある → JSON Schema をランタイム検証込みで。

  4. Origin 検証の厳密性
    iframe オリジンが構築時に確定 → 等値比較 (YouTube 型)。
    sandbox やサブドメイン切替が絡む → first-message trust (Vimeo 型) を選ぶ場合あり。ただしトレードオフあり。

プロトコルの世代を決める4つの問いを、条件が無い場合と付く場合の2択で並べた図。1 親子で状態を共有するか: 共有しない (method と event だけ) なら ping と "ready" 程度で足りる (Vimeo 型)、共有する (初期状態を親から流す) なら準備完了の通知を受けてから初期状態を流す (YouTube 型)。2 ペイロードの機密性: 公開情報のみなら暗号化しない、別スクリプトに読まれたら困るなら暗号化を検討し、鍵は同じ postMessage に乗せない。3 メッセージ構造を機械可読で公開するか: 閉じたクライアントなら TypeScript の型 (.d.ts) だけ、第三者が独自クライアントを書くなら JSON Schema でランタイム検証 (Safie)。4 origin 検証の厳しさ: iframe の origin が構築時に決まるなら等値比較 (YouTube 型)、sandbox やサブドメイン切替が絡むなら first-message trust を選ぶ場合も (Vimeo 型、トレードオフあり)

最小構成で足りる場合の実装例も置いておきます。iframe の高さを子から親へ通知するだけの用途なら、送り主を event.source と contentWindow の一致で特定し、受け取った数値を Number.isFinite と上限クランプに通せば足ります。URL で照合する実装は、同じページに2箇所埋め込まれた時点で壊れます。YAMLを1枚書くと企業サイト風の年表ページになるOSSで書いた embed.js がこれにあたります。

次: 版と cache-control

プロトコルを固定したら、次はバージョン戦略です。5製品で cache-control ヘッダーと URL 構造をそろえて並べると、iframe 型と DOM 直挿し型で戦略が真っ二つに割れます。版を選べる Video.js・Mux Player と、選ばせない Safie・YouTube・Vimeo。この分岐の理由を次で扱います。

実測日は 2026-08-08。プロトコルの語彙とハンドシェイク形状は年単位で安定します。ペイロード仕様は動きます。