Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ProxyによるWindow間RPC機構の構築

Avatar for syumai syumai
September 05, 2025

 ProxyによるWindow間RPC機構の構築

フロントエンドカンファレンス北海道2025 (2025/9/5)の発表資料です。
サンプル: https://github.com/syumai/easy-admin-demo

Avatar for syumai

syumai

September 05, 2025
Tweet

More Decks by syumai

Other Decks in Programming

Transcript

  1. 自己紹介 syumai ECMAScript 仕様輪読会 / Asakusa.go 主催 株式会社ベースマキナで管理画面のSaaS を開発中 Go

    でGraphQL サーバー (gqlgen) や TypeScript でフロン トエンドを書いています Software Design 2023 年12 月号から2025 年2 月号まで Cloudflare Workers の連載をしました Twitter ( 現𝕏): @__syumai Website: https://syum.ai
  2. 本日話すこと JavaScript のProxy の基本 Window 間通信の基本 別Window 上のメソッド呼び出しのProxy による実装 Window

    間通信のProxy によるWrap 方法 別Window 上のメソッドの型情報の利用 Comlink の簡単な紹介
  3. 例1 - プロパティの値の操作 const doubleProxy = new Proxy(obj, doubleHandler); //

    2倍された値がプロパティから取れる console.log(doubleProxy.a); // 2 console.log(doubleProxy.b); // 4
  4. 例2 - 存在しないプロパティの取得 // プロパティのないオブジェクト const obj = {}; const

    proxy = new Proxy(obj, { // プロパティの取得操作に割り込み、プロパティ名をそのまま値として返す get(_, prop) { return prop; } }) // 存在しないプロパティの値が取れる console.log(proxy.a); // a console.log(proxy.b); // b
  5. Object の分類 全てのObject は2 つの種類に分類できる 1. ordinary object あらゆるObject が基本的にはこれ

    2. exotic object Array などの、独自定義の内部メソッドを持つObject がこれ Array は [[DefineOwnProperty]] の動作が特殊 Proxy は、ユーザー定義のexotic object を作る機能 これらの用語はECMAScript 仕様にも頻繁に現れます
  6. Window の参照の取得方法 iframe, window.open などで得られる 参照取得方法の例 経路 親 => 子

    子 => 親 iframe iframe.contentWindow window.parent window.open(url) open 関数の返り値 window.opener <a ... target="_blank"> - window.opener
  7. Window 間での値の受け渡し方法 以下の2 つがある 1. プロパティアクセス Window Object のグローバルなプロパティを介して値を受け渡す 例:

    window.parent.a = 1 2. postMessage グローバルなプロパティを直接使わずメッセージ送信する 例: window.parent.postMessage(1)
  8. 補足: Origin とは eiji さんの記事より Origin は scheme (http とか https

    とか) 、hostname 、port の組み合わせを指す。 same-origin と言った場合、これらすべてが一致するものを示している same-site/cross-site, same-origin/cross-origin をちゃんと理解する: https://zenn.dev/agektmr/articles/f8dcd345a88c97
  9. postMessage の利用例 受け手側 window.addEventListener("message", (event) => { console.log(event.data); }); 送り手側

    // iframeの親にメッセージを送る window.parent.postMessage("Hello from child window!");
  10. 例: 管理画面SaaS ( 仮称: Easy Admin) の組み込み画面 Easy Admin は、顧客がリッチな画面を構築するために、自由に画面を組み立てて、

    iframe で組み込む機能を持っている Easy Admin は、公開API を持っていないが、iframe 内からサービス固有の機能を呼び 出すためのSDK を提供している 例: ログイン中ユーザー情報の取得、DB へのクエリ実行など ( 注: 似ていますが、ベースマキナの話ではないです。また、Easy Admin ( 仮称) と同名 のプロダクトがあったとしても無関係です)
  11. 顧客側の実装イメージ import { client } from "@easy-admin/sdk"; const UserCard =

    () => { // ログイン中ユーザー情報を取得する const user = use(client.getCurrentUser); return ( // ユーザー情報を表示するUI ); }; const App = () => { return ( <Suspense fallback={<p>読み込み中です...</p>}> <UserCard /> </Suspense> ); }; export default App;
  12. 実装方針 顧客が実装するページであるiframe 側のWindow をクライアント、Easy Admin 側の Window をサーバーと見立てて、クライアント・サーバー風の作りにします iframe ->

    Easy Admin がリクエスト Easy Admin -> iframe がレスポンス まずは顧客が生でpostMessage を使う形にして、最後にpostMessage を隠蔽した関数 に改良し、SDK から配布する形にします 最終的には、 getCurrentUser 以外の処理呼び出しにも対応します
  13. 実装ステップ 1. iframe からEasy Admin へのgetCurrentUser のリクエスト送信 2. Easy Admin

    のiframe からのgetCurrentUser のリクエスト受信 3. Easy Admin からiframe へのgetCurrentUser のレスポンス送信 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 5. ( 安全機構の追加) 6. postMessage 呼び出しの隠蔽
  14. 実装ステップ 1. iframe からEasy Admin へのgetCurrentUser のリクエスト送信 2. Easy Admin

    のiframe からのgetCurrentUser のリクエスト受信 3. Easy Admin からiframe へのgetCurrentUser のレスポンス送信 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 5. ( 安全機構の追加) 6. postMessage 呼び出しの隠蔽
  15. 1. iframe からEasy Admin へのgetCurrentUser のリクエスト送信 まずは、顧客が自分でpostMessage 呼び出しを書く形式での実装を行う 顧客のコード上に、 getCurrentUser

    関数を実装していく type EasyAdminRequest = { operation: "getCurrentUser" } // 今は1種類 const getCurrentUser = () => { // リクエストを組み立てる const reqMsg = { operation: "getCurrentUser" } satisfies EasyAdminRequest; // postMessageでiframeからEasy Adminにリクエストを送る window.parent.postMessage(reqMsg); // TODO: レスポンスの受信処理 };
  16. 実装ステップ 1. iframe からEasy Admin へのgetCurrentUser のリクエスト送信 2. Easy Admin

    のiframe からのgetCurrentUser のリクエスト受信 3. Easy Admin からiframe へのgetCurrentUser のレスポンス送信 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 5. ( 安全機構の追加) 6. postMessage 呼び出しの隠蔽
  17. 2. Easy Admin のiframe からのgetCurrentUser のリクエスト受信 Easy Admin は、自身のWindow Object

    に message イベントハンドラーを設定してリ クエストを待ち受けます window.addEventListener("message", (event) => { const reqMsg = event.data as EasyAdminRequest; // TODO: レスポンスの送信処理 });
  18. 実装ステップ 1. iframe からEasy Admin へのgetCurrentUser のリクエスト送信 2. Easy Admin

    のiframe からのgetCurrentUser のリクエスト受信 3. Easy Admin からiframe へのgetCurrentUser のレスポンス送信 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 5. ( 安全機構の追加) 6. postMessage 呼び出しの隠蔽
  19. 3. Easy Admin からiframe へのgetCurrentUser のレスポンス送信 Easy Admin は、iframe のWindow

    Object へpostMessage でレスポンスを送信します window.addEventListener("message", async (event) => { const reqMsg = event.data as EasyAdminRequest; // Easy Admin側のgetCurrentUser関数を呼んで、ユーザー情報を取得する const user = await getCurrentUser(); const resMsg = { operation: "getCurrentUser", payload: user, } satisfies EasyAdminResponse; // iframeにレスポンスを送る iframe.contentWindow.postMessage(resMsg); });
  20. 実装ステップ 1. iframe からEasy Admin へのgetCurrentUser のリクエスト送信 2. Easy Admin

    のiframe からのgetCurrentUser のリクエスト受信 3. Easy Admin からiframe へのgetCurrentUser のレスポンス送信 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 5. ( 安全機構の追加) 6. postMessage 呼び出しの隠蔽
  21. 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 iframe 側は、自身のWindow Object に

    message イベントハンドラーを設定してレス ポンスを待ち受けます 現時点では、 message イベントハンドラーは、関数呼び出しの度に登録し、関 数呼び出し完了時に登録解除する const getCurrentUser = () => { const handler = (event) => { const resMsg = event.data as EasyAdminResponse; // TODO: ユーザー情報を返す // 呼び出し完了時に登録解除する window.removeEventListener("message", handler); } window.addEventListener("message", handler); // ... };
  22. 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 ここでpostMessage のやり取りをPromise でWrap する

    利用者視点で、ただのasync function に見えるようになる const getCurrentUser = () => { return new Promise((resolve) => { const handler = (event) => { const resMsg = event.data as EasyAdminResponse; // ユーザー情報でresolveする resolve(resMsg.payload); window.removeEventListener("message", handler); } window.addEventListener("message", handler); // リクエスト送信処理もPromise内に入れる (messageイベントリスナー設定後に送信する必要があるため) }); };
  23. 4. iframe のEasy Admin からのgetCurrentUser のレスポンス受信 ステップ4 時点での最終形です Easy Admin

    とiframe の間のリクエスト・レスポンスは送り合えるようになった const getCurrentUser = () => { return new Promise((resolve) => { const handler = (event) => { const resMsg = event.data as EasyAdminResponse; resolve(resMsg.payload); window.removeEventListener("message", handler); } window.addEventListener("message", handler); const reqMsg = { operation: "getCurrentUser" } satisfies EasyAdminRequest; window.parent.postMessage(reqMsg); }); };
  24. 5. 安全機構の追加 - メッセージへのID 付与 今の実装だと、 getCurrentUser の結果が呼び出し順に返ることが保証されない リクエスト・レスポンスの両方に呼び出しの対応関係を保証するID を付ける

    ( crypto.randomUUID() などを使う) type EasyAdminRequest = { id: string; operation: "getCurrentUser" } type EasyAdminResponse = { id: string; operation: "getCurrentUser"; payload: User; };
  25. 5. 安全機構の追加 - 送信先Origin の指定 postMessage は、送信先Origin を指定できる iframe 側は、Easy

    Admin 以外のサイトに埋め込まれた時に、メッセージ内容を 読まれたくない // Easy AdminのOriginにだけメッセージを送る! window.parent.postMessage(reqMsg, easyAdminOrigin);
  26. 5. 安全機構の追加 - 送信元のバリデーション postMessage で送られてきたevent には、送信元の情報が含まれる 送信元Window Object 送信元Origin

    Easy Admin 側は、意図したiframe から以外の送信を弾くべき const iframe = document.getElementById("client-app"); window.addEventListener("message", async (event) => { if (event.source !== iframe.contentWindow) { return; } if (event.origin !== clientAppOrigin) { return; } const reqMsg = event.data as EasyAdminRequest; // ...
  27. 関数の追加が大変 呼び出せる関数の種類が増える度に型定義が増える type EasyAdminRequest = ( { operation: "getCurrentUser" }

    | { operation: "listUsers" // (NEW!) } ) & { id: string; } type EasyAdminResponse = ( { operation: "getCurrentUser"; payload: User; } | { operation: "listUsers"; // (NEW!) payload: User[]; } ) & { id: string; };
  28. 関数の追加が大変 Easy Admin 側のswitch のケースも都度増える window.addEventListener("message", async (event) => {

    const reqMsg = event.data as EasyAdminRequest; let resMsg: EasyAdminResponse; switch (reqMsg.operation) { // operationの種類が増えるとswitchが必要 case: "getCurrentUser": const user = await getCurrentUser(); resMsg = { operation: "getCurrentUser", payload: user, }; break; case: "listUsers": // (NEW!) const users = await listUsers(); // ... } iframe.contentWindow.postMessage(resMsg); });
  29. 関数呼び出しのProxy 化 iframe 側は、 get に割り込むProxy Object を作り、プロパティアクセスに対して postMessage のやり取りをする関数を返すようにする

    const client = new Proxy({}, { get(_, prop) { // プロパティ名が、そのままEasy Admin側の呼びたいメソッド名になる! createRequestSender({ operation: prop }); } });
  30. 関数呼び出しのProxy 化 リクエストも、レスポンスも、形式がまとまる const createRequestSender = ({ operation }: {

    operation: string }) => // 引数に渡った値をそのままEasy Adminに送る (...payload) => new Promise((resolve) => { const handler = (event) => { const resMsg = event.data; resolve(resMsg.payload); // ... } // ... const reqMsg = { id, operation, payload }; // リクエストの形式が1種類になった! window.parent.postMessage(reqMsg); });
  31. 関数呼び出しのProxy 化 Proxy のObject は、 BuiltInFuncs 型のProxy としてexport する //

    `BuiltInFuncs` を型引数に指定する const client = new Proxy<BuiltInFuncs>({}, { get(_, prop) { createRequestSender(prop); } });
  32. 関数呼び出しのProxy 化 Easy Admin 側は、switch を書かなくてよくなり、非常にシンプルになる window.addEventListener("message", async (event) =>

    { const reqMsg = event.data; // 指定された関数を取得して呼び出す const func = builtInFuncs[reqMsg.operation]; const payload = func(...reqMsg.payload); const resMsg = { operation: reqMsg.operation, payload, }; iframe.contentWindow.postMessage(resMsg); });
  33. 機能改善 - エラーハンドリング Easy Admin 側での関数実行でthrow されたError をiframe でcatch したい

    Easy Admin 側で catch して、Error も値としてレスポンスに含めて返す そして、 iframe 側でError を再度throw する const func = builtInFuncs[reqMsg.operation]; let resMsg; try { const payload = func(...reqMsg.payload); resMsg = { operation: reqMsg.operation, payload, }; } catch(error) { resMsg = { operation: reqMsg.operation, error, }; }