埋め込みコードを自作する: 最小 SDK を6決断で組む
シリーズを通して5製品を分解しました。ここで一度、逆に組み立て直します。想定は「顧客サイトに <script> 1行貼ると、チャットウィジェットが右下に浮かぶ」という最小 SDK です。分解で見えた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 を選んで丸ごと隔離するのが単純です。

決断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 を挟むことになります。

決断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 を見ます。

顧客サイトのオリジンは 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年キャッシュします。
| 資源 | URL | cache-control |
|---|---|---|
| loader | https://sdk.myapp.com/v1/latest/ | no-cache |
| iframe.html | https://sdk.myapp.com/v1/lib/iframe.html | no-cache (nonce ヘッダー付きで動的配信) |
| iframe 本体 JS | https://sdk.myapp.com/v1/lib/main-<hash>.js | public, max-age=31536000, immutable |
| iframe 本体 CSS | https://sdk.myapp.com/v1/lib/styles-<hash>.css | public, 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 でも動きます。

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);
})();

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-control | loader は no-cache、本体は immutable | iframe 型で顧客に版を選ばせない |
| 初期化 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年後にもう一度分解しても同じ枠組みで読めるはずです。
この記事は役に立ちましたか?