0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サードパーティ JavaScript の二段階ロードと、次回の起動機会を残す失敗回復設計

0
Posted at

分析タグや埋め込みウィジェット、外部サービスの SDK のように、第三者のサイト上で読み込まれて動く JavaScript を サードパーティ JavaScript と呼びます。本記事では、その中でも機能一式を SDK として配信する場合を対象にします。

こうしたコードでは、読み込み処理と本体処理を一つのファイルにまとめて配信すると、更新、キャッシュ、障害の切り分けが難しくなることがあります。そこで、起動だけを担当する小さなスクリプト bootstrap.js と、実際の機能を持つ core.js を分けて配信します。

ホストページ
    │
    └── bootstrap.js
            │
            └── core.js

本記事では、この二段階ロードの基本構成と、本体の読み込みに失敗したときに自動リトライを繰り返さず、次回の起動機会を残す設計について説明します。

実装例は TypeScript 5.x を前提とし、主に次のブラウザ API を扱います。

全体構成

埋め込み先であるホストページには、起動用スクリプトだけを配置します。

<script src="https://cdn.example.com/sdk/bootstrap.js" defer></script>

bootstrap.js は、次の処理だけを担当します。

  1. 起動処理の重複を防ぐ
  2. SDK 本体の core.js を読み込む

一方、core.js は次のような本体処理を担当します。

  • DOM の走査
  • MutationObserver の設定
  • SPA の画面遷移への対応
  • UI のマウント
  • イベントリスナーの登録
  • 顧客別設定の反映

起動処理と本体処理を分けることで、変更頻度や責務の異なるコードを別々に管理できます。

基本的な起動スクリプト

まずは、最小構成の bootstrap.js を示します。

(function () {
  if (window.__sdkBootState === "loading" ||
      window.__sdkBootState === "ready") {
    return;
  }

  window.__sdkBootState = "loading";

  try {
    var script = document.createElement("script");

    script.src = "https://cdn.example.com/sdk/core.js";

    script.onerror = function () {
      window.__sdkBootState = "idle";
    };

    document.head.appendChild(script);
  } catch (error) {
    window.__sdkBootState = "idle";
  }
})();

SDK 本体の初期化が完了したら、core.js 側で状態を更新します。

window.__sdkBootState = "ready";

TypeScript でグローバルプロパティを宣言する場合は、次のように定義できます。

type SdkBootState = "idle" | "loading" | "ready";

declare global {
  interface Window {
    __sdkBootState?: SdkBootState;
  }
}

単純な boolean ではなく、"idle""loading""ready" の三つの状態を使っています。

これにより、少なくとも次の状態を区別できます。

状態 意味
idle または未定義 起動していない。再度起動できる
loading 本体を読み込み中または初期化中
ready 本体の初期化が完了している

__sdk_loaded のような名前を true にした直後に本体を読み始める実装では、「ロード済み」と「ロード中」の区別がつきません。状態名を分けたほうが、実際のライフサイクルを正確に表現できます。

動的に追加する script に defer は付けない

ホストページに直接記述する bootstrap.js には、defer を付けています。

<script src="https://cdn.example.com/sdk/bootstrap.js" defer></script>

これにより、bootstrap.js は HTML の解析と並行して取得され、文書の解析後に実行されます。

一方、bootstrap.jsdocument.createElement("script") で動的に作成する core.js に、同じ感覚で defer を設定するのは適切ではありません。

var script = document.createElement("script");
script.src = "https://cdn.example.com/sdk/core.js";
script.defer = true;
document.head.appendChild(script);

defer による「文書の解析完了後に、記述順で実行する」という挙動は、主として HTML パーサーが挿入した従来形式のスクリプトに対するものです。動的に追加したスクリプトでは、defer を付けても同じ実行制御にはなりません。

今回の構成では、bootstrap.js 自体が defer で文書解析後に実行されるため、その中で追加する core.js に改めて defer を設定する必要はありません。

var script = document.createElement("script");
script.src = "https://cdn.example.com/sdk/core.js";
document.head.appendChild(script);

複数の従来形式スクリプトを動的に追加し、追加順に実行させる必要がある場合は、それぞれを DOM に接続する前に async IDL 属性を false にします。

var first = document.createElement("script");
first.async = false;
first.src = "https://cdn.example.com/sdk/vendor.js";

var second = document.createElement("script");
second.async = false;
second.src = "https://cdn.example.com/sdk/core.js";

document.head.appendChild(first);
document.head.appendChild(second);

ただし、本体が一つだけであれば、実行順を制御するための設定は不要です。

try/catch が捕捉する範囲

次の処理を trycatch で囲んでいます。

try {
  var script = document.createElement("script");
  script.src = "https://cdn.example.com/sdk/core.js";
  script.onerror = function () {
    window.__sdkBootState = "idle";
  };
  document.head.appendChild(script);
} catch (error) {
  window.__sdkBootState = "idle";
}

ここで注意したいのは、CSP によってスクリプトの取得が拒否された場合に、appendChild() が必ず同期的な例外を送出するわけではないことです。一般的な外部スクリプトの取得失敗は、script 要素の error イベントとして通知されます。HTML 仕様でも、外部スクリプトの取得結果がエラーであれば、その要素に error イベントを発生させる処理が定義されています。

script.onerror = function () {
  window.__sdkBootState = "idle";
};

trycatch は、主に次のような同期例外に備えるための防御的な処理です。

  • document.head が存在せず、誤った操作を行った
  • DOM が通常とは異なる状態にある
  • ホストページによって DOM API が差し替えられている
  • ブラウザ拡張や監視ツールなどが処理へ介入している

したがって、次のように理解するのが正確です。

  • ネットワークエラー、404、CSP による取得拒否など
    → 主に script.onerror で扱う
  • DOM 操作中に発生する同期例外
    trycatch で扱う

trycatch を置くだけでは、スクリプトの非同期な取得失敗は捕捉できません。

本体の初期化失敗も状態に反映する

script.onerror が捕捉できるのは、主にスクリプトファイルの取得や準備に失敗したケースです。一方、core.js の取得には成功しても、本体の初期化処理で例外が発生することがあります。

async function initialize(): Promise<void> {
  // DOM の初期化や API 呼び出しなど
}

この場合は、core.js 自身が起動状態を更新する必要があります。

async function start(): Promise<void> {
  try {
    await initialize();
    window.__sdkBootState = "ready";
  } catch (error) {
    window.__sdkBootState = "idle";
    console.error("[sdk] initialization failed", error);
  }
}

void start();

これにより、失敗の種類にかかわらず状態が整理されます。

失敗箇所 状態を戻す場所
core.js の取得失敗 script.onerror
スクリプト要素の追加時に同期例外 bootstrap.jscatch
core.js の初期化失敗 core.jscatch

本体の取得成功と初期化成功は別の事象です。script 要素の load イベントだけをもって、SDK 全体の起動成功と判断しないようにします。

bootstrap.js は外部ファイルとして配信する

bootstrap.js は小さなファイルですが、ホストページへインラインコードとして直接埋め込むとは限りません。本記事では、次のような外部スクリプトとして配信します。

<script
  src="https://cdn.example.com/sdk/bootstrap.js"
  defer
></script>

この構成には、CSP との調整が比較的分かりやすいという利点があります。

インラインスクリプトを実行するには、ホストページの CSP に応じて、次のいずれかが必要になる場合があります。

  • nonce
  • hash
  • 'unsafe-inline'

第三者 SDK がホストページの nonce を独自に発行することはできません。インラインコードを採用すると、ホスト側の HTML 生成処理や CSP ヘッダーとの連携が必要になります。一方、外部スクリプトであれば、SDK の配信元を script-src で許可する構成にできます。

ただし、外部ファイルにすればどの CSP でも通るわけではありません。たとえば、ホストページが次のポリシーを使用している場合、別オリジンの CDN は許可されません。

Content-Security-Policy: script-src 'self'

SDK を別オリジンから配信するには、ホスト側でその配信元を許可するか、nonce と 'strict-dynamic' を利用するなど、ホスト側の CSP 設計との調整が必要です。

CSP Level 3 の 'strict-dynamic' では、nonce または hash で信頼されたスクリプトが、非パーサー挿入の <script> 要素を使って依存スクリプトを追加する構成を取れます。ただし、動的に組み立てる URLへ外部入力を混入させないなど、読み込み元を厳格に管理する必要があります。

なぜ二段階に分けるのか

変更頻度の違いを分離できる

bootstrap.js が担う処理は、基本的に次の二つだけです。

  • 重複起動の防止
  • 本体スクリプトの読み込み

一方、core.js にはプロダクトの機能が含まれるため、頻繁に更新されます。

  • DOM 補正ルール
  • UI
  • API クライアント
  • 計測処理
  • SPA 対応
  • 顧客別機能

更新頻度の異なるコードを分けておけば、起動処理を変更せずに本体だけを更新できます。

キャッシュ方針を分けられる

たとえば、次のような配信方針を取れます。

bootstrap.js
Cache-Control: no-cache

core.a1b2c3.js
Cache-Control: public, max-age=31536000, immutable

bootstrap.js は毎回再検証し、最新の本体 URL を参照させます。一方、core.a1b2c3.js のようなコンテンツハッシュ付きファイルは長期キャッシュできます。本体の内容が変わればファイル名も変わるため、古いファイルと新しいファイルが混同されません。

script.src =
  "https://cdn.example.com/sdk/core.a1b2c3.js";

「本体だけを長期キャッシュし、ローダーは短期間で更新する」という説明は、固定名の core.js よりも、コンテンツハッシュ付きファイルと組み合わせた場合に成立しやすくなります。

障害箇所を分けて観測できる

二段階に分けることで、次の状態を別々に確認できます。

  1. bootstrap.js が実行されたか
  2. core.js の取得が始まったか
  3. core.js の取得に成功したか
  4. core.js の評価に進んだか
  5. SDK の初期化が完了したか

単一ファイルでは「SDK が動かなかった」という一つの現象に見えますが、段階を分けると、失敗した位置を特定しやすくなります。

起動シーケンス

core.js の読み込みに失敗した場合は、状態を "idle" に戻します。

状態を idle に戻した後の挙動

本体の読み込みに失敗しても、bootstrap.js はその場で自動リトライしません。

script.onerror = function () {
  window.__sdkBootState = "idle";
};

ここで行っているのは、次回 bootstrap.js が実行されたときに、再び起動処理へ進める状態へ戻すことだけです。本記事ではこの方式を「次回の起動機会を残す」と表現します。その場で再送する能動的なリトライではなく、外部から次の起動契機が与えられたときに再試行できるようにする設計です。

自動的に再試行するわけではない

状態を "idle" に戻しても、それだけで bootstrap.js が再実行されるわけではありません。再度起動するには、何らかの契機が必要です。

契機 再度起動するか
マルチページアプリケーションで別ページへ移動 する。新しい windowbootstrap.js が実行される
ページを再読み込み する
タグマネージャーがタグを再評価 条件によってはする
顧客側スクリプトが起動関数を再度呼ぶ する
SPA で pushState() による画面遷移 通常はしない
同じページで何も操作せず待つ しない

したがって、SPA の初回起動時に core.js の取得に失敗すると、そのセッション中は SDK が起動しない可能性があります。これを許容できない場合は、回数制限付きの再試行など、別の仕組みが必要です。

無制限の自動リトライを避ける理由

埋め込み SDK では、SDK 自体の可用性だけでなく、ホストページへ余計な負荷や障害を与えないことも重要です。

次のような恒常的な設定不備は、直後に再試行しても解消しません。

  • CSP の script-src が SDK の配信元を許可していない
  • 顧客側の WAF が SDK のパスを遮断している
  • CDN のドメインが許可リストに入っていない
  • URL が誤っている
  • 契約やテナント設定が無効になっている

こうした状態で自動リトライを繰り返すと、次の問題が生じます。

  • 同じ失敗リクエストが繰り返される
  • CSP 違反やネットワークエラーがコンソールに蓄積する
  • 顧客側のエラー監視へ不要なイベントが送られる
  • SDK 配信元のログや監視にノイズが増える
  • ホストページ上で不要な処理が継続する

一過性の通信エラーへの対策として再試行を入れる場合でも、無制限には行わず、最大回数や待機時間を明確にします。

const MAX_RETRIES = 1;

埋め込み SDK では、まず「再試行しない」構成を基準にし、実際の障害データを見て必要性を判断するほうが安全です。

読み込み失敗と DOM 消失は別の問題

SPA や Turbo Drive、htmx などを利用したページでは、SDK の読み込みには成功していても、画面遷移によって SDK のマウント先が削除されることがあります。

<div id="widget-root"></div>

これは、core.js の読み込み失敗とは異なります。

  • 読み込み失敗
    → ネットワークや CSP などにより、本体を取得または実行できない
  • DOM 消失
    → 一度起動した本体は残っているが、表示先の要素が削除された

後者には、MutationObserver を使った再マウントが適しています。

MutationObserver による再マウント

一度正常に起動した後でマウント先が削除された場合だけ、再マウントを試みます。

let booting = false;
let mountedOnce = false;

async function autoBoot(): Promise<void> {
  if (booting) {
    return;
  }

  if (document.getElementById("widget-root")) {
    return;
  }

  booting = true;

  try {
    await boot();
    mountedOnce = true;
  } catch (error) {
    console.error("[widget] boot failed", error);
  } finally {
    booting = false;
  }
}

Observer は、<body> 自体が差し替えられても残る document.documentElement に設定します。

function installSurvivalObserver(): void {
  if (typeof MutationObserver === "undefined") {
    return;
  }

  const observer = new MutationObserver(() => {
    if (!mountedOnce) {
      return;
    }

    if (!document.getElementById("widget-root")) {
      void autoBoot();
    }
  });

  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
  });
}

mountedOnce を確認しているのは、初回起動に失敗している間、ホストページのあらゆる DOM 更新を契機に boot() を繰り返さないためです。これにより、Observer の役割を「初回ロード失敗のリトライ」ではなく「正常に起動した UI が後から削除された場合の復元」に限定できます。

Observer でネットワーク再試行を行わない

Observer のコールバック内から、失敗した外部スクリプトを無条件に再取得する構成は避けます。DOM 更新は頻繁に発生するため、次のような実装では再試行が連続する可能性があります。

const observer = new MutationObserver(() => {
  if (!document.getElementById("widget-root")) {
    void loadCoreAgain();
  }
});

Observer は、DOM の状態変化を検知するための仕組みです。ネットワーク障害の再試行契機として使う場合は、回数制限や待機時間などを別途設ける必要があります。

構成 API を挟む多段階ロード

実運用では、bootstrap.js から直ちに core.js を読み込むのではなく、顧客ごとの配信構成を API から取得する場合があります。

bootstrap.js
    │
    ├── 配信構成 API
    │       └── 有効なモジュール一覧
    │
    └── 必要なモジュール
            └── ウィジェット本体

たとえば、埋め込みタグの data-key から顧客を識別します。

<script
  src="https://cdn.example.com/sdk/bootstrap.js"
  data-key="tenant-123"
  defer
></script>

起動スクリプトは、そのキーを使って配信構成を取得します。

const config = await fetchConfig({
  apiBase,
  tenantKey,
});

const modules = await Promise.all(
  config.modules.map((moduleId) =>
    loadModule(moduleId, config),
  ),
);

ここで取得する情報には、次のようなものが考えられます。

  • 有効な機能
  • 読み込むモジュール
  • CDN のベース URL
  • UI の設定
  • 機能フラグ
  • テナント識別子

この応答を plan と呼ぶ実装もありますが、契約プランそのものではなく実際に配信する構成を表すのであれば、configmanifest のほうが役割を理解しやすい名前です。

動的 import() でモジュールを取得する場合

配信構成に応じて ESM を読み込む場合は、動的 import() を利用できます。

const moduleUrl =
  `${config.cdnBase}/modules/${moduleFilename(moduleId)}`;

const module = await import(
  /* @vite-ignore */
  moduleUrl
);

await module.default.init(context);

別オリジンから JavaScript モジュールを読み込む場合は、従来形式の外部スクリプトとは異なり、CORS が必要です。配信元は適切な Access-Control-Allow-Origin ヘッダーを返し、さらにホストページの CSP がモジュールの URL とそのモジュールが読み込む依存先を許可している必要があります。

次の点を事前に確認します。

  • CDN が CORS に対応しているか
  • JavaScript の正しい MIME type を返しているか
  • CSP がモジュールの配信元を許可しているか
  • モジュール内の相対 import が意図した URL に解決されるか
  • バンドラーが動的 URL をビルド時に解決しようとしないか

/* @vite-ignore */ は Vite による変換を抑制するための指定であり、ブラウザのセキュリティ制約を緩和するものではありません。

読み込み段階ごとにガードを分ける

読み込み処理が多段階になると、一つのフラグだけで状態を表すのは難しくなります。ただし、「ローダー段」「本体段」のようにコードの位置だけで呼ぶより、何を防ぐための状態かを名前に表したほうが明確です。

たとえば、次のように整理できます。

起動処理全体の状態

window.__sdkBootState
  • 起動していない
  • 起動中
  • 起動済み

を区別します。

モジュール取得の共有 Promise

複数の呼び出し元が同じ構成取得やモジュール取得を始めないようにします。

let configPromise: Promise<SdkConfig> | undefined;

function getConfig(): Promise<SdkConfig> {
  configPromise ??= fetchConfig();
  return configPromise;
}

UI の再入防止

Observer、手動呼び出し、画面遷移イベントが同時に発生しても、二重にマウントしないようにします。

let booting = false;

マウント先の存在確認

if (document.getElementById("widget-root")) {
  return;
}

状態を分ける基準は、ファイルや「段」の数ではなく、それぞれが防ぐ競合や重複処理です。

document.currentScript の扱い

従来形式のスクリプトを実行している最中であれば、document.currentScript から現在実行中の <script> 要素を取得できます。

const ownScript = document.currentScript;

これは、スクリプトが asyncdefer かだけで null になるものではありません。HTML 仕様上、document.currentScript は、現在実行中のスクリプトが従来形式のスクリプトである場合に、その要素を返します。次の場合は null になります。

  • 現在スクリプト要素を実行していない
  • タイマーやイベントハンドラー内から参照している
  • 現在実行中なのが JavaScript モジュールである
  • スクリプトの同期実行が終わった後に参照している

そのため、data-key などを読みたい場合は、起動直後に同期的に取得して変数へ保存します。

const ownScript =
  document.currentScript as HTMLScriptElement | null;

const tenantKey =
  ownScript?.dataset.key ?? null;

次のように、非同期処理の後で初めて参照すると、null になる可能性があります。

await fetchConfig();

const ownScript = document.currentScript;
// 通常、この時点では null

必要な値は await より前に取得します。

const ownScript =
  document.currentScript as HTMLScriptElement | null;

const tenantKey = ownScript?.dataset.key;

await fetchConfig(tenantKey);

DOM 走査による補助的な解決

埋め込み方法を完全には制御できない場合は、該当する <script> 要素を検索する補助処理を用意できます。

function resolveOwnScript():
  HTMLScriptElement | null {
  const current =
    document.currentScript as HTMLScriptElement | null;

  if (current) {
    return current;
  }

  const scripts = Array.from(
    document.querySelectorAll<HTMLScriptElement>(
      "script[src]",
    ),
  );

  const candidates = scripts.filter(
    (script) =>
      script.dataset.sdkLoader === "true",
  );

  return candidates.at(-1) ?? null;
}

埋め込みタグには、SDK 専用の識別属性を付けます。

<script
  src="https://cdn.example.com/sdk/bootstrap.js"
  data-sdk-loader="true"
  data-key="tenant-123"
  defer
></script>

単に data-key を持つ最後のスクリプトを選ぶと、ホストページ内の別のスクリプトを誤って取得する可能性があります。

// 条件が広すぎる
document.querySelectorAll("script[data-key]");

ファイル名による判定も、URL の変更や複数バージョンの併存に弱いため、SDK 固有の属性を優先します。

ESM では import.meta.url を使う

bootstrap.js 自体を JavaScript モジュールとして配信する場合、document.currentScript は利用できません。

<script
  type="module"
  src="https://cdn.example.com/sdk/bootstrap.js"
  data-key="tenant-123"
></script>

モジュール自身の URL が必要な場合は、import.meta.url を利用します。

const moduleUrl = import.meta.url;

ただし、import.meta.url からは <script> 要素の data-* 属性を直接取得できません。モジュールで data-key を受け渡す必要がある場合は、次のような別の設計を検討します。

  • URL のクエリーパラメーターへ含める
  • グローバルな設定オブジェクトを先に定義する
  • SDK 専用属性を持つ <script> 要素を DOM から検索する
  • 初期化関数を公開し、引数として設定を渡す

起動状態を確認するための情報

運用や E2E テストのために、最低限の状態を外部から確認できるようにしておくと便利です。

type SdkBootState = "idle" | "loading" | "ready";

declare global {
  interface Window {
    __sdkBootState?: SdkBootState;
  }
}

Playwright では、次のように初期化完了を待てます。

await page.waitForFunction(
  () => window.__sdkBootState === "ready",
);

ただし、グローバル変数はホストページから変更できます。これはセキュリティ上信頼できる状態管理ではなく、診断やテストのための観測情報として扱います。

より詳細な診断が必要であれば、状態だけでなく、最後の失敗理由や時刻を持たせる方法もあります。

interface SdkDiagnostics {
  state: "idle" | "loading" | "ready";
  lastError?: string;
  updatedAt: number;
}

ただし、第三者サイトのグローバル空間へ公開する情報は最小限にし、機密情報や内部 API の応答を含めないようにします。

最小構成と実運用構成の対応

最小構成 実運用での拡張
bootstrap.js から core.js を読み込む 配信構成 API を取得し、必要なモジュールだけを読み込む
一つの起動状態 構成取得、モジュール取得、UI マウントごとに重複防止を設ける
本体取得失敗時に idle へ戻す 失敗箇所ごとに状態と診断情報を更新する
次回のページ読み込みで再試行する 必要に応じて回数制限付きのリトライを追加する
DOM の変化は考慮しない 正常起動後の DOM 消失を MutationObserver で検知する
従来形式の外部スクリプト ESM と動的 import() を利用してモジュール単位で配信する
document.currentScript から設定を読む 同期的な値の保存、専用属性による検索、import.meta.url を使い分ける

機能が増えても、基本的な考え方は変わりません。

  • 小さな起動処理と、変更頻度の高い本体を分離する
  • 重複起動を防ぐ
  • 読み込み中と起動済みを区別する
  • 失敗時に状態を解除し、次の起動機会を妨げない
  • 恒常的な失敗に対して無制限の再試行を行わない
  • ネットワーク障害と DOM 消失を別の問題として扱う

まとめ

埋め込み SDK を二段階で読み込む構成では、bootstrap.js が起動を担当し、core.js が実際の機能と初期化処理を担当します。

起動処理を小さく保ち、失敗時には状態を idle に戻して次の起動機会を残し、恒常的な失敗に対しては無制限の再試行を避けます。MutationObserver は読み込み失敗の再送ではなく、一度マウントした DOM が削除された場合の復元に限定します。個々の要点は前節の対応表と設計原則に整理したとおりです。

この設計の目的は、SDK の起動成功率だけを最大化することではありません。

第三者サイト上で動作するコードとして、ホストページへ過剰な通信や処理を持ち込まず、失敗時にも安全に停止し、次の妥当な起動機会を残すことが重要です。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?