🙋
puppeteer をローカルプロキシとしてリクエストを監視&モックする
パフォーマンスチューニングで、ソースコードに触らず非破壊でネットワークリクエストを書き換えて、LCPがどれだけ改善するかの実験ツールが欲しかったんですが、この目的で良いプロキシツールがないです。
世のローカルプロキシツールは DNS の設定を要求してきます。これは潜在的に意図しない状況を引き起こすので、使いたくありませんでした。
tl;dr
- puppeteer の
page.setRequestInterception(true)
でリクエストを覗いて、書き換えた
ブラウザからリクエスト内容を奪う方法
テスト用HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<script type="module">
const x = await fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json())
const el = document.createElement('pre')
el.textContent = JSON.stringify(x, null, 2)
document.body.appendChild(el)
</script>
</body>
</html>
これは jsonplaceholder からJSONを取得してDOMに pre 要素として挿入する簡単なHTMLです。
このリクエストの内容を途中で引っこ抜いて、内容を書換えたいとします。
puppeteer network interception
puppeteer 自体のインストールは略
これを使う Node のスクリプト
import puppeteer, { type HTTPRequest } from "puppeteer";
async function main() {
const browser = await puppeteer.launch({
headless: false,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--window-size=1024,768",
],
});
// Ctrl-C で強制終了
process.on("SIGINT", () => {
browser.close().finally(() => {
process.exit(0);
});
});
// requests インスタンスが同一化を確認する用
const requests = new Set<HTTPRequest>();
const page = (await browser.pages())[0];
await page.setRequestInterception(true);
page.on("request", (req) => {
requests.add(req);
if (req.isInterceptResolutionHandled()) return;
if (req.url().endsWith("/posts")) {
req.respond({
status: 200,
contentType: "application/json",
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
body: JSON.stringify([
{ id: 1, title: "title1" },
{ id: 2, title: "title2" },
{ id: 3, title: "title3" },
]),
});
return;
}
req.continue();
});
page.on("requestfinished", async (request) => {
requests.delete(request);
const response = request.response();
if (!response) {
return;
}
});
page.on("requestfailed", (request) => {
requests.delete(request);
});
await page.goto("http://localhost:4000/proxied.html", {
waitUntil: "networkidle0",
});
console.log("[results]", requests.size);
await browser.close();
}
main().catch(console.error);
大事なのはここ。 /posts
にリクエストしているときは、モックデータで即座にレスポンスを作って変更しています。
const page = (await browser.pages())[0];
await page.setRequestInterception(true);
page.on("request", (req) => {
// ...
if (req.url().endsWith("/posts")) {
req.respond({...});
return
}
req.continue();
}
Initiator
Chromeのデバッガープロトコルで動いてるのでネットワークが発火するまでのコールスタックが取れます。
if (req.url().endsWith("/posts")) {
const initiator = req.initiator();
console.log(
"[mock]",
req.url(),
"by",
// initiator?.url,
initiator?.stack?.callFrames.map((frame) => {
return `${frame.url}:${frame.functionName}:${frame.lineNumber}:${frame.columnNumber}`;
})
);
req.respond({...});
return;
}
proxied.html のJSで main 関数を挟んでみます。
async function main() {
fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json())
.then(data => {
const el = document.createElement('pre')
el.textContent = JSON.stringify(data, null, 2)
document.body.appendChild(el)
})
}
main();
こういうログ。
[mock] https://jsonplaceholder.typicode.com/posts by [
'http://localhost:4000/proxied.html:main:9:6',
'http://localhost:4000/proxied.html::17:4'
]
かんたんなネットワークプロファイラを作る
何のJSのリクエストがどれだけ遅いかを確認するために、Initiatorごとにリクエストを計測するプロファイラを作ってみます。
import puppeteer, { type HTTPRequest } from "puppeteer";
type Profile = Map<
string,
Array<[duration: number, started: number, ended: number]>
>;
function serializeHttpRequest(req: HTTPRequest): string {
const method = req.method();
const initiator = req.initiator();
const firstFrame = initiator?.stack?.callFrames[0];
if (!firstFrame) return `${method} <no-stack> ${req.url()}`;
return `${method} ${firstFrame?.url}:${firstFrame?.functionName}:${firstFrame?.lineNumber}:${firstFrame?.columnNumber}`;
}
function serializeProfile(profile: Profile): string {
const sortedUrls = Array.from(profile.keys()).sort();
let s = "";
let allTotal = 0;
for (const url of sortedUrls) {
const durations = profile.get(url)!;
const reqsByStarted = durations.sort((a, b) => a[1] - b[1]);
if (reqsByStarted.length === 1) {
s += `${url}\t${reqsByStarted[0][0]}\n`;
allTotal += reqsByStarted[0][0];
} else {
const sum = reqsByStarted.reduce((acc, [duration]) => acc + duration, 0);
allTotal += sum;
s += `${url}\ttotal:${sum}\n`;
for (const [duration, started, ended] of reqsByStarted) {
s += ` ${duration}\n`;
}
}
}
s += `total:${allTotal}`;
return s;
}
async function main(targetUrl: string) {
const browser = await puppeteer.launch({
headless: false,
// slowMo: 30,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--window-size=1024,768",
],
});
process.on("SIGINT", () => {
browser.close().finally(() => {
process.exit(0);
});
});
// requests インスタンスをキャッシュして開放しているかを確認
const requests = new Set<HTTPRequest>();
const page = (await browser.pages())[0];
await page.setRequestInterception(true);
const profiles: Map<
string,
Array<[duration: number, started: number, ended: number]>
> = new Map();
const requestStarted: Map<HTTPRequest, number> = new Map();
page.on("request", (req) => {
requests.add(req);
requestStarted.set(req, performance.now());
if (req.isInterceptResolutionHandled()) return;
req.continue();
});
page.on("requestfinished", async (request) => {
requests.delete(request);
const started = requestStarted.get(request)!;
const now = performance.now();
const duration = now - started;
const key = serializeHttpRequest(request);
if (!profiles.has(key)) {
profiles.set(key, []);
}
profiles.get(key)!.push([duration, started, now]);
});
page.on("requestfailed", (request) => {
requests.delete(request);
const started = requestStarted.get(request)!;
const now = performance.now();
const duration = now - started;
const key = serializeHttpRequest(request);
if (!profiles.has(key)) {
profiles.set(key, []);
}
profiles.get(key)!.push([duration, started, now]);
});
await page.goto(targetUrl, {
waitUntil: "networkidle0",
});
await page.waitForSelector("body");
await page.waitForNetworkIdle();
console.log("[serialized]", serializeProfile(profiles));
await browser.close();
}
main("<url>").catch(console.error);
適当にでっちあげたスクリプトなので、コードは適当です。
https://www.nicovideo.jp/ranking 相手の実行例
OPTIONS <no-stack> https://api.nicoad.nicovideo.jp/v1/nicoadgroups 57.81432599999994
OPTIONS <no-stack> https://prebid-a.rubiconproject.com/event 25.908453999999892
POST https://micro.rubiconproject.com/prebid/dynamic/14490.js?key1=wwwnicovideojp:i:11:39025 total:1965.1232170000007
157.67779999999993
45.16228000000001
409.00818700000036
373.6880229999997
271.213068
138.20560100000012
25.350596000000223
137.82976099999996
359.40771700000005
25.330491000000166
13.241911999999957
9.00778100000025
POST https://resource.video.nimg.jp/web/scripts/bundle/vendor.js?1729155654::1:479756 63.3017040000002
POST https://www.googletagmanager.com/gtag/js?id=G-5LM4HED1NJ&l=NicoGoogleTagManagerDataLayer&cx=c:Jc:239:222 48.95321899999999
POST https://www.googletagmanager.com/gtag/js?id=G-5LM4HED1NJ&l=NicoGoogleTagManagerDataLayer&cx=c:Mc:240:212 72.90145000000007
POST https://www.googletagmanager.com/gtag/js?id=G-FS29H4ZGX2&l=NicoGoogleTagManagerDataLayer&cx=c:Jc:161:222 142.85180200000013
POST https://www.googletagmanager.com/gtag/js?id=G-FS29H4ZGX2&l=NicoGoogleTagManagerDataLayer&cx=c:Mc:162:212 102.04987099999994
total:23485.146703
これを任意なデータでソートしたり大きい数字だけ表示したりすると、いい感じに使えそうですね。
というわけで、Chrome Debugger Protocol でローカルプロキシ相当のことをする例でした。
Discussion