postMessage の使い方と契約設計: 3世代を比較
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 payload | JSON Schema (safie.link/schemas/...) | origin !== this._targetOrigin return (子は ACK まで *) | "syn" → "ack" → "connected" + 鍵交換 | AES-GCM (親 → 子のみ) |
3世代あります。Vimeo が最小構成、YouTube が状態同期を加えたもの、Safie がハンドシェイク + 暗号化まで含む最重量。SDK が扱うデータの機密性と、親子で共有する状態の量で必要な世代が決まります。

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通だけです。

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 が保険として効きます。

Vimeo や YouTube はここまでやっていません。代わりに実装内の event === "ready" のような文字列判定と、内部 TypeScript 型で通しています。iframe 型の3製品を並べると、機密度が上がるほどランタイム検証まで持ち込む傾向が見えます。3例からの推論なので、傾向以上のことは言えません。
自作するときの決断ポイント
3世代のどこに位置を取るかは、以下で決められます。
-
親子で状態を共有するか
共有しない (Vimeo 型: method 呼び出しと event 通知だけ) → 準備完了を待つpingと"ready"程度で足ります。
共有する (YouTube 型: 初期状態を親から流す) →"listening"相当の準備完了通知を受けてから、初期状態を流す手順が要ります。 -
ペイロードの機密性はどの水準か
公開情報のみ → 暗号化不要。
受信側の window で動く別スクリプトに読まれたら困る → 暗号化を検討する。鍵は同じpostMessageに乗せない。 -
メッセージ構造を機械可読で公開するか
.d.ts配布で十分な閉じたクライアント → TypeScript 型のみ。
サードパーティが独自クライアントを書く可能性がある → JSON Schema をランタイム検証込みで。 -
Origin 検証の厳密性
iframe オリジンが構築時に確定 → 等値比較 (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。プロトコルの語彙とハンドシェイク形状は年単位で安定します。ペイロード仕様は動きます。
この記事は役に立ちましたか?