はじめに
前回の 「中編」 では、.toString() と eval() を組み合わせ、ドメインの壁を超えて関数を遠隔実行する 「RPC」 の実装を解説しました。
シリーズ記事
| № | 記事 | 内容 |
|---|---|---|
| 1 | [悪用禁止] 外部サイトのDOMを自前SPAで操る(前編):Cross-Domain通信の仕組み | postMessageによるセキュアな通信基盤とHeartbeatの実装 |
| 2 | [悪用禁止] 外部サイトのDOMを自前SPAで操る(中編):RPCによる関数の遠隔実行 | 関数シリアライズによるドメイン跨ぎのRPCメカニズム |
| 3 | [悪用禁止] 外部サイトのDOMを自前SPAで操る(後編):Node.jsとTerserによる自動化と配備 | 本編 |
開発環境で手作業でコンソールを叩く分にはこれで完成ですが、一般ユーザーに「非エンジニアでも簡単に使えるツール」として提供するには、配布の手間を最小限に抑える必要があります。
最終回となる「後編」では、Node.jsとTerserを用いたビルドプロセスの自動化と、ユーザーの導入体験を向上させる 「Bookmarklet化」 について詳しく見ていきます。
方法
配布とインストールの自動化(Bookmarklet化)
「コンソールへのコピペ」による実行は、エンジニア以外の人に使ってもらうには不親切です。
コードを自動でエスケープし、javascript: プロトコルを付与して「Bookmarklet」化することで、ユーザーへの提供コストを下げ、利便性を向上させます。
1. 実行用スクリプトのファイル化
まず、前編・中編を通じて完成させた Parent側(外部サイト側)の全量コード を、プロジェクト内に bridge-loader.js という名前で保存します。
(function() {
const parentDomain = window.location.origin;
const spaUrl = `http://localhost:3000/#parentDomain=${encodeURIComponent(parentDomain)}`;
// 1. SPAを子画面として開く
const subWin = window.open(spaUrl, "Cross-Domain-RPC-Demo");
if (subWin) subWin.focus();
// 2. メッセージハンドラ(Heartbeat応答 + RPC実行)
window.addEventListener("message", (event) => {
// セキュリティチェック:許可したオリジン(localhost:3000)以外は無視
if (event.origin !== "http://localhost:3000") return;
const { type, functionStr, timestamp, args } = event.data;
// Heartbeat (PING) への応答
if (type === 'HEARTBEAT_PING') {
event.source.postMessage({ type: 'HEARTBEAT_PONG' }, event.origin);
}
// RPCリクエスト(関数実行命令)の処理
else if (type === "RPC_REQUEST") {
try {
// 文字列化された関数を復元してParentのコンテキストで実行
const executor = eval('(' + functionStr + ')');
const result = executor(...args);
// 実行結果をChild側へ返信
event.source.postMessage({
type: "RPC_RESPONSE",
timestamp,
data: result
}, event.origin);
} catch (e) {
console.error("RPC Error:", e);
}
}
});
})();
これにより、開発時は読みやすい外部ファイルとして管理し、配布時は一行の「URL化したスクリプト」に変換するという、クリーンな開発環境が整います。
2. Terserによる自動コンパイル
次に、保存したファイルを読み込んで極限まで圧縮し、Bookmarklet形式に変換するスクリプト build-bookmarklet.js を作成します。
const { minify } = require('terser');
const fs = require('fs');
const path = require('path');
// 1. コマンドライン引数から対象ファイルを取得
const inputPath = process.argv[2];
if (!inputPath) {
console.error("Error: 入力ファイルパスを指定してください。");
console.error("Usage: node build-bookmarklet.js <file-path>");
process.exit(1);
}
async function build() {
try {
// 2. 指定されたファイルを読み込む
const code = fs.readFileSync(inputPath, 'utf8');
// 3. Terserで圧縮を実行
const minified = await minify(code, {
compress: { passes: 2 },
mangle: true,
format: { comments: false }
});
const bookmarklet = `javascript:${minified.code}`;
// 4. 元のファイル名をベースに出力ファイル名を決定 (例: bridge-loader_bookmarklet.js)
const ext = path.extname(inputPath);
const base = path.basename(inputPath, ext);
const outputPath = `./${base}_bookmarklet.js`;
// 5. ファイルとして書き出し
fs.writeFileSync(outputPath, bookmarklet);
console.log(`--- Success: ${outputPath} ---`);
console.log(bookmarklet);
} catch (err) {
console.error("Build Error:", err.message);
}
}
build();
実行は node build-bookmarklet.js bridge-loader.js のように、対象のファイルを引数に渡すだけです。
これにより、複数のツールを開発する場合でも、単一のビルドスクリプトを使いまわすことが可能になります。
3. UIへの統合:ドラッグ&ドロップ体験
生成された文字列を、SPA内の設定画面で <a> タグの href にセットします。
<!-- Settings.html (Child) -->
<div class="install-section">
<p>インストールの際は、下のボタンをブックマークバーにドラッグしてください:</p>
<a href="javascript:..." draggable="true" class="bookmarklet-btn">
Install Bridge Tool
</a>
</div>
ユーザーはこのリンクを下記の図のように、ブックマークバーにドラッグするだけで導入が完了します。
配布の手間という高い障壁を、最もシンプルなUXで解決しています。
ブラウザ拡張機能を作るまでもないが、特定のサイト上で高度なデータ処理やDOM操作を行いたい場合、このアーキテクチャは非常に有効なアプローチになります。
ぜひ、皆さんの開発支援ツール開発に役立ててください。
余談:サーバーレス・ブリッジの実装(Blob URLの活用)
本連載ではSPA(Child)を http://localhost:3000 でホストする構成を前提としましたが、外部サーバーを一切立てずにブックマークレットのみで完結させる手法も存在します。
具体的には、Child側のHTML/JSソースコードを文字列としてブックマークレットに含め、Blob オブジェクト を介して実行時に動的なURLを生成する方法です。
// 1. Child(子画面)となるHTML/JSの定義
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Serverless Bridge SPA</title>
<style>
body { font-family: sans-serif; padding: 20px; background: #f0f4f8; }
button { padding: 10px 20px; font-size: 14px; cursor: pointer; }
</style>
</head>
<body>
<h3>Serverless Bridge SPA</h3>
<p>Status: <span id="status">Disconnected</span></p>
<button onclick="requestTitle()">Parent側のタイトルを取得 (RPC)</button>
<script>
let isConnected = false;
let lastPongTime = Date.now();
const rpcCallbacks = {};
// RPC実行用Promiseラッパー
const execInParent = (executorFn, ...args) => {
return new Promise((resolve, reject) => {
if (!isConnected) return reject("Not connected");
const timestamp = Date.now();
rpcCallbacks[timestamp] = { resolve, reject };
// Blob URLは同一オリジン扱いのため、targetOriginに "*" を指定可能
window.opener.postMessage({
type: 'RPC_REQUEST',
functionStr: executorFn.toString(),
args,
timestamp
}, "*"); // 同一オリジン間のためワイルドカードを許容
});
};
// メッセージハンドラ
window.addEventListener("message", (event) => {
const { type, timestamp, data } = event.data;
if (type === 'HEARTBEAT_PONG') {
isConnected = true;
lastPongTime = Date.now();
document.getElementById("status").textContent = "Connected";
} else if (type === 'RPC_RESPONSE' && rpcCallbacks[timestamp]) {
rpcCallbacks[timestamp].resolve(data);
delete rpcCallbacks[timestamp];
}
});
// 生存確認(Heartbeat)の定時実行
setInterval(() => {
if (Date.now() - lastPongTime > 5000) {
isConnected = false;
document.getElementById("status").textContent = "Disconnected";
}
if (window.opener) {
window.opener.postMessage({ type: 'HEARTBEAT_PING' }, "*");
}
}, 1000);
// RPC実行テスト
async function requestTitle() {
try {
const result = await execInParent(() => document.title);
alert("Parent Title: " + result);
} catch (err) {
alert("Error: " + err);
}
}
</script>
</body>
</html>`;
// 2. Blob URLの生成と起動
const blob = new Blob([htmlContent], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
const subWin = window.open(blobUrl, "Serverless-Bridge");
if (subWin) subWin.focus();
// 3. Parent側の受信ロジック(そのままブラウザコンソールで実行することを想定)
window.addEventListener("message", (event) => {
const { type, functionStr, timestamp, args } = event.data;
// Heartbeat応答
if (type === 'HEARTBEAT_PING') {
event.source.postMessage({ type: 'HEARTBEAT_PONG' }, "*");
}
// RPCリクエストの実行
else if (type === "RPC_REQUEST") {
try {
const executor = eval('(' + functionStr + ')');
const result = executor(...args);
event.source.postMessage({
type: "RPC_RESPONSE",
timestamp,
data: result
}, "*");
} catch (e) {
console.error("RPC Execution Error:", e);
}
}
});
特徴とトレードオフ
この手法は、通常のオリジン間通信(Cross-Domain)とは異なるメリット・デメリットを持ちます。
| 項目 | 特徴 |
|---|---|
| Originの継承 |
blob: URLは生成元のドメイン(Origin)を継承します。操作対象のサイト上で生成すれば、Child Windowも同一オリジン扱いとなります。 |
| 通信の簡略化 |
同一オリジン(Same-Origin) となるため、postMessage を使わずとも window.opener 経由で直接的なDOM操作や関数実行が可能です(Origin通信のロジックを省略可能)。 |
| ポータビリティ | 外部サーバーを立てる必要がなく、ブックマークレット単体でツールが完結します。 |
| データ量の制限 | ブックマークレットの文字数制限により、埋め込めるコード量(ライブラリ等)に限界があります。 |
| 更新の手間 | コードを修正するたびに、ユーザーにブックマークレットの再登録を依頼する必要があります。 |
ツールが小規模であり、かつ「特定のサイトに最適化した補助ツール」として配布する場合には、このサーバーレスなアプローチも非常に有効です。
