← シリーズに戻る

最小の埋め込み SDK を端から端まで組む — 6つの決断を実装で選び直す

Part 6 / 6 script 1行で配るJS SDKの設計 — 実在5製品を分解する

シリーズを通して5製品を分解しました。ここで一度、逆に組み立て直します。

想定は「顧客サイトに <script> 1行貼ると、チャットウィジェットが右下に浮かぶ」という最小 SDK です。分解で見えた6つの決断を、実装で明示的に選んでいきます。

最小の埋め込みチャット SDK の全体アーキテクチャ図。顧客サイト・SDK iframe(自社オリジン)・自社バックエンドの3ゾーンと postMessage / HTTPS+WSS の境界を可視化した図

想定要件

  • 顧客サイト (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 を選んで丸ごと隔離するのが単純です。

決断2: loader / body 分離

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

  • loader (https://sdk.myapp.com/v1/latest/): 5KB 程度、iframe を生成して postMessage の橋渡しだけ
  • body (https://sdk.myapp.com/v1/lib/iframe.html から始まる本体): UI 一式、~300KB

分離する主な理由は cache-control 戦略のためです (決断5)。loader は毎回取り直させて版を切り替え可能に、body は 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-srcapi.myapp.com に限定: 万一 XSS を踏んでも、他ドメインに情報を持ち出せない
  • frame-ancestors は指定しない (顧客サイトから埋め込まれる前提)

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

決断4: postMessage 契約

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

プロトコル:

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

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

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

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

origin 検証は等値比較:

// iframe 側
window.addEventListener("message", (event) => {
  if (event.origin !== "https://example.com") return; // 顧客サイトのオリジン (loader 経由で伝達)
  // ペイロード処理
});

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

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

暗号化は不要: チャット履歴が同一オリジン内の他 iframe に傍受されるリスクは、動画プレイヤーと同程度に小さいと判断します。将来、決済や医療画像を扱うなら AES-GCM 鍵交換を追加します。

決断5: cache-control 戦略

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

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

顧客に版を選ばせません。loader を毎回取り直して自社側で常に最新の body hash を吐き出します。iframe 型を選んだ時点でこの戦略が自然です (前記事参照)。

決断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 でも動きます。

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

二重ロード対策:

// loader の先頭
if (window.__MyAppChatLoaded) return;
window.__MyAppChatLoaded = true;

これで顧客が誤って <script> を2回書いても、2回目は即 return します。

骨格実装

loader (擬似コード):

(function() {
  if (window.__MyAppChatLoaded) return;
  window.__MyAppChatLoaded = true;

  const parentOrigin = location.origin;
  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";
  document.body.appendChild(iframe);

  const targetOrigin = "https://sdk.myapp.com";
  let ready = false;
  const queue = window.MyAppChat || [];

  window.addEventListener("message", (event) => {
    if (event.origin !== targetOrigin) return;
    if (event.data.type === "ready") {
      ready = true;
      // 積まれていた命令を巻き戻す
      queue.forEach((cmd) => iframe.contentWindow.postMessage(
        { type: "cmd", method: cmd[0], params: cmd[1] },
        targetOrigin
      ));
    }
    // res / event の処理は省略
  });

  // 以後の window.MyAppChat.push を intercept
  window.MyAppChat = {
    push: (cmd) => {
      if (ready) {
        iframe.contentWindow.postMessage(
          { type: "cmd", method: cmd[0], params: cmd[1] },
          targetOrigin
        );
      } else {
        queue.push(cmd);
      }
    }
  };
})();

iframe body (擬似コード):

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

window.addEventListener("message", (event) => {
  if (event.origin !== parentOrigin) 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/body 分離分離cache-control 戦略のため
CSP 隔離per-request nonce + connect-src 限定万一の XSS 時の外部持ち出し防止
postMessage 契約{type, method, params, id} + 等値 origin 検証硬く単純に
cache-controlloader は no-cache、body は immutableiframe 型で顧客に版を選ばせない
初期化 API命令的 (遅延実行キュー)静的 HTML と SPA の両方で動く

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

まとめと次の一歩

script 1行を貼るだけで何かが動く SDK は、裏で5〜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年後にもう一度分解しても同じ枠組みで読めるはずです。