本記事は、弊社が2026年7月に開発し社内および実案件のレビューフローで運用している画面レビューツール「PinRemark(ピンリマーク)」の設計・実装の記録です。掲載しているコードは実際の実装からの抜粋(一部簡略化)です。特定のSaaSやライブラリの優劣を論じるものではなく、「リバースプロキシでレビューウィジェットを注入する」というアプローチの技術的な勘所を共有することを目的としています。
はじめに:Web制作の「修正指示」はなぜ伝わらないのか
Webサイトの制作では、公開前に必ず「お客様に画面を確認してもらい、修正点を集める」工程があります。ここで交わされるやり取りは、多くの現場でいまだにこうです。
- スクリーンショットを撮り、ペイントソフトで赤丸を付けてメールに添付
- Excelに「トップページ 中段あたり 画像差し替え」と書いた指示書
- 「スマホで見ると崩れてる箇所がある」という電話(どの機種のどの画面かは不明)
この方式の問題は、手間そのものより「位置情報の欠落」にあります。「中段あたり」はPCとスマホで違う場所を指し、スクリーンショットは撮った瞬間の画面幅でしか正しくない。結果、制作側は「どこの話か」を推測で補い、推測が外れると手戻りになります。往復のたびにメールの添付ファイルが増え、どの指示が対応済みかも分からなくなっていきます。
「実際の画面の、その場所を、クリックして指せればいいのに」——これを実現するのが画面レビューツール(ビジュアルフィードバックツール)と呼ばれるジャンルで、海外製を中心に多くのSaaSがあります。ただ、既存サービスの多くはターゲットサイトへのタグ(スクリプト)埋め込みを前提としています。ここが今回、自社開発に踏み切ったポイントでした。
タグ埋め込み型ではなく、リバースプロキシ型にした理由
レビューウィジェットをページに出す方法は、大きく3つあります。
| 方式 | 仕組み | 課題 |
|---|---|---|
| タグ埋め込み型 | ターゲットサイトのHTMLに<script>を追加 |
レビューのために本番・ステージングのコードを変更する必要がある。消し忘れると本番に残る |
| iframe型 | レビュー画面の中にターゲットサイトをiframeで表示 | X-Frame-OptionsやCSPのframe-ancestorsで表示を拒否される。クロスオリジンだとiframe内のDOMに触れず、ピンを要素に紐付けられない |
| リバースプロキシ型 | プロキシがターゲットサイトを中継し、HTMLを書き換えてウィジェットを注入 | URL書き換えの実装が重い(本記事の本丸) |
決め手は「ターゲットサイトへの影響ゼロ」を絶対条件にしたことです。レビューはあくまで制作側の都合であり、そのためにお客様のサイト(あるいは納品直前のコード)へ一時的でもスクリプトを混入させたくない。同一ページを「レビュー用URL」と「素のURL」で並行して見られることも重要でした。リバースプロキシ型なら、ターゲットサイトは自分が中継されていることを知る必要すらありません。
同じ発想はWeb高速化サービスや翻訳プロキシ(例:明示的にプロキシ構成を取るサービス群)で実績のあるパターンで、技術的な前例は十分。問題は「どこまで書き換えれば普通のサイトが壊れず表示できるか」の見極めです。
全体アーキテクチャ:依存パッケージは3つだけ
構成は意図的に小さくしています。ランタイムはNode.js 20、npm依存はExpress・ws・better-sqlite3の3つのみ。フロントエンド(ウィジェットと管理画面)はビルド工程なしのバニラJSです。社内ツールは「数年後に触る人が環境を再現できること」が何より大事で、フレームワークやバンドラの世代交代に巻き込まれない構成を選びました。
クライアント(ブラウザ)
│ http://pinremark-host:8720/p/<project-key>/...
▼
PinRemark プロキシサーバー (Node.js / Express)
│ 1. ターゲットサイトへリクエストを中継
│ 2. レスポンス HTML の URL を /p/<key>/ 配下に書き換え
│ 3. レビューウィジェット JS を </body> 直前に自動注入
▼
ターゲットサイト(変更・埋め込み一切不要)
| パス | 役割 |
|---|---|
/p/<project-key>/... | リバースプロキシ(クライアントに共有するレビューURL) |
/__pinremark__/widget.js | 注入されるウィジェット本体 |
/__pinremark__/api/... | ピン・コメントのREST API |
/__pinremark__/ws | WebSocket(リアルタイム配信) |
/admin/ | 制作チーム向け管理ダッシュボード |
__pinremark__という名前空間は、ターゲットサイトのパスと衝突しないための予約プレフィックスです。プロキシ・API・WebSocketを同一プロセスに同居させているので、デプロイはDockerコンテナ1つで完結します。
実装①:プロキシのURL書き換え——本丸はここ
リバースプロキシ型の成否は、レスポンスの書き換え精度で決まります。ターゲットサイト内のリンクや画像がプロキシを経由せず素のURLに飛んでしまうと、そのページではピンが立てられません。PinRemarkでは以下を書き換えています。
| 対象 | 書き換え内容 |
|---|---|
| HTML属性 | href / src / action / formaction / poster / data-src / data-hrefのうち、ルート相対URLとターゲット同一ホストの絶対URLを/p/<key>/配下へ |
srcset | カンマ区切りの各候補URLを個別に書き換え(レスポンシブ画像対応) |
| CSS | 外部CSSファイルとインラインCSSのurl(/...)(ルート相対のみ) |
Locationヘッダ | リダイレクト先がターゲットと同一ホストならプロキシ配下へ |
Set-Cookie | Domain除去・Pathをプロキシ配下に固定・Secure除去(http運用のため) |
| セキュリティヘッダ | Content-Security-Policy・X-Frame-Options・HSTSを除去(注入スクリプトの動作保証) |
meta refresh | content="0; url=..."のURL部分 |
URL値の書き換え判定はこの1関数に集約しています。data:やmailto:のようなスキームは素通しし、相対パスは書き換えません——プロキシ配下のURLからの相対解決で自然に正しい場所へ届くからです。
// ターゲットサイト内の URL を /p/<key>/... に書き換える
function rewriteUrlValue(value, project, target) {
if (!value) return value;
const v = value.trim();
if (/^(#|data:|blob:|javascript:|mailto:|tel:|about:)/i.test(v)) return value;
const prefix = `/p/${project.key}`;
if (/^https?:\/\//i.test(v) || v.startsWith('//')) {
const u = new URL(v.startsWith('//') ? `http:${v}` : v);
if (u.host === target.host) {
return `${prefix}${u.pathname}${u.search}${u.hash}`;
}
return value; // 外部ドメインはそのまま
}
if (v.startsWith('/')) return `${prefix}${v}`;
return value; // 相対パスはプレフィックス配下で自然に解決される
}
書き換え漏れの「安全網」:Refererフォールバック
どれだけ属性を書き換えても、JavaScriptが実行時に組み立てるURLは静的な書き換えでは捕捉できません。fetch('/api/items')のようなコードはプロキシを素通りし、プロキシサーバー自身の/api/itemsに404で着弾します。そこで、マッチしなかったリクエストのRefererヘッダを見て、レビュー閲覧中のプロジェクトを特定できたら正しいプロキシ配下へ307リダイレクトで送り返す安全網を最後に敷いています。
// 書き換え漏れ(JS が組み立てた URL 等)を Referer から救済するフォールバック
function refererFallback(req, res, next) {
const referer = req.headers.referer || '';
const m = referer.match(/\/p\/([a-zA-Z0-9_-]+)(\/|$)/);
if (m && projectExists(m[1])) {
return res.redirect(307, `/p/${m[1]}${req.originalUrl}`);
}
next();
}
307を使うのは、POST等のメソッドとボディを維持したまま転送するためです(308でも可ですが、恒久的なマッピングではないので307にしています)。この安全網のおかげで、SPAでない一般的なサイトなら「表示が壊れる」ケースはほぼなくなりました。
実装②:ウィジェット注入とShadow DOMによる完全隔離
HTMLの書き換えが済んだら、最後に</body>の直前へウィジェットのscriptタグを1本注入します。プロジェクトキーなどの文脈はdata-*属性で渡します。
const inject = `<script src="/__pinremark__/widget.js"
data-project="${project.key}" data-prefix="/p/${project.key}" defer></script>`;
html = html.replace(/<\/body>/i, `${inject}</body>`);
ここで重要なのがスタイルとロジックの隔離です。注入先は「どんなCSSが当たっているか分からない他人のページ」。素朴にdivを追加すると、ターゲットサイトのリセットCSSやTailwindのユーティリティに巻き込まれてウィジェットの見た目が崩れ、逆にウィジェットのCSSがターゲットサイトのデザインを壊す事故も起きます。
PinRemarkでは、ツールバー・ピン・コメントモーダルといったUIをすべてShadow DOM(attachShadow({mode: 'open'}))の内側でレンダリングしています[1]。Shadow DOM内のスタイルは外に漏れず、外のスタイルも(継承プロパティ以外は)中に入ってきません。「レビューUIがBootstrapのボタンに化ける」「ターゲットのz-index戦争に巻き込まれる」類いの問題を、仕組みごと封じられます。
注入したスクリプトを確実に動かすため、プロキシはContent-Security-Policyヘッダを除去しています。これはターゲットサイトのセキュリティポリシーを緩める行為なので、レビュー用途の限定的なURLでのみ許される割り切りです。PinRemarkのレビューURLは納品前・テスト公開中のサイト確認を想定しており、不特定多数に公開する使い方は想定していません。
実装③:ピンの位置をどう保存するか——セレクタ+相対%の二重化
「画面のこの場所」を保存する方法として、最初に思いつくのはクリック座標(px)の記録です。しかしこれは画面幅が変わった瞬間に破綻します。お客様が1920pxのモニタで立てたピンを、制作者が1440pxのノートPCで見ると、まったく別の要素の上に表示されてしまう。
PinRemarkはピンの位置を2重の形式で保存し、復元時に優先順位をつけて解決します。
| 優先度 | 形式 | 内容 |
|---|---|---|
| 第一優先 | CSSセレクタ+要素内相対位置(%) | クリックされた要素をid起点またはtag:nth-of-typeのパスで特定し、その要素の中の相対位置(x%, y%)を記録 |
| フォールバック | ページ絶対座標(px) | DOM変更などでセレクタが解決できなくなった場合に使用 |
セレクタ+相対%方式なら、「3枚目のカードの右上」に立てたピンは、画面幅が変わってカードの位置が動いても「3枚目のカードの右上」に追従します。加えて、投稿時のビューポートサイズとUser-Agentもメタデータとして保存しているため、「スマホ実機で見たときの指摘」であることが制作側に伝わります。
ピンは2種類あり、クリックで点ピン、ドラッグで範囲指定(矩形)を設置できます。「この段落全体の行間を詰めたい」のような面の指摘は矩形のほうが正確に伝わるためです。矩形も中心直下の要素を基準に同じ二重形式で保存します。
実装④:WebSocketリアルタイム同期とSQLite永続化
レビューは「お客様が画面を見ながら、制作者が電話やWeb会議で話す」状況で使われることが多く、ピンを立てた瞬間に相手の画面にも出ることが体験の質を大きく左右します。PinRemarkはwsによるWebSocketで、ピン・コメントの作成/更新イベントを同一プロジェクトの全閲覧者と管理画面へ即時配信します。管理画面はproject=*で接続し、全プロジェクトの動きを購読します。
永続化はSQLite(better-sqlite3/WALモード)です。テーブルは3つだけ。
projects(id, key UNIQUE, name, target_url, created_at)
pins(id, project_id, number, page_path, selector, x_percent, y_percent,
page_x, page_y, kind[point|rect], w_percent, h_percent,
status[open|resolved], created_by,
viewport_w, viewport_h, user_agent, created_at, updated_at)
comments(id, pin_id, author, body, created_at)
この規模のツールにPostgreSQLを立てるのは運用負担のほうが大きい、というのが判断です。better-sqlite3は同期APIでトランザクションが素直に書け、WALモードなら読み取りと書き込みの並行にも十分耐えます[2]。バックアップはdata/ディレクトリのファイルコピーで済みます。
ハマりどころと既知の制約
1. Cookieの書き換えは「動くまで」が長い
認証付きのステージングサイトを中継する場合、ターゲットが発行するSet-Cookieをそのまま返すと、Domain不一致やPath不一致でブラウザがCookieを保存しません。Domain属性を除去し、Pathをプロキシ配下(/p/<key>)に付け替え、http運用のためSecure属性も除去する——という書き換えでログインセッションが維持できるようになります。裏を返せば、Secureを外している以上、機密性の高い本番サイトの中継には使うべきではありません。これは設計上の割り切りとしてREADMEにも明記しています。
2. RefererヘッダとOriginヘッダの付け替え
ターゲットサイト側がRefererやOriginを見てCSRF対策をしている場合、プロキシのホスト名のままだと拒否されます。転送時にRefererのプロキシ部分をターゲットのオリジンへ付け替えることで、フォーム送信も通るようにしています。
3. 中継できないもの:ターゲット自身のWebSocket
現状の実装はHTTP(S)の中継のみで、ターゲットサイト自身がWebSocketを使う場合(チャットUIなど)その通信は中継されません。また、Refererが付かないリクエスト(rel="noreferrer"や一部のプリフェッチ)は前述のフォールバックでも救済できません。ここは「レビューという用途では実害が出にくい」と判断して割り切っています。
4. 圧縮の二重処理
Node.jsのfetch(undici)はAccept-Encodingを自動でネゴシエーションし、レスポンスを展開済みで返します。にもかかわらず上流のContent-Encoding: gzipヘッダをそのままクライアントへ返すと、ブラウザは「gzipのはずなのに展開できない」と表示に失敗します。content-encoding・content-length・transfer-encodingを転送対象から外すのを忘れないことです。
運用してみて:修正やり取りはどう変わったか
PinRemarkは現在、弊社のWeb制作案件のレビューフローで実際に使っています。お客様には「レビュー用URL」を1本お渡しするだけ。アプリのインストールもアカウント登録も不要で、ブラウザで開いて右下の「ピンを立てる」を押し、気になった場所をクリックしてコメントを書いてもらいます。
変化として大きかったのは次の3点です。
- 「どこの話か」の往復が消えた。ピンは要素に紐付いているので、PCで立てた指摘をスマホ幅で確認しても同じ場所を指す。位置の解釈違いによる手戻りがなくなった
- 対応漏れが構造的に起きなくなった。指摘は自動採番され、対応後に「解決」へ変更する運用のため、「メールのどこかに書いてあった要望」が消失しない
- お客様側の心理的ハードルが下がった。「わざわざメールを書くほどでもない」細かな違和感が、ワンクリックで届くようになり、公開前に潰せる品質問題が増えた
サイトの微調整フェーズの体感速度は明確に上がりました。指摘を受けてから修正・確認依頼までがリアルタイムで回るため、Web会議をしながらその場で直して「解決」まで進むケースもあります。
よくある質問(FAQ)
Q. レビュー対象のサイトにタグやスクリプトを埋め込む必要はありますか?
不要です。プロキシサーバーがHTMLを中継する際にウィジェットのscriptタグをオンザフライで注入するため、ターゲットサイトのコードは一切変更しません。レビューをやめたければ、レビューURLを使わなくなるだけで痕跡も残りません。
Q. ターゲットサイトのCSSとレビューUIが干渉しませんか?
ウィジェットUIはすべてShadow DOM内でレンダリングしているため、ターゲットサイトのCSSフレームワークやリセットCSSと相互に干渉しません。
Q. レイアウトが変わってもピンの位置はずれませんか?
ピンは「CSSセレクタ+要素内相対位置(%)」を第一優先、「ページ絶対座標」をフォールバックとする二重形式で保存しており、対象要素が特定できる限りレイアウト変化や画面幅の違いに追従します。大規模なDOM構造の変更(要素の削除など)があった場合は絶対座標での表示になります。
Q. 認証付きサイトや本番サイトもレビューできますか?
Cookieベースの認証付きサイトは、Set-CookieのDomain・Path書き換えにより中継できます。ただしhttp運用のためSecure属性を除去する設計であり、機密性の高い本番サイトの中継は想定していません。納品前・テスト公開中のサイトでの利用を前提としています。
Q. 同様のツールの受託開発は依頼できますか?
可能です。今回のようなリアルタイム同期を伴う業務ツール、レビュー・承認フロー、既存システムと連携する社内向けWebアプリの設計・開発を承っています。Webシステム開発のページ、またはお問い合わせからご相談ください。
まとめ
- Web制作の修正指示は「位置情報の欠落」が手戻りの根本原因。実画面へのピン留めで解決できる
- タグ埋め込み型・iframe型の課題(コード混入、X-Frame-Options/CSP、クロスオリジン制約)を避けるため、リバースプロキシ型を採用した
- 成否を分けるのはURL書き換えの網羅性。HTML属性・srcset・CSSの
url()・Location・Set-Cookieを書き換え、JSが組み立てる動的URLはRefererベースの307リダイレクトで救済する - ウィジェットUIはShadow DOMで隔離し、ターゲットサイトのCSSとの相互干渉を仕組みごと封じた
- ピン位置は「CSSセレクタ+要素内相対%」と「絶対座標」の二重保存で、画面幅の違いやレイアウト変化に追従させる
- 構成はNode.js 20+Express+ws+better-sqlite3のみ。ビルド工程なし・Dockerコンテナ1つで、数年後も保守できる形にした
汎用のSaaSを使えば済む領域でも、「ターゲットに一切手を入れない」「社内LANで完結させる」のような譲れない条件があるなら、小さく自社開発する価値は十分にあります。PinRemarkは実装の中核(プロキシ書き換え+注入+Shadow DOM+WebSocket)だけなら1,000行に満たない規模ですが、修正やり取りという制作フローのボトルネックを確実に取り除いてくれました。
テクノスフィアでは、こうしたリアルタイム性のある業務ツールやWebシステムの受託開発を承っています。WebSocketを用いた最大200名同時参加のリアルタイム入札システムなどの開発事例も公開していますので、あわせてご覧ください。
参考文献・出典
- MDN Web Docs「Using shadow DOM」(Shadow DOMによるスタイル・DOMのカプセル化) — developer.mozilla.org
- SQLite「Write-Ahead Logging」(WALモードの並行読み書き特性) — sqlite.org/wal.html
- MDN Web Docs「307 Temporary Redirect」(メソッドとボディを維持するリダイレクト) — developer.mozilla.org
- MDN Web Docs「Content-Security-Policy」 — developer.mozilla.org
- better-sqlite3(GitHub) — github.com/WiseLibs/better-sqlite3
- ws: a Node.js WebSocket library(GitHub) — github.com/websockets/ws
※本記事のコードは2026年7月時点の弊社実装からの抜粋(一部簡略化)です。PinRemarkは弊社の社内ツールであり、現時点で一般提供・販売は行っていません。
WebSocketを使ったリアルタイムWebシステムの開発事例は最大200名同時参加のリアルタイム入札システムで、AIエージェントによる業務自動化はObsidian作業ログ+Redmineチケット運用の完全自動化で紹介しています。