最小の埋め込み 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 / 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-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: <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 で永久キャッシュします。
| 資源 | 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 body JS | https://sdk.myapp.com/v1/lib/main-<hash>.js | public, max-age=31536000, immutable |
| iframe body CSS | https://sdk.myapp.com/v1/lib/styles-<hash>.css | public, 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-control | loader は no-cache、body は immutable | iframe 型で顧客に版を選ばせない |
| 初期化 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年後にもう一度分解しても同じ枠組みで読めるはずです。
この記事は役に立ちましたか?