Enforce safe Chromium launch options across every Playwright / Puppeteer script in a project — without editing a single call site.
プロジェクト内のすべての Playwright / Puppeteer 起動に、安全な起動オプションと同時起動数の上限を強制する。呼び出し側のコードは1行も書き換えない。
A browser-automation project grows to 50–100 scripts. Somewhere in a wiki, or in a shell helper function, is the rule: "always pass --disable-gpu and friends."
Nobody does. We audited ours and found the flags in 1 script out of 100+ — the "mandatory" list lived in a PowerShell function, which a Node script can never call, so the mitigation had never been applied anywhere. On top of that, nothing capped how many browsers could exist at once, so headless Chromium instances accumulated across a working day until the desktop stopped responding to input and needed a reboot.
Telling people to remember a flag does not work. Enforce it in the runtime instead.
Preloaded with --require, it hooks Module._load and wraps chromium.launch, launchPersistentContext and the Puppeteer-shaped launch, so every launch in every script gets:
| Safe args injected | --disable-gpu, --disable-gpu-compositing, --disable-software-rasterizer, --disable-dev-shm-usage, --renderer-process-limit=4, and others. Args you already set explicitly are respected. |
| Concurrency cap | A PID-based lockfile pool in the temp dir. New launches queue instead of piling up. Stale locks from dead processes are reclaimed automatically. |
| Headless by default | headless: false is rewritten unless PWGUARD_ALLOW_HEADED=1. On a machine a human also uses, a window appearing steals the foreground. |
| Off-screen placement stripped | --window-position=-10000,-10000 makes document.visibilityState 'hidden', after which SPAs stop handling clicks and stop running timers. It is a trap; the guard removes it. |
| Guaranteed cleanup | Slots released on exit, SIGINT, SIGTERM, uncaughtException, and on browser close. |
# One-off
NODE_OPTIONS="--require /abs/path/to/pw-guard.cjs" node your-script.js
# Persistent (Windows, user-level)
setx NODE_OPTIONS "--require C:/abs/path/to/pw-guard.cjs"\U, \t etc. as escapes and fails with Cannot find module 'C:Usersdir...' — which breaks every node and npm invocation on the machine until you fix it. We shipped that bug and had to unpick it live.
Two real failure modes:
- Shells that were already open when you set the variable never see it. Long-lived terminals and background workers silently run unguarded.
- Absolute-path requires —
require('C:/work/my-project/node_modules/playwright')— which hooks matching only the bare specifier will miss. (This guard handles that case, but package-level patching removes the question entirely.)
So there is a second, environment-independent route: append a loader line to each package's own index.js.
node apply-pkg-patch.mjs --root /abs/path/to/project # apply (keeps .orig-backup)
node apply-pkg-patch.mjs --root /abs/path/to/project --check # report only
node apply-pkg-patch.mjs --root /abs/path/to/project --revert # restoreRe-run after any npm install — installing overwrites the patched file.
NODE_OPTIONS="--require /abs/path/to/pw-guard.cjs" node selftest.cjsIt reads chrome://version and prints whether each flag actually reached the browser process. Check, don't assume — assuming is exactly how the 1-in-100 situation happened.
| Env var | Default | Meaning |
|---|---|---|
PWGUARD_DISABLE |
– | 1 disables the guard entirely |
PWGUARD_MAX |
3 |
Max concurrent automation browsers |
PWGUARD_WAIT_MS |
120000 |
Queue timeout; after this it proceeds anyway rather than deadlocking |
PWGUARD_KEEP |
– | 1 = this browser is parked for a human; exempt from the cap and stamped with --pwguard-keep so an external reaper can spare it |
PWGUARD_ALLOW_HEADED |
– | 1 permits headless: false |
PWGUARD_SCOPE |
– | Substring of cwd/argv[1]; the guard only engages inside it. Use this when NODE_OPTIONS is set machine-wide so it does not load inside npm, editors, or unrelated projects. |
PWGUARD_LOG |
%TEMP%/pw_guard.log |
Log path |
We first set it to 1, on the theory that accumulated Chromium instances were exhausting GDI/USER handles. Measurement did not support that: with a parked browser open, GDI was 1,180 and USER 2,231, against a warning threshold of 8,000. Serialising every browser task just made the pipeline slow. 3 is bounded without being the bottleneck — tune it, and measure before you believe a theory about your own freezes.
Requirements: Node 14+. No dependencies. Works with Playwright and Puppeteer.
ブラウザ自動化のスクリプトが50〜100本に増えると、「起動時には必ず --disable-gpu などを付けること」というルールは 誰も守らなくなります。
実際に監査したところ、100本以上あるスクリプトのうち、フラグが付いていたのは 1本だけ でした。「必須」とされていた引数リストは PowerShell の関数の中にあり、Node のスクリプトからは原理的に呼べません。つまり対策はどこにも適用されていませんでした。加えて同時起動数の上限が無かったため、headless の Chromium が一日かけて積み上がり、最終的にデスクトップが入力を受け付けなくなって再起動が必要になりました。
「気をつけて付けてね」では直りません。実行時に強制するのが本ツールです。
--require でプリロードすると Module._load をフックし、chromium.launch / launchPersistentContext / Puppeteer 形式の launch をラップします。結果、全スクリプトの全起動に対して次が効きます。
- 安全な引数を自動注入(
--disable-gpuほか。呼び出し側が明示指定した引数は尊重) - 同時起動数の上限(tmp のロックファイル方式。超過分は待ち行列に入る。死んだプロセスのロックは自動回収)
- headless を既定に強制(
PWGUARD_ALLOW_HEADED=1のときのみ画面付きを許可) - 画面外配置
--window-positionの除去(visibilityStateがhiddenになり SPA がクリックを処理しなくなるため) - 終了時のロック解放を保証
NODE_OPTIONS="--require /abs/path/to/pw-guard.cjs" node your-script.js/ で書いてください。 バックスラッシュだと Node がエスケープとして解釈し、Cannot find module 'C:Usersdir...' となってそのマシン上の node と npm がすべて壊れます(実際に踏みました)。
① 変数を設定する前から起動していたシェルには届きません。常駐ターミナルやバックグラウンドのワーカーは、気付かないままガード無しで走ります。
② require('C:/work/my-project/node_modules/playwright') のような絶対パス require は、ベア名だけを見るフックでは取りこぼします。
そこで環境変数に依存しない経路として、各パッケージの index.js に読み込み行を追記する方法を用意しています。
node apply-pkg-patch.mjs --root /abs/path/to/project # 適用(.orig-backup を残す)
node apply-pkg-patch.mjs --root /abs/path/to/project --check # 確認のみ
node apply-pkg-patch.mjs --root /abs/path/to/project --revert # 復旧npm install すると追記は消えるので、その都度やり直してください。
NODE_OPTIONS="--require /abs/path/to/pw-guard.cjs" node selftest.cjschrome://version を読み、各フラグが実際にブラウザプロセスへ届いたかを出力します。「付いているはず」で済ませないこと。それが 100本中1本という事態の原因でした。
当初は「Chromium の蓄積がハンドルを枯渇させている」という仮説から 1 にしていました。しかし実測では、常駐ブラウザ1台がある状態で GDI 1,180 / USER 2,231(警告閾値 8,000)と、仮説を支持しませんでした。全ブラウザ処理を直列化した結果、パイプライン全体が遅くなっただけでした。3 は上限として機能しつつ律速にならない値です。自分のマシンのフリーズについては、仮説を信じる前に必ず実測してください。
要件: Node 14 以上。依存パッケージなし。Playwright / Puppeteer 両対応。
MIT License. Built by AI Jidoka Lab (Taku) — https://setlog-app.github.io/ai-jidoka-lab/