← シリーズに戻る

埋め込みコードを自作する: 最小 SDK を6決断で組む

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

シリーズを通して5製品を分解しました。ここで一度、逆に組み立て直します。想定は「顧客サイトに <script> 1行貼ると、チャットウィジェットが右下に浮かぶ」という最小 SDK です。分解で見えた6つの決断を、実装で明示的に選んでいきます。

最小の埋め込みチャット SDK の設計図。顧客サイト・SDK iframe (自社オリジン)・自社バックエンドの3つの領域を postMessage と HTTPS / WSS でつなぎ、受け取るときに origin と event.source を照合する位置と、6つの決断がそれぞれどこに効くかを示した図

想定要件

  • 顧客サイト (example.com) に <script src="https://sdk.myapp.com/v1/latest/"> 1行貼るだけで動く
  • 右下にチャットボタンが浮かび、クリックすると会話ウィンドウが開く
  • 顧客サイトの CSS・JS・Cookie に干渉しない
  • 会話ログを自社サーバー (api.myapp.com) と送受信する
  • チャット履歴の暗号化は要件に含めない (機密度は動画メタデータと同程度)

決断1: iframe 型 vs DOM 直挿し型

顧客サイトの CSS・JS に干渉しない要件があるので iframe 型 です。顧客が Tailwind と Bootstrap を混在させていても、こちらの UI に影響しません。認証 Cookie を myapp.com オリジンに閉じ込められる副次効果もあります。代替案の DOM 直挿し (Web Components) を選ぶと、shadow DOM で CSS は隔離できますが、JS 側の window は共有します。顧客サイトが window.fetch を書き換えていた場合の副作用が読めません。チャットウィジェットは要件が閉じているので、iframe を選んで丸ごと隔離するのが単純です。

Web Components (DOM 直挿し + shadow DOM) と iframe 型で、顧客サイトの CSS・JS・Cookie のうち SDK まで届くものを比べた図。題は「shadow DOM でも window は共有する。iframe は window ごと分ける」。Web Components では、顧客のページ example.com の window の中に顧客の CSS と顧客の JS があり、SDK の UI は shadow root の中にあるが、SDK の JS は顧客と同じ window で動く。CSS は届かない (shadow DOM が遮る)。window は共有するので、書き換えた window.fetch がそのまま効く。Cookie は顧客のオリジン example.com で動く。iframe 型 (この章で選ぶもの) では、顧客のページの中の iframe が sdk.myapp.com の別のオリジン、別の window になる。CSS は届かない。window は別で、顧客の書き換えは届かない。認証 Cookie を myapp.com に閉じ込める

決断2: loader / 本体の分離

Loader を小さくして初速を稼ぐか、単一ファイルで済ませるか。今回は 分離 を選びます。

  • loader (https://sdk.myapp.com/v1/latest/): 5KB 程度、iframe を生成して postMessage の橋渡しだけ
  • 本体 (loader が読み込む実際のコード。https://sdk.myapp.com/v1/lib/iframe.html から始まる): UI 一式、~300KB

分離する主な理由は cache-control 戦略のためです (決断5)。loader は毎回取り直させて版を切り替え可能に、本体は hash URL で長期キャッシュ。単一ファイルにするとこの分離ができません。

決断3: CSP と iframe 内隔離

iframe.html のレスポンスヘッダー:

content-security-policy:
  default-src 'self';
  script-src 'self' 'nonce-<per-request-random>';
  style-src  'self' 'nonce-<per-request-random>';
  connect-src 'self' https://api.myapp.com wss://api.myapp.com;
  img-src    'self' data: blob:;
  • script-src 'self' 'nonce-...': iframe.html を配るサーバーが per-request nonce を発行、iframe 内の <script nonce="..."> と一致するもののみ実行
  • connect-src を api.myapp.com に限定: 万一 XSS を踏んでも、他ドメインに情報を持ち出せない
  • frame-ancestors は指定しない (顧客サイトから埋め込まれる前提)

per-request nonce の実装は、iframe.html を動的にヘッダー付きで配るサーバーが要ります。静的ホスティング (S3 + CloudFront) だけでは組めません。Cloudflare Workers や CDN の Edge Functions を挟むことになります。

per-request nonce はレスポンスヘッダーと HTML の両方に毎回違う値を書くことを、Safie の実例で示した図。題は「nonce はヘッダーと HTML の両方に、毎回違う値を書く」。safie.link の lib/iframe.html を 2026-10-01 に続けて2回取ると (nonce は先頭8文字)、1回目はレスポンスヘッダーが script-src 'self' 'nonce-0cf50fa7…' で HTML の本文が script nonce="0cf50fa7…"、2回目はヘッダーが 'nonce-3bef7cc4…' で本文も nonce="3bef7cc4…"。静的ホスティングだけ (S3 + CloudFront) は毎回同じバイトを返すので nonce が固定になり、知られたら意味がない。Workers・Edge Functions を挟むと、リクエストごとに乱数を作ってヘッダーと HTML に差し込める。この章の iframe.html は no-cache で配る。動的に配るのは iframe.html だけで、ハッシュ入りの main-….js と styles-….css は静的なまま immutable で配れる

決断4: postMessage 契約

親子で状態は共有しません (loader は iframe を作って postMessage の橋を張るだけ、状態は iframe 側)。ハンドシェイクは片側の “ready” 通知のみで足ります。

プロトコル:

// 子 → 親 (iframe ロード完了)
{ type: "ready" }

// 親 → 子 (顧客が SDK メソッド呼び出し)
{ type: "cmd", method: "open", params: {}, id: <number> }

// 子 → 親 (メソッド結果)
{ type: "res", id: <number>, result: any, error: string | null }

// 子 → 親 (状態変化通知)
{ type: "event", name: "message-received", data: {...} }

origin 検証は等値比較、送り主は event.source で照合:

// iframe 側
window.addEventListener("message", (event) => {
  if (event.origin !== "https://example.com") return; // 顧客サイトのオリジン (loader 経由で伝達)
  if (event.source !== window.parent) return;         // 送り主が自分を埋め込んだ親か
  // ペイロード処理
});

同じページにウィジェットが2つ埋め込まれるとどちらの iframe も origin は https://sdk.myapp.com になり、origin だけでは自分の iframe からの返事かを区別できないので、loader 側も origin に加えて event.source === iframe.contentWindow を見ます。

同じページにウィジェットを2つ埋め込むと origin が同じになり、event.source で区別することを示した図。顧客サイト example.com にウィジェット A と B があり、それぞれ iframe A と iframe B (どちらも sdk.myapp.com) を持つ。message イベントは window で受けるので、iframe B が送った返事が A の listener にも届く。A の listener が通す条件は、event.origin === "https://sdk.myapp.com" (A も B も同じオリジンなので通ってしまう) と、event.source === iframe.contentWindow (送り主は iframe A の window か。B なので捨てる)。origin は「どこのサイトか」、source は「どの窓か」を見ている。iframe 側も event.source === window.parent を照合する

顧客サイトのオリジンは loader が iframe に伝えます。具体的には loader が iframe.src = ".../iframe.html#" + encodeURIComponent(location.origin) の形で hash に埋め込み、iframe 側の JS が起動時に location.hash を parse します。

first-message trust は選びません。攻撃側 iframe が先に喋る可能性を残すため、初期実装のうちは等値比較の硬い設計で行きます。

暗号化は不要: loader は targetOrigin を指定して iframe の window に直接送るので、兄弟 iframe や別タブには届きません。読まれうるのは顧客ページで動く別スクリプトが親の window に listener を足した場合ですが、そのスクリプトは postMessage や crypto.subtle そのものを書き換えられるので、ページ内の暗号化で守り切るのは難しいと考えます (推論で、試してはいません)。チャット履歴の機密度は動画プレイヤーと同程度と判断し、暗号化は入れません。

決断5: cache-control 戦略

Loader は「バージョニング契約」なので毎回取り直させます。本体は hash URL で1年キャッシュします。

資源URLcache-control
loaderhttps://sdk.myapp.com/v1/latest/no-cache
iframe.htmlhttps://sdk.myapp.com/v1/lib/iframe.htmlno-cache (nonce ヘッダー付きで動的配信)
iframe 本体 JShttps://sdk.myapp.com/v1/lib/main-<hash>.jspublic, max-age=31536000, immutable
iframe 本体 CSShttps://sdk.myapp.com/v1/lib/styles-<hash>.csspublic, max-age=31536000, immutable

顧客に版を選ばせません。loader を毎回取り直して自社側で常に最新の本体 hash を吐き出します。iframe 型を選んだ時点でこの戦略が自然になる理由は、cache-control 設定と版固定: 5製品で戦略が割れる で扱いました。

決断6: 初期化スタイル

命令的で作ります。

<!-- 顧客が書くコード -->
<script src="https://sdk.myapp.com/v1/latest/" async></script>
<script>
  window.MyAppChat = window.MyAppChat || [];
  window.MyAppChat.push(["init", { apiKey: "..." }]);
  window.MyAppChat.push(["setUser", { id: "user123" }]);
</script>

遅延実行キュー パターンです。SDK ロード完了前に呼ばれた命令を配列 (MyAppChat) に蓄えておき、SDK がロード完了したら配列を巻き戻して順次実行します。callback を書かせない分、顧客側のコードが同期的に書けます。Google Analytics の gtag などが使うパターンで、SPA でも静的 HTML でも動きます。

この章で組む最小のチャット SDK で、起動してから命令が届くまでのメッセージの順番を示したシーケンス図。loader (親、example.com) と iframe (子、sdk.myapp.com) の2本の線と、左に顧客のコード。1 顧客は SDK が来る前から MyAppChat.push で init 命令を書け、loader の配列に積まれる。2 loader は顧客のオリジンを iframe.html#https%3A%2F%2Fexample.com の hash で渡し、子は hash を読んで照合に使う (これは postMessage ではなく iframe の URL)。3 子から親へ {type: "ready"}。4 親は積んでいた命令を順に {type: "cmd", method, params, id} で送る。5 ready の後の push は積まずにすぐ {type: "cmd", method, params, id} で送る。6 子から結果が同じ id で {type: "res", id, result, error} として返る。7 子から新着などの通知が {type: "event", name, data} で来る。型は決断4の定義

Web Components (<my-chat>) は今回選ばない: iframe 型なので Web Components の隔離メリット (shadow DOM) を重ねる意味が薄く、Web Components 非対応環境を切る必要も無いためです。

二重ロード対策:

// loader の先頭 (下の骨格実装のように IIFE の中に置く。関数の外の return は構文エラー)
if (window.__MyAppChatLoaded) return;
window.__MyAppChatLoaded = true;

これで顧客が誤って <script> を2回書いても、2回目は即 return します。止めるのは SDK の定義 (window.MyAppChat の差し替え) が2回走ることだけで、ウィジェットは init のたびに1つ作ります。同じページに2つ置くなら、init を2回書きます (下の骨格実装)。

骨格実装

loader (擬似コード):

(function() {
  // 1回にするのは SDK の定義だけ。ウィジェットは init のたびに作る
  if (window.__MyAppChatLoaded) return;
  window.__MyAppChatLoaded = true;

  const parentOrigin = location.origin;
  const targetOrigin = "https://sdk.myapp.com";
  let nextId = 0; // res を同じ id で返してもらい、どの cmd の結果か対応づける

  // ウィジェット1つ分: iframe、ready の状態、ready 前に積んだ命令
  function createWidget() {
    const iframe = document.createElement("iframe");
    iframe.src = "https://sdk.myapp.com/v1/lib/iframe.html#" +
      encodeURIComponent(parentOrigin);
    iframe.style.cssText = "position:fixed;bottom:20px;right:20px;" +
      "border:none;width:60px;height:60px;z-index:2147483647";
    // 見える iframe は body に置く。loader が <head> で走って body がまだ無ければ待つ
    const mount = () => document.body.appendChild(iframe);
    if (document.body) mount();
    else document.addEventListener("DOMContentLoaded", mount);

    let ready = false;
    const queue = [];
    const send = (cmd) => iframe.contentWindow.postMessage(
      { type: "cmd", method: cmd[0], params: cmd[1], id: ++nextId },
      targetOrigin
    );

    window.addEventListener("message", (event) => {
      if (event.origin !== targetOrigin) return;
      if (event.source !== iframe.contentWindow) return; // 同じページの別ウィジェットと区別する
      if (event.data.type === "ready") {
        ready = true;
        queue.forEach(send); // 積まれていた命令を巻き戻す
      }
      // res / event の処理は省略
    });

    return { push: (cmd) => (ready ? send(cmd) : queue.push(cmd)) };
  }

  // init でウィジェットを1つ作り、以後の命令は直前に init したウィジェットへ送る
  let current = null;
  const push = (cmd) => {
    if (cmd[0] === "init") current = createWidget();
    if (current) current.push(cmd);
  };

  // SDK が来る前に積まれた命令を流し、以後の window.MyAppChat.push を intercept
  const early = window.MyAppChat || [];
  window.MyAppChat = { push };
  early.forEach(push);
})();

骨格実装の loader が1回にするものと、init のたびに作るものを分けて示した図。題は「1回にするのは SDK の定義だけ。ウィジェットは init のたびに作る」。script を2回書いても定義は1回で、init を2回書けば2つ並ぶ。左は同じ script が2本あるとき: 1本目が window.__MyAppChatLoaded = true にして SDK を定義し、2本目は if (window.__MyAppChatLoaded) return ですぐ抜けるので、定義の差し替えが2回走らない。1本目は、SDK が来る前に顧客が積んだ配列を window.MyAppChat = { push } に差し替え、中身を順に流す。以後の push はこの関数が受ける。右は顧客の push が届く先: init の命令でウィジェット A を作って以後の宛先を A にし、setUser の命令は A へ、次の init の命令でウィジェット B を作って以後の宛先を B にし、open の命令は B へ。ウィジェット A と B はそれぞれ iframe を1つ持ち、ready までは自分のキューに積む。本文の loader 擬似コードから描いたもので、動かして測ったものではない

iframe 本体 (擬似コード):

const parentOrigin = decodeURIComponent(location.hash.slice(1));

window.addEventListener("message", (event) => {
  if (event.origin !== parentOrigin) return;
  if (event.source !== window.parent) return;
  const { type, method, params, id } = event.data;
  if (type !== "cmd") return;
  handleCommand(method, params).then(
    (result) => event.source.postMessage(
      { type: "res", id, result, error: null }, parentOrigin
    ),
    (error) => event.source.postMessage(
      { type: "res", id, result: null, error: String(error) }, parentOrigin
    )
  );
});

// ロード完了通知
window.parent.postMessage({ type: "ready" }, parentOrigin);

これで最小構成の骨格が組めます。加えて必要になるのは、命令的 API のドキュメント化、エラーハンドリング、リトライ、アクセシビリティ (ARIA)、国際化、テレメトリー、CI/CD、CDN 設定です。ここに書いたのは意思決定の骨だけで、production はこの周辺に肉が付きます。

6つの決断まとめ

決断選んだもの主な理由
埋め込み形態iframe 型顧客の CSS/JS/Cookie から完全隔離
loader/本体の分離分離cache-control 戦略のため
CSP 隔離per-request nonce + connect-src 限定万一の XSS 時の外部持ち出し防止
postMessage 契約{type, method, params, id} + 等値 origin 検証 + event.source 照合硬く単純に、2箇所埋め込みでも混線させない
cache-controlloader は no-cache、本体は immutableiframe 型で顧客に版を選ばせない
初期化 API命令的 (遅延実行キュー)静的 HTML と SPA の両方で動く

シリーズ全体で扱った5製品と対応させると、この最小 SDK は「Safie の隔離思想 + YouTube 型 origin 検証 + Google Analytics 型初期化キュー」のハイブリッドになります。5製品それぞれの経済的・技術的制約から生まれた設計を、自分の要件に合わせて組み合わせ直すのがこのシリーズを読んだ後の使い方です。

自分ならどれか

この章のチャットウィジェットは、下の表のうち「干渉させたくない」「同じページに2箇所以上埋め込まれうる」「静的 HTML と SPA に分かれている」「どの形でも」の4つの状況に当たります。要件が変われば選ぶものも変わるので、状況ごとの選び方を表にしておきます。シリーズの入口のページに置いた表と同じものです。

状況選ぶもの効いている理由
顧客サイトの CSS・JS・Cookie と干渉させたくないiframe 型。loader と本体を分け、版は選ばせない顧客のスタイルや window.fetch の書き換えが届かない。loader を毎回取り直させれば、親子の版がずれない
顧客が自分で UI を組む、フレームワークに溶け込ませたいDOM 直挿し (Web Components)。版番号まで指定できる URL を顧客に選ばせる版を書いてもらえば、API が変わってもアップグレードの時期を顧客が選べる (Video.js は版が必須、Mux Player は書けば1年 immutable)
認証トークンや録画映像のように、漏れると困るものを扱うiframe の中に per-request nonce の CSP を張り、connect-src を自社に閉じる乗っ取られても外へ持ち出せない (Safie)。静的ホスティングだけでは組めない
親子で状態を共有する子の準備完了の合図を待ってから、初期状態を流すYouTube の listening → initialDelivery
同じページに2箇所以上埋め込まれうるorigin の等値比較に加えて event.source を照合する2つの iframe は origin が同じなので、origin だけでは自分の iframe か区別できない
顧客が JS を書かない静的 HTML や CMS初期化は data 属性Video.js の data-setup のように、貼るだけで動く
顧客の環境が静的 HTML と SPA に分かれている初期化は 命令的 + 遅延実行キューロード前の呼び出しを配列に積み、ロード後に巻き戻すので、どちらでも同じ書き方で動く
どの形でも二重ロード対策を最初のコミットから入れるタグマネージャはスニペットを2回発火する。5製品中4製品が入れている

上の2行で形態が決まると、版の方針もそこで決まります。

決断3で per-request nonce の CSP を入れたのは、「漏れると困るもの」の行ほどの機密ではなくても、会話ログを外へ持ち出されたくなかったからです。扱うものが認証トークンや録画映像になって暗号化を検討する場合も、決断4で書いたとおりページ内のスクリプトからは守り切れないので、少なくとも鍵を同じ postMessage に乗せない設計から考えることになると私は見ています (推論で、試してはいません)。

まとめと次の一歩

script 1行を貼るだけで何かが動く SDK は、裏で6つの決断を全部済ませています。5製品のバンドルを実際に読むと、その答え方が「経済的・技術的制約から逆算されて綺麗に分岐している」ことが見えました。自作するときは、要件を明確にしてから決断ごとに選ぶ、というのがシリーズ全体の主張です。

さらに深く行きたい方向:

  • 観測性: 顧客サイト側で window.onerror が汚れないよう、iframe 内でエラーを吸って自社サーバーに送る設計
  • A/B テスト: iframe 内で表示するバリエーションを自社側で切り替える
  • プライバシー: iframe 内で顧客サイトの Cookie にアクセスできないという性質を利用して、GDPR/CCPA 対応を単純化する
  • バイナリ配信: プロトコルバッファや Cap’n Proto を postMessage に載せて、JSON のシリアライズコストを削る

これらは自作 SDK が production に近づくにつれ順に必要になります。骨格を組んだ後、各方向に伸ばしていく参照点として、このシリーズを使ってもらえたらと思います。

実測日は 2026-08-08。5製品のバンドル ID・鍵・URL は変わりますが、この6つの決断軸は動きません。1年後にもう一度分解しても同じ枠組みで読めるはずです。