← ブログに戻る

プロダクトを出すたび支援導線が勝手に付く: Cloudflare Workersのエッジ注入オーバーレイ

昨日はSponsorボタンが出ていない問題を42リポジトリ棚卸しして直しました。今日はその続きで、支援導線をリポジトリのREADMEからプロダクトの画面そのものに広げます。

このサイトには /products/ 配下にミニプロダクトを並べるハブがあります。第1号はhistorymapというYAMLから年表を生成するツールで、今後も週次でここに増えていく予定です。やりたかったのはこうです。

  • 全プロダクトの画面右下にKo-fi / GitHub Sponsorsへの導線を置く
  • ただし各プロダクトのコードには一切手を入れない
  • 今後新しく出すプロダクトにも、何もしなくても付いてほしい
  • 将来はURL別の広告モーダルも同じ仕組みに載せたい

結論として、Cloudflare WorkersのHTMLRewriterでエッジ注入する構成に落ち着きました。設計判断と、途中で踏んだ「Workerを書いたのに1行も実行されない」罠を記録します。

前提: プロダクトはRouteで分離された独立Worker

kenimoto.dev/products/<app>/ は、本体サイトとは別の独立Workerが配信しています。

kenimoto.dev/*                     → Worker: kenimoto-dev(本体サイト)
kenimoto.dev/products/historymap/* → Route → Worker: historymap(独立repo)

CloudflareはCustom Domainより具体的なRouteを優先するので、本体はそのまま、プロダクトごとにRouteを1本足すだけで同一ドメイン配下に同居できます。アプリ=repo=Worker=Routeが1対1で、サンセットも昇格もRoute単位で扱えるのが気に入っています。

この構成の代償として、本体サイトのレイアウトはプロダクトのページに一切及びません。支援導線を足したければ、何かしらの方法で「後から被せる」必要があります。

iframeラッパー案は捨てた

最初に検討したのは、本体サイトが殻ページを配信して中身をiframeで嵌める案です。導線は殻に置けばいいので実装は素直ですが、比較して捨てました。

観点iframeラッパーエッジ注入
SEO / AIクローラー殻ページは実質空箱ページ本体がそのまま評価される
URLと画面の同期内部遷移で外側URLが置き去り問題なし
管理単位アプリ配信+殻ページの二重管理Worker 1個のまま
実装コストpostMessageで高さ同期など恒常負債HTMLRewriterで数十行

決め手はSEOです。iframeの中身は親ページのコンテンツとして評価されないので、kenimoto.dev/products/<app>/ に検索エンジンとAIクローラーからのリンク価値を蓄積するという目的と真っ向から衝突します。導線のために本体の資産形成を犠牲にするのは本末転倒でした。

ローダーパターン: 配線と実体を分ける

エッジ注入そのものは簡単です。Static Assetsだけだった各プロダクトのWorkerに、スクリプトを1枚足します。

const OVERLAY_TAG =
  '<script src="https://kenimoto.dev/assets/products-overlay.js" defer></script>';

export default {
  async fetch(request, env) {
    const response = await env.ASSETS.fetch(request);

    if (new URL(request.url).hostname !== 'kenimoto.dev') return response;

    const contentType = response.headers.get('content-type') || '';
    if (!contentType.includes('text/html')) return response;

    return new HTMLRewriter()
      .on('body', {
        element(el) {
          el.append(OVERLAY_TAG, { html: true });
        },
      })
      .transform(response);
  },
};

設計上のポイントは、注入するのがscriptタグ1行だけという点です。ボタンのUIも計測もURL別の出し分けルールも、全部 products-overlay.js という本体サイト側の1ファイルに置いています。

本体repo:       public/assets/products-overlay.js  ← 実体(UI・計測・出し分けルール)
各プロダクトrepo: worker/index.js                    ← 配線(上のコードのコピー、プロダクト固有の内容ゼロ)

この分離が効くのは変更のときです。導線のデザイン変更も、広告キャンペーンの差し替えも、本体repoの1ファイルを更新すれば全プロダクトに即時反映されます。プロダクト側の再デプロイは要りません。逆にプロダクト側の配線はプロダクト固有の内容を含まないので、新プロダクトのテンプレートに同梱しておけば、以後は出すだけで導線付きになります。

実体側は将来の広告用に、URL条件と描画関数のペアを並べる構造にしてあります。

// URL別ルール: 広告モーダル等を足すときはここに追記する
const RULES = [{ test: () => true, render: renderSupportFab }];

「このドキュメントページを見ている人にはこの本のモーダルを出す」のようなページ単位のマッチングを、配信レイヤだけで足せる置き場です。

fork問題: OSSリポジトリに置くデプロイglueの行儀

一つ悩んだのが、プロダクトの一部はpublicなOSSだという点です。historymapのリポジトリに worker/index.js を置くと、forkして自分のCloudflareアカウントにデプロイした人の画面にも私のKo-fiボタンが出てしまう。これは行儀が悪い。

対策は上のコードにすでに入っています。

if (new URL(request.url).hostname !== 'kenimoto.dev') return response;

Workerは自分がどのURLで呼ばれたかを知っているので、ホスト名が kenimoto.dev のときだけ注入します。forkがどこにデプロイされても(*.workers.dev でも独自ドメインでも)配線は不活性で、外部リクエストすら発生しません。オーバーレイJS側にも同じホスト名チェックを入れて二重にしてあります。

副産物として、wrangler dev のローカル開発やworkers.devのプレビューでも注入が走らないので、開発中にボタンが邪魔になることもなくなりました。READMEには「worker/ はkenimoto.dev配信専用のglue。ホストゲート済みだが、forkしたら消すのを推奨」と明記しています。

罠: Workerを書いたのに1行も実行されない

デプロイして本番URLを確認したら、注入されていませんでした。

$ curl -s https://kenimoto.dev/products/historymap/ | grep -c 'products-overlay.js'
0

レスポンスヘッダに cf-cache-status: HIT が付いていたので、最初は旧構成時代のHTMLがエッジキャッシュに残っているのだと疑いました。該当URLをパージして再確認、それでも0。キャッシュではありません。

原因は仕様でした。Workers Static Assetsは、リクエストがアセットに一致する場合、デフォルトではWorkerスクリプトを起動せずアセットを直接返します。 main にスクリプトを指定していても、です。Workerが実行されるのは「アセットに一致しなかったリクエスト」だけ。今回のように全ページが静的HTMLだと、注入コードは永久に呼ばれません。

解決は設定1行です。

"assets": {
  "directory": "./dist-worker",
  "binding": "ASSETS",
  "run_worker_first": true
}

run_worker_first: true で全リクエストが先にWorkerを通るようになり、注入が動きました。アセット直接配信のエッジキャッシュ最適化を手放すことになりますが、HTMLRewriterはストリーミング処理なので体感の差はありません。

「デプロイは成功、エラーはゼロ、でもコードが1行も実行されていない」という沈黙の失敗なので、Static Assetsと main を併用する構成では最初に疑う場所として覚えておくと数十分節約できます。

ガードのまとめ

オーバーレイの実体側は、入口で4つのガードを通しています。

if (window.self !== window.top) return;              // iframe埋め込み先では描画しない
if (location.hostname !== 'kenimoto.dev') return;    // fork先・プレビューでは何もしない
if (!location.pathname.startsWith('/products/')) return;
if (window.__kenProductsOverlay) return;              // 二重読み込み防止

1つ目は今回の構成に固有の事情です。historymapはiframe埋め込みが主要ユースケースのツールなので、他人のサイトに埋め込まれた年表の上に支援ボタンが浮いたら事故です。最上位ウィンドウのときだけ描画します。

計測はGA4に寄せました。プロダクトページが自前のgtagを持たない場合はオーバーレイがbootstrapし、支援クリックは support_click イベントで本体サイトと同じプロパティに飛ばします。プロダクト側にアナリティクスの実装義務がないのも「テンプレートに配線を入れるだけ」の一部です。

まとめ

  • プロダクト画面への横断的な導線は、iframeラッパーではなくエッジ注入で。SEOを犠牲にしない
  • 注入はscriptタグ1行の「配線」に徹し、実体は本体サイトの1ファイルに集中させる。変更が全プロダクトに即時反映され、再デプロイ不要
  • OSSリポジトリに置くデプロイglueはホスト名でゲートする。forkには不活性、READMEに削除推奨を明記
  • Static Assets + main の併用は run_worker_first: true を忘れると、Workerが沈黙したまま一切実行されない

新しいプロダクトを出すときにやることは、テンプレートからrepoを作ってデプロイするだけ。支援導線と計測はコンセントに挿さった状態で付いてきます。マネタイズの配管を「後でやる」リストから消せたのが、この構成の一番の収穫でした。