テンポや拍子などに名前を付けて保存した設定を、YouTube の動画のようにブログやサイトに埋め込めるようにしました。共有のパネルで設定を公開し、同じパネルの「埋め込む」でコードをコピーして、ページの HTML に貼ります。

- コピーしたコードをページの HTML に貼る。公開している設定でだけ押せる
貼ったページには、再生ボタンの付いた小さなメトロノームが出ます。鳴らす仕組みはアプリと同じなので、拍のアクセントやパターン、プログレッシブ、マルチトラックも、アプリで鳴らしたときと同じに鳴ります。練習のメモやレッスンの案内のページに、そのまま鳴らせるテンポを置いておく使い方を想定しています。

- 設定の名前と作った人
- 同じ設定をアプリで開く(新しいタブ)
- 再生と停止。スペースキーでも鳴らせる
この記事では、作るときに決めたことを書きます。iframe に入れてよいページをどう限るか、ほかのサイトの iframe の中で何が変わるか、アプリと同じ仕組みで鳴らしながら、見た人がアプリで使っている設定をどう守るかです。
iframe に入れてよいのは 1 つのパスだけ
Metronome.fm は、これまですべてのページで iframe に入ることを禁じていました。ほかのサイトが透明な iframe を重ねて、利用者に意図しないボタンを押させる攻撃(クリックジャッキング)を防ぐためです。禁じるのには、ページを返すときに付ける 2 つのヘッダーを使っています。CSP の frame-ancestors 'none' と、それに対応していない古いブラウザ向けの X-Frame-Options: DENY です。
埋め込み用には /embed/{設定の ID} というページを新しく作り、このページだけ frame-ancestors * にして、X-Frame-Options を付けないようにしました。ヘッダーを作る関数は「埋め込みを許すか」を引数で受け取ります。
// lib/security-headers.ts(抜粋)
const baseDirectives = (framing: Framing): Directives => ({
"frame-ancestors": framing === "anywhere" ? ["*"] : ["'none'"],
"base-uri": ["'self'"],
"object-src": ["'none'"],
});
export const buildSecurityHeaders = (env: Env, framing: Framing = "deny") => [
{ key: "Content-Security-Policy", value: buildEnforcedCsp(framing) },
// ALLOW-FROM は廃止されていて「どこでも可」は表せないので、許すページには付けない
...(framing === "deny" ? [{ key: "X-Frame-Options", value: "DENY" }] : []),
// …
];ヘッダーは next.config.ts の headers() で、パスごとに指定しています。すべてのページに当たる指定のあとに、埋め込みのページ用の指定を並べるだけでは、埋め込めるようになりません。Next.js では、同じパスに 2 つの指定が当たると、同じ名前のヘッダーは後の指定で上書きされます。一方で、片方の指定にしかないヘッダーはそのまま残ります。埋め込みのページでは、CSP は後の指定で埋め込み用に上書きされますが、ふつうのページの指定にしかない X-Frame-Options: DENY が残るので、どこにも埋め込めません。
そこで、ふつうのページの指定が埋め込みのページには当たらないよう、パスの条件から外しました(正規表現の否定の先読み)。
// next.config.ts(抜粋)
async headers() {
return [
{ source: "/((?!embed/[^/]+$).*)", headers: buildSecurityHeaders() },
{ source: "/embed/:settingId", headers: buildEmbedSecurityHeaders() },
];
},外すのは、1 階層の /embed/{設定の ID} だけです。はじめは /embed/ で始まるパスをまとめて外していましたが、これだと /embed/a/b のような存在しないパスで出る 404 のページも、iframe に入れられてしまいます。サイト共通の 404 は、ほかのサイトの iframe に入れる理由のないページです。iframe に入れられるページは少ないほうが安全なので、1 階層だけに限りました。E2E テストでは、埋め込みのページ、アプリの画面、埋め込みのページの下の存在しないパスの 3 つで、ヘッダーが狙いどおりかを確かめています。
ほかのサイトの iframe には Cookie が届かない
ブラウザは、ほかのサイトに埋め込まれた iframe がページを読み込むとき、SameSite=Lax の Cookie を付けません。Metronome.fm では、サインインの状態を持つ Cookie も、言語を持つ Cookie も SameSite=Lax です。そのため、埋め込みの中ではいつも、サインインしていない初めての人として扱われます。これを前提に、次の 3 つを決めました。
1 つ目は、出す設定を公開中のものだけにしたことです。持ち主が見ても、非公開の設定は出しません。持ち主のブラウザでだけ見えていると、ほかの人には見えていないことに、貼ったあとで気づけないためです。非公開に戻したり消したりした設定を開くと、iframe の枠に収まる短い案内を出します。
2 つ目は、再生の上限をゲストとして数えるようにしたことです。サインインしていない人(ゲスト)は、1 日に再生できる時間に上限があります。アプリでは、ゲストの再生した時間を、Cookie に入れたブラウザの ID ごとに数えています。埋め込みにはこの Cookie も届かないので、IP アドレスごとに数えます。時間は鳴らしているあいだだけ、アプリと同じく 10 秒ごとに数えています。
// app/embed/_components/embed-player.tsx(抜粋)
useEffect(() => {
// 確かめ終わる前に再生を押されたときに、再生の開始側が結果を待てるようにする
trackPlaybackLimitCheck(refreshPlaybackLimitState());
}, []);
useEffect(() => {
if (!isPlaying) return;
const interval = setInterval(() => {
void syncPlaybackLimit({ tick: true });
}, STATISTIC_SEND_INTERVAL_SECONDS * 1000);
return () => clearInterval(interval);
}, [isPlaying]);3 つ目は、サインインを埋め込みの中で行わないようにしたことです。上限に達したときのお知らせには、アプリと同じく「サインイン」のリンクが出ます。埋め込みでは、押すとアプリを新しいタブで開き、サインインはそちらで続けてもらいます。埋め込みの中ではサインインの状態を使わないので、サインインに使う Clerk の処理も、埋め込みのページを開くときは省いています。
アプリと同じ仕組みで鳴らし、アプリで使っている設定は上書きしない
埋め込みのために、別の小さなメトロノームは作りませんでした。マニュアルやサイトの紹介ページ(LP)のデモには、アプリと切り離した簡単な鳴らし方がありますが、これではパターンやマルチトラックを再現できません。埋め込みのページは、保存した設定を、アプリが設定を持っておく入れ物(ストア)に読み込み、アプリと同じ再生の仕組みで鳴らします。メトロノームの部分は、拍・BPM・再生ボタンだけにしました。
気をつけたのは、このストアがブラウザの localStorage に中身を保存することです。アプリはこれを使って、次に開いたときに前の設定を戻しています。ほかのサイトの iframe ではブラウザが保存先を分けるので、アプリの設定とは混ざりません。しかし Metronome.fm 自身のページ(このブログなど)に埋め込むと、保存先がアプリと同じになり、アプリで作っていた設定を埋め込んだ設定で上書きしてしまいます。そこで、埋め込みのページでは書き込みだけを止めました。
// features/metronome/store/metronome-settings-store/persist.ts(抜粋して簡略化)
const persistence = { enabled: true };
export const stopPersistingMetronomeSettings = () => {
persistence.enabled = false;
};
// zustand の persist に渡す storage の setItem
setItem: (name, value) => {
if (!isBrowserEnvironment() || !persistence.enabled) return;
localStorage.setItem(name, JSON.stringify(value));
},埋め込みのコード
コピーするコードは、幅をページに合わせ、640px より広くはしない iframe です。
<iframe src="https://metronome.fm/embed/{設定の ID}" title="{設定の名前}"
width="100%" height="400" style="border:0;max-width:640px" loading="lazy"></iframe>高さは 400px です。はじめは 360px にしていましたが、開発中に測ると、この高さでは再生ボタンが 21px はみ出しました。そこで、余白を詰めたうえで、高さを 400px にしました。
title には設定の名前を入れ、読み上げで iframe の中身がわかるようにしています。設定の名前は利用者が自由に付けるので、" や < が入っていても属性が途中で閉じてしまわないよう、HTML の属性用にエスケープしてからコードに入れています。