🙋

puppeteer をローカルプロキシとしてリクエストを監視&モックする

2024/10/18に公開

パフォーマンスチューニングで、ソースコードに触らず非破壊でネットワークリクエストを書き換えて、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 自体のインストールは略

https://pptr.dev/guides/network-interception

これを使う 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