optbrew 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/AGENT_INSTALL.md +94 -0
  5. package/CHANGELOG.md +74 -1
  6. package/README.ko.md +129 -105
  7. package/README.md +129 -105
  8. package/THIRD_PARTY_NOTICES.md +88 -0
  9. package/docs/platforms.md +45 -8
  10. package/docs/release.md +37 -4
  11. package/lib/cli.mjs +5 -2
  12. package/lib/install.mjs +2 -1
  13. package/lib/records.mjs +2 -1
  14. package/lib/transaction.mjs +12 -5
  15. package/lib/update.mjs +3 -2
  16. package/package.json +29 -9
  17. package/plugin.json +2 -2
  18. package/skills/.claude-plugin/icon.png +0 -0
  19. package/skills/.claude-plugin/plugin.json +23 -0
  20. package/skills/README.md +27 -0
  21. package/skills/optbrew/SKILL.md +223 -8
  22. package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
  23. package/skills/optbrew/bundle-manifest.json +409 -49
  24. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
  25. package/skills/optbrew/references/ai-crawlers.json +1 -1
  26. package/skills/optbrew/references/ai-crawlers.md +1 -1
  27. package/skills/optbrew/references/api.md +6 -4
  28. package/skills/optbrew/references/check-registry.json +16 -16
  29. package/skills/optbrew/references/chrome-paths.json +31 -0
  30. package/skills/optbrew/references/endpoints.json +10 -0
  31. package/skills/optbrew/references/harness-signals.json +5 -0
  32. package/skills/optbrew/references/intake.md +76 -5
  33. package/skills/optbrew/references/judging.md +233 -0
  34. package/skills/optbrew/references/limits.json +26 -0
  35. package/skills/optbrew/references/measurement.md +1 -1
  36. package/skills/optbrew/references/messages.ko.json +170 -0
  37. package/skills/optbrew/references/report.md +18 -6
  38. package/skills/optbrew/references/routing.md +2 -2
  39. package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
  40. package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
  41. package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
  42. package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
  43. package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
  44. package/skills/optbrew/references/runbooks/index.json +18 -0
  45. package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
  46. package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
  47. package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
  48. package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
  49. package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
  50. package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
  51. package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
  52. package/skills/optbrew/references/runbooks/seranking.md +38 -0
  53. package/skills/optbrew/references/schemas/contract.schema.json +42 -15
  54. package/skills/optbrew/references/scoring.md +40 -5
  55. package/skills/optbrew/references/source-analysis.md +40 -0
  56. package/skills/optbrew/references/versions.json +9 -0
  57. package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
  58. package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
  59. package/skills/optbrew/scripts/audit.mjs +10 -96
  60. package/skills/optbrew/scripts/cli.mjs +5 -2
  61. package/skills/optbrew/scripts/core/bundle.mjs +30 -13
  62. package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
  63. package/skills/optbrew/scripts/core/cdp.mjs +105 -0
  64. package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
  65. package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
  66. package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
  67. package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
  68. package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
  69. package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
  70. package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
  71. package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
  72. package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
  73. package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
  74. package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
  75. package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
  76. package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
  77. package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
  78. package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
  79. package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
  80. package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
  81. package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
  82. package/skills/optbrew/scripts/core/chrome.mjs +546 -0
  83. package/skills/optbrew/scripts/core/collector.mjs +65 -59
  84. package/skills/optbrew/scripts/core/compare.mjs +18 -1
  85. package/skills/optbrew/scripts/core/config.mjs +31 -0
  86. package/skills/optbrew/scripts/core/contracts.mjs +31 -2
  87. package/skills/optbrew/scripts/core/cwv.mjs +100 -0
  88. package/skills/optbrew/scripts/core/derive.mjs +10 -161
  89. package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
  90. package/skills/optbrew/scripts/core/facts.mjs +203 -19
  91. package/skills/optbrew/scripts/core/findings.mjs +19 -1
  92. package/skills/optbrew/scripts/core/html.mjs +41 -3
  93. package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
  94. package/skills/optbrew/scripts/core/judgments.mjs +675 -0
  95. package/skills/optbrew/scripts/core/keys.mjs +86 -0
  96. package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
  97. package/skills/optbrew/scripts/core/messages.mjs +23 -0
  98. package/skills/optbrew/scripts/core/metrics.mjs +4 -2
  99. package/skills/optbrew/scripts/core/migration.mjs +37 -0
  100. package/skills/optbrew/scripts/core/page-select.mjs +136 -0
  101. package/skills/optbrew/scripts/core/probe.mjs +83 -0
  102. package/skills/optbrew/scripts/core/protocol.mjs +38 -14
  103. package/skills/optbrew/scripts/core/render.mjs +160 -0
  104. package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
  105. package/skills/optbrew/scripts/core/report-html.mjs +198 -0
  106. package/skills/optbrew/scripts/core/report-page.mjs +14 -0
  107. package/skills/optbrew/scripts/core/report.mjs +357 -131
  108. package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
  109. package/skills/optbrew/scripts/core/scope.mjs +23 -5
  110. package/skills/optbrew/scripts/core/score.mjs +12 -4
  111. package/skills/optbrew/scripts/core/secrets.mjs +2 -0
  112. package/skills/optbrew/scripts/core/stages.mjs +694 -0
  113. package/skills/optbrew/scripts/core/transport.mjs +157 -0
  114. package/skills/optbrew/scripts/core/weights.mjs +2 -1
  115. package/skills/optbrew/scripts/key.mjs +90 -0
  116. package/skills/optbrew/scripts/preflight.mjs +176 -0
  117. package/skills/optbrew/scripts/run.mjs +81 -0
  118. package/skills/optbrew/source-lock.json +1 -1
  119. package/source-lock.json +1 -1
@@ -0,0 +1,546 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { createCdpClient, pipeTransport, webSocketTransport } from './cdp.mjs';
8
+ import { PROFILE_PREFIX, chromeEnv, sweepStaleProfiles } from './chrome-temp.mjs';
9
+ import { chromePaths, limits } from './config.mjs';
10
+ import { assertInScope } from './scope.mjs';
11
+
12
+ /** A timer that never keeps the process alive; it only bounds a race. */
13
+ const sleep = (ms, value) => delay(ms, value, { ref: false });
14
+
15
+ /** An exception thrown by script evaluated in the page. */
16
+ class EvaluateError extends Error {
17
+ constructor(message) { super(message); this.name = 'EvaluateError'; }
18
+ }
19
+
20
+ const envValue = (env, name) => env[Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase())];
21
+
22
+ /** Expand `%NAME%` against `env`; a variable that is not set makes the whole template unusable. */
23
+ function expand(template, env) {
24
+ let missing = false;
25
+ const text = template.replace(/%([^%]+)%/g, (_, name) => {
26
+ const value = envValue(env, name);
27
+ if (!value) missing = true;
28
+ return value ?? '';
29
+ });
30
+ return missing ? null : text;
31
+ }
32
+
33
+ const guessKind = file => /edge/i.test(path.basename(file)) ? 'edge' : /chromium/i.test(path.basename(file)) ? 'chromium' : 'chrome';
34
+
35
+ /**
36
+ * Where a Chromium browser lives, in the order OPTBREW_CHROME, the platform's standard install paths, names on PATH
37
+ * (Windows: tried with the PATHEXT executable extensions), then Chromium under PLAYWRIGHT_BROWSERS_PATH.
38
+ * Returns `{ path, kind }`, or `null` when nothing is found. An OPTBREW_CHROME that points at a missing file is the
39
+ * owner's explicit choice, so it is not silently replaced: the result is `{ path: null, kind: null, reason: 'optbrew_chrome_missing' }`.
40
+ */
41
+ export function findChrome({ env = process.env, platform = process.platform, exists = existsSync, list = readdirSync } = {}) {
42
+ const config = chromePaths();
43
+ const p = platform === 'win32' ? path.win32 : path.posix;
44
+ const override = envValue(env, 'OPTBREW_CHROME');
45
+ if (override) return exists(override) ? { path: override, kind: guessKind(override) } : { path: null, kind: null, reason: 'optbrew_chrome_missing' };
46
+
47
+ for (const entry of config[platform] ?? []) {
48
+ const candidate = expand(entry.path, env);
49
+ if (candidate && exists(candidate)) return { path: candidate, kind: entry.kind };
50
+ }
51
+
52
+ const extensions = platform === 'win32' ?
53
+ (envValue(env, 'PATHEXT') ?? '.EXE').split(';').filter(extension => /^\.(exe|com)$/i.test(extension)).map(extension => extension.toLowerCase()) : [''];
54
+ const directories = (envValue(env, 'PATH') ?? '').split(platform === 'win32' ? ';' : ':').map(directory => directory.replace(/^"|"$/g, '')).filter(Boolean);
55
+ for (const { name, kind } of config.path_names) {
56
+ for (const directory of directories) {
57
+ for (const extension of extensions) {
58
+ const candidate = p.join(directory, name + extension);
59
+ if (exists(candidate)) return { path: candidate, kind };
60
+ }
61
+ }
62
+ }
63
+
64
+ const playwright = envValue(env, 'PLAYWRIGHT_BROWSERS_PATH');
65
+ if (playwright && playwright !== '0') {
66
+ let names = [];
67
+ try { names = list(playwright); } catch { /* an unreadable directory means no browser there */ }
68
+ const revisions = names.map(name => /^chromium-(\d+)$/.exec(name)).filter(Boolean)
69
+ .sort((a, b) => Number(b[1]) - Number(a[1]));
70
+ for (const [directory] of revisions) {
71
+ for (const relative of config.playwright_chromium[platform] ?? []) {
72
+ const candidate = p.join(playwright, directory, relative);
73
+ if (exists(candidate)) return { path: candidate, kind: 'chromium' };
74
+ }
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ const exited = child => child.exitCode !== null || child.signalCode !== null;
81
+
82
+ /** Kill a process tree by pid: Windows `taskkill /T /F`, elsewhere the process group, else the process alone. */
83
+ function killTree(pid) {
84
+ if (process.platform !== 'win32') {
85
+ try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
86
+ return Promise.resolve();
87
+ }
88
+ return new Promise(resolve => {
89
+ const killer = spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore', windowsHide: true });
90
+ killer.once('error', resolve);
91
+ killer.once('exit', resolve);
92
+ });
93
+ }
94
+
95
+ const LIST_WINDOWS = 'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($env:OPTBREW_MARKER) } | ForEach-Object { $_.ProcessId }';
96
+
97
+ /**
98
+ * Pids whose command line names `marker`. The marker travels in the environment, so the query never finds itself.
99
+ * A query that fails or runs past `timeoutMs` counts as an empty list, so a hung process listing cannot hang `close()`.
100
+ */
101
+ function pidsWithCommandLine(marker, timeoutMs) {
102
+ // `-ww` keeps `ps` from cutting long command lines, which would hide the profile path.
103
+ const [command, args] = process.platform === 'win32' ?
104
+ ['powershell', ['-NoProfile', '-NonInteractive', '-Command', LIST_WINDOWS]] : ['ps', ['-ww', '-eo', 'pid=,args=']];
105
+ return new Promise(resolve => {
106
+ execFile(command, args, { env: { ...process.env, OPTBREW_MARKER: marker }, maxBuffer: 1 << 24, windowsHide: true, timeout: timeoutMs }, (error, stdout) => {
107
+ if (error) { resolve([]); return; }
108
+ resolve(stdout.split(/\r?\n/).map(line => /^\s*(\d+)(?:\s+(.*))?$/.exec(line))
109
+ .filter(match => match && (process.platform === 'win32' || match[2]?.includes(marker)))
110
+ .map(match => Number(match[1])).filter(pid => pid !== process.pid));
111
+ });
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Helpers such as the crash handler can outlive the browser for a moment, and no longer belong to its process tree. This run's
117
+ * temporary profile path is unique and sits on their command lines, so it identifies them; nothing else is ever touched.
118
+ */
119
+ async function killLeftovers(userDataDir, graceMs) {
120
+ const deadline = Date.now() + graceMs;
121
+ let pids = await pidsWithCommandLine(userDataDir, graceMs);
122
+ while (pids.length) {
123
+ // The first list is always acted on, however long it took; only the re-checks are bounded by the grace period.
124
+ for (const pid of pids) await killTree(pid);
125
+ // Ending a process takes a moment on Windows, so ask again until none is listed.
126
+ if (Date.now() >= deadline) break;
127
+ pids = await pidsWithCommandLine(userDataDir, graceMs);
128
+ }
129
+ }
130
+
131
+ /** Answer authentication challenges from memory: only a proxy challenge gets the proxy credentials, once per request. */
132
+ function answerAuthChallenges(cdp, authorization) {
133
+ const decoded = authorization ? Buffer.from(authorization.replace(/^Basic\s+/i, ''), 'base64').toString('utf8') : null;
134
+ const separator = decoded?.indexOf(':') ?? -1;
135
+ const credentials = decoded === null ? null : { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) };
136
+ const answered = new Set();
137
+ cdp.on('Fetch.authRequired', ({ requestId, authChallenge }, sessionId) => {
138
+ const provide = credentials && authChallenge.source === 'Proxy' && !answered.has(requestId);
139
+ answered.add(requestId);
140
+ const authChallengeResponse = provide ? { response: 'ProvideCredentials', ...credentials } : { response: 'CancelAuth' };
141
+ cdp.send('Fetch.continueWithAuth', { requestId, authChallengeResponse }, sessionId).catch(() => {});
142
+ });
143
+ }
144
+
145
+ async function readActivePort(userDataDir, deadline) {
146
+ while (Date.now() < deadline) {
147
+ try {
148
+ const [port, browserPath] = (await readFile(path.join(userDataDir, 'DevToolsActivePort'), 'utf8')).split('\n');
149
+ if (port && browserPath) return `ws://127.0.0.1:${port}${browserPath}`;
150
+ } catch { /* not written yet */ }
151
+ await sleep(50);
152
+ }
153
+ throw new Error('chrome did not publish a debugging port');
154
+ }
155
+
156
+ /** One launch attempt with one transport; on any failure it kills its own process tree and removes its profile directory. */
157
+ async function attempt({ executable, proxy, hostRules, tmpRoot, transport: kind, launchTimeout }) {
158
+ const { chrome_close_grace_ms: closeGrace, cdp_command_timeout_ms: commandTimeout } = limits();
159
+ const userDataDir = await mkdtemp(path.join(tmpRoot, PROFILE_PREFIX));
160
+ const tempDir = path.join(userDataDir, 'tmp');
161
+ try { await mkdir(tempDir); } catch (error) { await rm(userDataDir, { recursive: true, force: true }); throw error; }
162
+ const args = ['--headless=new', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check',
163
+ '--disable-background-networking', '--disable-extensions',
164
+ kind === 'pipe' ? '--remote-debugging-pipe' : '--remote-debugging-port=0'];
165
+ // Chrome refuses to start as root without its sandbox disabled (containers, CI).
166
+ if (process.getuid?.() === 0) args.push('--no-sandbox');
167
+ if (proxy) args.push(`--proxy-server=${proxy.host.includes(':') ? `[${proxy.host}]` : proxy.host}:${proxy.port}`);
168
+ if (hostRules) args.push(`--host-resolver-rules=${hostRules}`);
169
+
170
+ const child = spawn(executable, args, { env: chromeEnv(process.env, tempDir), stdio: kind === 'pipe' ? ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'ignore', 'pipe'],
171
+ windowsHide: true, detached: process.platform !== 'win32' });
172
+ // The last words Chrome wrote to stderr, kept for the error of a Chrome that dies at start (a sandbox, socket or profile problem
173
+ // names itself there). Only the tail is kept and only a short, single-line piece is shown.
174
+ let stderrTail = '';
175
+ child.stderr.on('data', chunk => { stderrTail = (stderrTail + chunk).slice(-2000); });
176
+ const gone = new Promise(resolve => child.once('exit', resolve));
177
+ const failed = new Promise((_, reject) => {
178
+ child.once('error', () => reject(new Error('chrome could not be started')));
179
+ gone.then(async () => {
180
+ // 'exit' can come before stderr is drained; wait for its end, bounded, so the dying words are not lost.
181
+ await Promise.race([new Promise(resolve => child.stderr.readableEnded ? resolve() : child.stderr.once('end', resolve)), sleep(closeGrace)]);
182
+ const said = stderrTail.replace(/\s+/g, ' ').trim().slice(-300);
183
+ reject(new Error(`chrome exited before it was ready${said ? `: ${said}` : ''}`));
184
+ });
185
+ });
186
+ failed.catch(() => {});
187
+
188
+ let transport = null;
189
+ let cancelled = false;
190
+ const cleanup = async () => {
191
+ transport?.close();
192
+ if (!exited(child) && child.pid !== undefined) { await killTree(child.pid); await Promise.race([gone, sleep(closeGrace)]); }
193
+ await killLeftovers(userDataDir, closeGrace);
194
+ child.stderr.destroy();
195
+ await rm(userDataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
196
+ };
197
+
198
+ let cdp;
199
+ try {
200
+ const deadline = Date.now() + launchTimeout;
201
+ const connect = async () => {
202
+ const opened = kind === 'pipe' ? pipeTransport(child.stdio[3], child.stdio[4]) : await webSocketTransport(await readActivePort(userDataDir, deadline));
203
+ // A connection that completes after the launch was abandoned must not outlive the cleanup.
204
+ if (cancelled) { opened.close(); throw new Error('chrome launch was cancelled'); }
205
+ transport = opened;
206
+ cdp = createCdpClient(transport, { timeoutMs: commandTimeout });
207
+ await cdp.send('Browser.getVersion');
208
+ };
209
+ let timer;
210
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('chrome launch timed out')), launchTimeout); });
211
+ try { await Promise.race([connect(), failed, timeout]); } finally { clearTimeout(timer); }
212
+ } catch (error) {
213
+ cancelled = true;
214
+ await cleanup();
215
+ throw error;
216
+ }
217
+
218
+ answerAuthChallenges(cdp, proxy?.authorization);
219
+ let closing = null;
220
+ const shutdown = async () => {
221
+ if (!exited(child)) {
222
+ await Promise.race([cdp.send('Browser.close').catch(() => {}), gone, sleep(closeGrace)]);
223
+ await Promise.race([gone, sleep(closeGrace)]);
224
+ }
225
+ await cleanup();
226
+ };
227
+ return { cdp, close: () => closing ??= shutdown() };
228
+ }
229
+
230
+ /**
231
+ * Start a headless Chrome with a fresh temporary profile and a DevTools connection. The pipe variant is tried first; the
232
+ * port variant only when the pipe one cannot be started. `proxy` is `{ host, port, authorization }`, and `authorization`
233
+ * stays in memory (never on the command line, in a log or in an error). `close()` always ends this run's process tree
234
+ * and deletes the profile directory.
235
+ */
236
+ export async function launchChrome({ path: executable, proxy = null, hostRules = null, tmpRoot = os.tmpdir(), transports = ['pipe', 'port'],
237
+ launchTimeoutMs = limits().chrome_launch_timeout_ms }) {
238
+ let failure;
239
+ await sweepStaleProfiles(tmpRoot).catch(() => []);
240
+ for (const transport of transports) {
241
+ try { return await attempt({ executable, proxy, hostRules, tmpRoot, transport, launchTimeout: launchTimeoutMs }); }
242
+ catch (error) { failure = error; }
243
+ }
244
+ throw failure;
245
+ }
246
+
247
+ /** Launch on the first `get()` only; every stage shares the result. The owner calls `close()` in a `finally`. */
248
+ export function chromeHandle({ find, launch }) {
249
+ let pending = null;
250
+ let closed = false;
251
+ let closing = null;
252
+ return {
253
+ get() {
254
+ if (closed) return Promise.reject(new Error('chrome handle is closed'));
255
+ pending ??= (async () => {
256
+ const found = await find();
257
+ return found?.path ? launch(found) : null;
258
+ })();
259
+ return pending;
260
+ },
261
+ close() {
262
+ closed = true;
263
+ closing ??= (async () => {
264
+ const chrome = pending ? await pending.catch(() => null) : null;
265
+ await chrome?.close();
266
+ })();
267
+ return closing;
268
+ },
269
+ };
270
+ }
271
+
272
+ const STATIC_TYPES = new Set(['Script', 'Stylesheet', 'Font', 'Image']);
273
+ const LOCAL_SCHEMES = new Set(['data:', 'blob:', 'about:']);
274
+
275
+ /** Chrome resolves names itself (pinned by host rules or the proxy), so only the URL boundary and IP literals are vetted here. */
276
+ function inScope(url, scope) {
277
+ try { assertInScope(url, scope, undefined, { addressVetting: 'proxy' }); return true; }
278
+ catch { return false; }
279
+ }
280
+
281
+ /** What to do with one intercepted request. The only implementation of the render interception rules. */
282
+ function decide({ url, method }, resourceType, scope) {
283
+ let parsed;
284
+ try { parsed = new URL(url); } catch { return { action: 'blocked', blocked_reason: 'out_of_scope' }; }
285
+ if (LOCAL_SCHEMES.has(parsed.protocol)) return { action: 'allowed', blocked_reason: null };
286
+ if (method !== 'GET' && method !== 'HEAD') return { action: 'blocked', blocked_reason: 'non_get_request' };
287
+ if (inScope(url, scope)) return { action: 'allowed', blocked_reason: null };
288
+ // Out of scope: only static GET resources on a public host may load; the page cannot read them, and they are listed.
289
+ if (STATIC_TYPES.has(resourceType) && inScope(url, { allowed: [{ origin: parsed.origin }] })) {
290
+ return { action: 'third_party_static', blocked_reason: null };
291
+ }
292
+ return { action: 'blocked', blocked_reason: 'out_of_scope' };
293
+ }
294
+
295
+ // What `fetchStatic` reports with `error.code`; any other failure is `third_party_failed`.
296
+ const STATIC_FAILURES = { scope_rejected: 'third_party_refused', third_party_redirect: 'third_party_redirect' };
297
+
298
+ const consoleText =args => args.map(arg => arg.value ?? arg.description ?? '').join(' ');
299
+
300
+ const READ_PAGE = `JSON.stringify({ final_url: location.href, title: document.title, dom: document.documentElement.outerHTML,
301
+ links: Array.from(document.querySelectorAll('a[href]'), anchor => anchor.href) })`;
302
+
303
+ const INTERCEPT_ALL = { patterns: [{ urlPattern: '*' }], handleAuthRequests: true };
304
+ const AUTO_ATTACH = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true };
305
+ // Top-level targets that can run page code or make requests of their own. The last entry drops every other type.
306
+ const TOP_LEVEL = [{ type: 'page' }, { type: 'service_worker' }, { type: 'shared_worker' }, { exclude: true }];
307
+ // A dedicated worker has no Fetch domain of its own ("'Fetch.enable' wasn't found"): its requests are paused on the session
308
+ // of the frame that owns it, which already has the interception.
309
+ const OWNER_INTERCEPTS = new Set(['worker']);
310
+
311
+ /**
312
+ * One browser-level auto-attach per Chrome: each new top-level target (a popup, a service worker, a shared worker) is
313
+ * paused and handed to the scopedPage that owns its browser context; any other target is resumed untouched. A target that
314
+ * shows up in a context whose page has already closed was ours and is closed, never resumed unintercepted.
315
+ * Resolves to `{ claims, retired }`: the `browserContextId -> adopt` map and the set of contexts already closed.
316
+ */
317
+ const routers = new WeakMap();
318
+ function routerFor(cdp) {
319
+ if (!routers.has(cdp)) {
320
+ routers.set(cdp, (async () => {
321
+ const claims = new Map();
322
+ const retired = new Set();
323
+ cdp.on('Target.attachedToTarget', (params, parent) => {
324
+ if (parent !== undefined) return;
325
+ const context = params.targetInfo.browserContextId;
326
+ if (claims.has(context)) claims.get(context)(params);
327
+ else if (retired.has(context)) cdp.send('Target.closeTarget', { targetId: params.targetInfo.targetId }).catch(() => {});
328
+ else if (params.waitingForDebugger) cdp.send('Runtime.runIfWaitingForDebugger', {}, params.sessionId).catch(() => {});
329
+ });
330
+ await cdp.send('Target.setAutoAttach', { ...AUTO_ATTACH, filter: TOP_LEVEL });
331
+ return { claims, retired };
332
+ })());
333
+ }
334
+ return routers.get(cdp);
335
+ }
336
+
337
+ /**
338
+ * One isolated page whose every request passes the scope rules: in-scope requests continue; an out-of-scope document or
339
+ * any request that is not GET/HEAD fails with BlockedByClient; an out-of-scope GET script, stylesheet, font or image
340
+ * loads and is recorded as `third_party_static`. Redirect hops arrive as new requests and are checked again. Past
341
+ * `budget` intercepted requests every request fails with `render_budget`.
342
+ * With `fetchStatic(url)` (direct mode) Chrome never touches a third-party static host: the bytes come from that function,
343
+ * which vets the address, and are handed to Chrome with Fetch.fulfillRequest; a refusal or failure fails the request and is
344
+ * recorded (`third_party_refused`, `third_party_redirect`, `third_party_failed`); at most `staticConcurrency` of those fetches
345
+ * run at once, the rest wait their turn. Without it (proxy mode) the proxy carries the request.
346
+ * Every record gets the `status` of its response or the `error` text of its failure from the Network events.
347
+ * The rules cover every target the page owns, not only its main frame: iframes and dedicated workers (auto-attached from the
348
+ * page session), popups, service workers and shared workers (auto-attached from the browser). Each is paused, gets the same
349
+ * Fetch interception, and only then runs; the records say which kind of target made the request (`target_type`).
350
+ */
351
+ export async function scopedPage({ chrome, context, budget, fetchStatic = null, staticConcurrency = limits().render_static_concurrency }) {
352
+ const { cdp } = chrome;
353
+ const { claims, retired } = await routerFor(cdp);
354
+ const { browserContextId } = await cdp.send('Target.createBrowserContext', { disposeOnDetach: true });
355
+ const offs = [];
356
+ const requests = [];
357
+ const consoleErrors = [];
358
+ const sessions = new Map();
359
+ // Records per Network request id in arrival order, so a response, a redirect or a failure lands on the right hop.
360
+ const byNetwork = new Map();
361
+ const targets = new Set();
362
+ const idleLoaders = new Set();
363
+ let targetId = null;
364
+ let sessionId = null;
365
+ let mainFrameId = null;
366
+ let seen = 0;
367
+ let mainBlocked = null;
368
+ let budgetBlocked = false;
369
+ let lastDocument = null;
370
+ let mainLoader = null;
371
+ let notifyMainBlocked = () => {};
372
+ let announceMainTarget;
373
+ const mainTarget = new Promise(resolve => { announceMainTarget = resolve; });
374
+
375
+ const send = (method, params) => cdp.send(method, params, sessionId);
376
+ const on = (event, fn) => {
377
+ const off = cdp.on(event, (params, session) => { if (session === sessionId) fn(params); });
378
+ offs.push(off);
379
+ return off;
380
+ };
381
+ const once = (event, test) => {
382
+ let off;
383
+ const promise = new Promise(resolve => { off = on(event, params => { if (test(params)) { off(); resolve(params); } }); });
384
+ return { promise, off };
385
+ };
386
+
387
+ // A slot is handed straight to the next waiter, so a fetch that starts meanwhile cannot take it and exceed the cap.
388
+ let staticRunning = 0;
389
+ const staticQueue = [];
390
+ const acquireStatic = () => staticRunning < staticConcurrency ? (staticRunning += 1, Promise.resolve()) : new Promise(resolve => staticQueue.push(resolve));
391
+ const releaseStatic = () => { const next = staticQueue.shift(); if (next) next(); else staticRunning -= 1; };
392
+ const limitedFetch = async url => {
393
+ await acquireStatic();
394
+ try { return await fetchStatic(url); } finally { releaseStatic(); }
395
+ };
396
+
397
+ const unresolved = networkId => byNetwork.get(networkId)?.find(record => record.status === null && record.error === null);
398
+
399
+ async function onPaused(session, { requestId, networkId, request, resourceType, redirectedRequestId, frameId }) {
400
+ seen += 1;
401
+ let verdict = seen > budget ? { action: 'blocked', blocked_reason: 'render_budget' } : decide(request, resourceType, context.scope);
402
+ if (verdict.blocked_reason === 'render_budget') budgetBlocked = true;
403
+ const record = { url: request.url, method: request.method, resource_type: resourceType, target_type: sessions.get(session),
404
+ redirected: Boolean(redirectedRequestId), ...verdict, status: null, error: null };
405
+ // Past the budget only the first overflowing request is listed, so a request flood cannot grow the list.
406
+ if (seen <= budget + 1) requests.push(record);
407
+ if (verdict.action !== 'blocked' && networkId) byNetwork.set(networkId, [...(byNetwork.get(networkId) ?? []), record]);
408
+ let served = null;
409
+ if (verdict.action === 'third_party_static' && fetchStatic) {
410
+ try { served = await limitedFetch(request.url); }
411
+ catch (error) {
412
+ verdict = { action: 'blocked', blocked_reason: STATIC_FAILURES[error.code] ?? 'third_party_failed' };
413
+ Object.assign(record, verdict);
414
+ }
415
+ }
416
+ if (served) {
417
+ // The caller chose which headers go to Chrome with the bytes; the status comes back from Chrome's own network events.
418
+ const responseHeaders = Object.entries(served.headers).map(([name, value]) => ({ name, value }));
419
+ await cdp.send('Fetch.fulfillRequest', { requestId, responseCode: served.status, responseHeaders, body: served.body.toString('base64') }, session);
420
+ } else if (verdict.action === 'blocked') {
421
+ if (session === sessionId && resourceType === 'Document' && frameId === mainFrameId) {
422
+ mainBlocked ??= verdict.blocked_reason;
423
+ notifyMainBlocked();
424
+ }
425
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, session);
426
+ } else await cdp.send('Fetch.continueRequest', { requestId }, session);
427
+ }
428
+
429
+ /** Put a newly attached target under the same interception before it runs. The main page is driven over its own session. */
430
+ async function adopt({ sessionId: child, targetInfo, waitingForDebugger }) {
431
+ const resume = () => waitingForDebugger ? cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => {}) : undefined;
432
+ // Our own page can be announced before createTarget has answered, so its identity is only known after that.
433
+ const ownMainPage = targetInfo.type === 'page' && targetInfo.targetId === await mainTarget;
434
+ if (ownMainPage || targets.has(targetInfo.targetId)) { await resume(); return; }
435
+ targets.add(targetInfo.targetId);
436
+ // Every adopted session is listed, so a target nested under a dedicated worker is adopted too instead of staying paused.
437
+ sessions.set(child, targetInfo.type);
438
+ // Outcomes of requests this target makes arrive on its own session; a target without a Network domain only loses them.
439
+ cdp.send('Network.enable', {}, child).catch(() => {});
440
+ if (!OWNER_INTERCEPTS.has(targetInfo.type)) {
441
+ try { await cdp.send('Fetch.enable', INTERCEPT_ALL, child); }
442
+ catch {
443
+ // A target that cannot be intercepted must not run: close it instead of resuming it.
444
+ await cdp.send('Target.closeTarget', { targetId: targetInfo.targetId }).catch(() => {});
445
+ return;
446
+ }
447
+ }
448
+ // Targets nested under this one (iframes, workers) attach to this session in turn; not every target type supports it.
449
+ await cdp.send('Target.setAutoAttach', AUTO_ATTACH, child).catch(() => {});
450
+ await resume();
451
+ }
452
+
453
+ async function close() {
454
+ // Interception and the claim stay in place until the context is gone, so a target starting now is still intercepted.
455
+ if (targetId) await cdp.send('Target.closeTarget', { targetId }).catch(() => {});
456
+ await cdp.send('Target.disposeBrowserContext', { browserContextId }).catch(() => {});
457
+ for (const off of offs.splice(0)) off();
458
+ claims.delete(browserContextId);
459
+ retired.add(browserContextId);
460
+ }
461
+
462
+ try {
463
+ claims.set(browserContextId, params => { adopt(params).catch(() => {}); });
464
+ ({ targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', browserContextId }));
465
+ announceMainTarget(targetId);
466
+ ({ sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }));
467
+ sessions.set(sessionId, 'page');
468
+ targets.add(targetId);
469
+ offs.push(cdp.on('Fetch.requestPaused', (params, session) => { if (sessions.has(session)) onPaused(session, params).catch(() => {}); }));
470
+ offs.push(cdp.on('Target.attachedToTarget', (params, parent) => { if (parent !== undefined && sessions.has(parent)) adopt(params).catch(() => {}); }));
471
+ offs.push(cdp.on('Network.requestWillBeSent', ({ requestId, redirectResponse }, session) => {
472
+ const record = redirectResponse && sessions.has(session) ? unresolved(requestId) : null;
473
+ if (record) record.status = redirectResponse.status;
474
+ }));
475
+ offs.push(cdp.on('Network.responseReceived', ({ requestId, response }, session) => {
476
+ const record = sessions.has(session) ? unresolved(requestId) : null;
477
+ if (record) record.status = response.status;
478
+ }));
479
+ offs.push(cdp.on('Network.loadingFailed', ({ requestId, errorText }, session) => {
480
+ const record = sessions.has(session) ? unresolved(requestId) : null;
481
+ if (record) record.error = errorText;
482
+ }));
483
+ on('Network.responseReceived', ({ type, frameId, response }) => {
484
+ if (type === 'Document' && frameId === mainFrameId) lastDocument = { status: response.status };
485
+ });
486
+ on('Page.lifecycleEvent', ({ name, loaderId }) => { if (name === 'networkIdle') idleLoaders.add(loaderId); });
487
+ on('Page.frameNavigated', ({ frame }) => { if (frame.id === mainFrameId) mainLoader = frame.loaderId; });
488
+ on('Runtime.consoleAPICalled', ({ type, args }) => { if (type === 'error') consoleErrors.push(consoleText(args)); });
489
+ on('Runtime.exceptionThrown', ({ exceptionDetails }) => { consoleErrors.push(exceptionDetails.exception?.description ?? exceptionDetails.text); });
490
+ await send('Page.enable');
491
+ await send('Network.enable');
492
+ await send('Runtime.enable');
493
+ await send('Page.setLifecycleEventsEnabled', { enabled: true });
494
+ mainFrameId = (await send('Page.getFrameTree')).frameTree.frame.id;
495
+ await send('Fetch.enable', INTERCEPT_ALL);
496
+ await send('Target.setAutoAttach', AUTO_ATTACH);
497
+ } catch (error) {
498
+ announceMainTarget(null);
499
+ await close();
500
+ throw error;
501
+ }
502
+
503
+ return {
504
+ requests,
505
+ close,
506
+ async goto(url) {
507
+ mainBlocked = null;
508
+ lastDocument = null;
509
+ mainLoader = null;
510
+ const result = { final_url: null, status: null, title: null, dom: null, links: [], console_errors: consoleErrors, blocked_reason: null, error: null };
511
+ const deadline = sleep(limits().timeout_ms, 'timeout');
512
+ const blocked = new Promise(resolve => { notifyMainBlocked = () => resolve('blocked'); });
513
+ const navigated = await Promise.race([send('Page.navigate', { url }), deadline]);
514
+ if (navigated === 'timeout') result.error = 'timeout';
515
+ else if (navigated.errorText) result.error = navigated.errorText;
516
+ else {
517
+ mainLoader ??= navigated.loaderId;
518
+ // Follow the page if it navigates itself (script, meta refresh) before it goes idle: only the idle event of the
519
+ // latest main-frame loader counts, and the blank page we started from reports its own idle half a second after it opened.
520
+ for (let settled = false; !settled;) {
521
+ const loader = mainLoader;
522
+ if (idleLoaders.has(loader) || mainBlocked) break;
523
+ const idle = once('Page.lifecycleEvent', ({ name, loaderId }) => name === 'networkIdle' && loaderId === loader);
524
+ const moved = once('Page.frameNavigated', ({ frame }) => frame.id === mainFrameId && frame.loaderId !== loader);
525
+ try {
526
+ const winner = await Promise.race([idle.promise.then(() => 'idle'), moved.promise.then(() => 'moved'), blocked, deadline]);
527
+ if (winner === 'timeout') result.error = 'timeout';
528
+ settled = winner !== 'moved' && !(winner === 'idle' && mainLoader !== loader);
529
+ } finally { idle.off(); moved.off(); }
530
+ }
531
+ }
532
+ notifyMainBlocked = () => {};
533
+ result.blocked_reason = mainBlocked ?? (budgetBlocked ? 'render_budget' : null);
534
+ if (mainBlocked || (result.error && result.error !== 'timeout')) return result;
535
+ const { result: evaluated } = await send('Runtime.evaluate', { expression: READ_PAGE, returnByValue: true });
536
+ Object.assign(result, JSON.parse(evaluated.value), { status: lastDocument?.status ?? null });
537
+ return result;
538
+ },
539
+ /** The value of an expression run in the loaded page; a promise is awaited. An exception in the page throws an EvaluateError. */
540
+ async evaluate(expression) {
541
+ const { result, exceptionDetails } = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
542
+ if (exceptionDetails) throw new EvaluateError(exceptionDetails.exception?.description ?? exceptionDetails.text);
543
+ return result.value;
544
+ },
545
+ };
546
+ }