rpc-agent 0.3.1 → 0.5.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.
- package/CHANGELOG.md +94 -0
- package/README.md +428 -5
- package/dist/args.js +214 -25
- package/dist/auth.js +416 -0
- package/dist/browser.js +6 -4
- package/dist/capture.js +47 -11
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +336 -0
- package/dist/check.js +677 -0
- package/dist/cli.js +115 -4
- package/dist/help.js +78 -4
- package/dist/login.js +150 -0
- package/dist/mcp.js +152 -7
- package/dist/options.js +14 -0
- package/dist/png.js +47 -0
- package/dist/term.js +20 -0
- package/dist/ui.js +220 -3
- package/package.json +3 -2
package/dist/auth.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Authenticated captures: cookies, headers, storage state and saved sessions.
|
|
2
|
+
//
|
|
3
|
+
// Everything rpc injects is scoped to the target origin (plus any origins the
|
|
4
|
+
// caller allows explicitly). Secret values never appear in output: summaries
|
|
5
|
+
// carry names only, and error text is scrubbed of every value we injected.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { chmod, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
export const MASK = '•••';
|
|
11
|
+
// ------------------------------------------------------------------ parsing
|
|
12
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
13
|
+
/** `name=value; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax` */
|
|
14
|
+
export function parseCookie(raw) {
|
|
15
|
+
const [pair = '', ...attrs] = raw.split(';');
|
|
16
|
+
const eq = pair.indexOf('=');
|
|
17
|
+
const name = (eq === -1 ? pair : pair.slice(0, eq)).trim();
|
|
18
|
+
if (eq === -1 || !name || !TOKEN.test(name)) {
|
|
19
|
+
throw new Error(`--cookie expects "name=value[; Domain=…; Path=…]", got "${name || '(empty)'}${eq === -1 ? '' : `=${MASK}`}"`);
|
|
20
|
+
}
|
|
21
|
+
const cookie = { name, value: pair.slice(eq + 1).trim() };
|
|
22
|
+
for (const attr of attrs) {
|
|
23
|
+
const i = attr.indexOf('=');
|
|
24
|
+
const key = (i === -1 ? attr : attr.slice(0, i)).trim().toLowerCase();
|
|
25
|
+
const val = i === -1 ? '' : attr.slice(i + 1).trim();
|
|
26
|
+
if (!key)
|
|
27
|
+
continue;
|
|
28
|
+
if (key === 'domain')
|
|
29
|
+
cookie.domain = val;
|
|
30
|
+
else if (key === 'path')
|
|
31
|
+
cookie.path = val || '/';
|
|
32
|
+
else if (key === 'secure')
|
|
33
|
+
cookie.secure = true;
|
|
34
|
+
else if (key === 'httponly')
|
|
35
|
+
cookie.httpOnly = true;
|
|
36
|
+
else if (key === 'samesite') {
|
|
37
|
+
const s = val.toLowerCase();
|
|
38
|
+
if (s !== 'lax' && s !== 'strict' && s !== 'none')
|
|
39
|
+
throw new Error(`Cookie "${name}": SameSite must be Lax, Strict or None`);
|
|
40
|
+
cookie.sameSite = (s[0].toUpperCase() + s.slice(1));
|
|
41
|
+
}
|
|
42
|
+
else if (key === 'expires' || key === 'max-age')
|
|
43
|
+
continue;
|
|
44
|
+
else
|
|
45
|
+
throw new Error(`Cookie "${name}": unknown attribute "${key}" (supported: Domain, Path, Secure, HttpOnly, SameSite)`);
|
|
46
|
+
}
|
|
47
|
+
return cookie;
|
|
48
|
+
}
|
|
49
|
+
/** `Name: value` */
|
|
50
|
+
export function parseHeader(raw) {
|
|
51
|
+
const i = raw.indexOf(':');
|
|
52
|
+
const name = (i === -1 ? raw : raw.slice(0, i)).trim();
|
|
53
|
+
if (i === -1 || !name || !TOKEN.test(name)) {
|
|
54
|
+
throw new Error(`--header expects "Name: value", got "${name || '(empty)'}${i === -1 ? '' : `: ${MASK}`}"`);
|
|
55
|
+
}
|
|
56
|
+
return [name, raw.slice(i + 1).trim()];
|
|
57
|
+
}
|
|
58
|
+
function originOf(url) {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(url);
|
|
61
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : undefined;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Accept `localhost:3000`, `https://x.com/path` or a bare host; return its origin. */
|
|
68
|
+
export function toOrigin(input) {
|
|
69
|
+
const raw = input.trim();
|
|
70
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
|
|
71
|
+
? raw
|
|
72
|
+
: /^(localhost|127\.|0\.0\.0\.0|\[::1\])/i.test(raw)
|
|
73
|
+
? `http://${raw}`
|
|
74
|
+
: `https://${raw}`;
|
|
75
|
+
const o = originOf(withScheme);
|
|
76
|
+
if (!o)
|
|
77
|
+
throw new Error(`"${input}" is not an http(s) origin`);
|
|
78
|
+
return o;
|
|
79
|
+
}
|
|
80
|
+
/** Cookie domain-match (RFC 6265 5.1.3), ignoring a leading dot. */
|
|
81
|
+
export function domainMatches(host, domain) {
|
|
82
|
+
const d = domain.replace(/^\./, '').toLowerCase();
|
|
83
|
+
const h = host.toLowerCase();
|
|
84
|
+
return h === d || h.endsWith(`.${d}`);
|
|
85
|
+
}
|
|
86
|
+
function hostOf(origin) {
|
|
87
|
+
return new URL(origin).hostname;
|
|
88
|
+
}
|
|
89
|
+
// ------------------------------------------------------------------ sessions
|
|
90
|
+
/** `$RPC_SESSIONS_DIR`, else `$XDG_CONFIG_HOME/rpc-agent/sessions`, else `~/.config/rpc-agent/sessions`. */
|
|
91
|
+
export function sessionsDir(env = process.env) {
|
|
92
|
+
if (env.RPC_SESSIONS_DIR)
|
|
93
|
+
return resolve(env.RPC_SESSIONS_DIR);
|
|
94
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'rpc-agent', 'sessions');
|
|
95
|
+
}
|
|
96
|
+
/** `http://localhost:3400` -> `http-localhost-3400` */
|
|
97
|
+
export function originSlug(origin) {
|
|
98
|
+
return origin
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
101
|
+
.replace(/^-+|-+$/g, '');
|
|
102
|
+
}
|
|
103
|
+
export function sessionPath(origin, dir = sessionsDir()) {
|
|
104
|
+
return join(dir, `${originSlug(origin)}.json`);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Write a storage state readable only by this user: the file is 0600 and,
|
|
108
|
+
* for the default sessions dir, the directory is 0700.
|
|
109
|
+
*/
|
|
110
|
+
export async function writeSession(state, meta, file) {
|
|
111
|
+
const target = file ? resolve(file) : sessionPath(meta.origin);
|
|
112
|
+
const dir = dirname(target);
|
|
113
|
+
if (!file) {
|
|
114
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
115
|
+
await chmod(dir, 0o700);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
await mkdir(dir, { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
const body = { cookies: state.cookies, origins: state.origins, rpc: meta };
|
|
121
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
122
|
+
await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', { mode: 0o600 });
|
|
123
|
+
await chmod(tmp, 0o600);
|
|
124
|
+
await rename(tmp, target);
|
|
125
|
+
return target;
|
|
126
|
+
}
|
|
127
|
+
function readState(path) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw new Error(`Could not read storage state ${path}: ${err.message.split('\n')[0]}`);
|
|
134
|
+
}
|
|
135
|
+
const s = parsed;
|
|
136
|
+
if (!s || typeof s !== 'object' || !Array.isArray(s.cookies ?? []) || !Array.isArray(s.origins ?? [])) {
|
|
137
|
+
throw new Error(`${path} is not a Playwright storageState file (expected { cookies: [], origins: [] })`);
|
|
138
|
+
}
|
|
139
|
+
return { cookies: s.cookies ?? [], origins: s.origins ?? [], ...(s.rpc ? { rpc: s.rpc } : {}) };
|
|
140
|
+
}
|
|
141
|
+
/** Cookies and localStorage that belong to the allowed origins only. */
|
|
142
|
+
export function scopeState(state, allowed) {
|
|
143
|
+
const hosts = allowed.map(hostOf);
|
|
144
|
+
return {
|
|
145
|
+
cookies: state.cookies.filter(c => hosts.some(h => domainMatches(h, c.domain))),
|
|
146
|
+
origins: state.origins.filter(o => allowed.includes(o.origin)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Latest expiry of the cookies, and whether all of them have passed it. */
|
|
150
|
+
function expiry(cookies, now = Date.now() / 1000) {
|
|
151
|
+
if (cookies.length === 0 || cookies.some(c => !(c.expires > 0)))
|
|
152
|
+
return { expired: false };
|
|
153
|
+
const latest = Math.max(...cookies.map(c => c.expires));
|
|
154
|
+
return { expired: latest < now, expires: new Date(latest * 1000).toISOString() };
|
|
155
|
+
}
|
|
156
|
+
function describeSession(path, file, origin) {
|
|
157
|
+
const own = scopeState(file, [origin]);
|
|
158
|
+
const e = expiry(own.cookies);
|
|
159
|
+
return {
|
|
160
|
+
origin,
|
|
161
|
+
path,
|
|
162
|
+
...(file.rpc?.savedAt ? { savedAt: file.rpc.savedAt } : {}),
|
|
163
|
+
cookies: own.cookies.length,
|
|
164
|
+
localStorage: own.origins.length,
|
|
165
|
+
...(e.expired ? { expired: true } : {}),
|
|
166
|
+
...(e.expires ? { expires: e.expires } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export async function listSessions(dir = sessionsDir()) {
|
|
170
|
+
let names;
|
|
171
|
+
try {
|
|
172
|
+
names = (await readdir(dir)).filter(n => n.endsWith('.json')).sort();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const n of names) {
|
|
179
|
+
const path = join(dir, n);
|
|
180
|
+
try {
|
|
181
|
+
const file = readState(path);
|
|
182
|
+
const origin = file.rpc?.origin ?? originFromSlug(n);
|
|
183
|
+
if (origin)
|
|
184
|
+
out.push(describeSession(path, file, origin));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Not ours or unreadable; skip.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
function originFromSlug(name) {
|
|
193
|
+
const m = /^(https?)-(.+?)(?:-(\d+))?\.json$/.exec(name);
|
|
194
|
+
return m ? `${m[1]}://${m[2].replace(/-/g, '.')}${m[3] ? `:${m[3]}` : ''}` : undefined;
|
|
195
|
+
}
|
|
196
|
+
/** Delete the saved session for one origin, or all of them. Returns the origins removed. */
|
|
197
|
+
export async function clearSessions(origin, dir = sessionsDir()) {
|
|
198
|
+
const all = await listSessions(dir);
|
|
199
|
+
const target = origin ? toOrigin(origin) : undefined;
|
|
200
|
+
const gone = all.filter(s => !target || s.origin === target);
|
|
201
|
+
for (const s of gone)
|
|
202
|
+
await rm(s.path, { force: true });
|
|
203
|
+
return gone.map(s => s.origin);
|
|
204
|
+
}
|
|
205
|
+
/** File mode bits, for tests and `rpc sessions list`. */
|
|
206
|
+
export async function fileMode(path) {
|
|
207
|
+
return (await stat(path)).mode & 0o777;
|
|
208
|
+
}
|
|
209
|
+
// ------------------------------------------------------------------ resolve
|
|
210
|
+
/**
|
|
211
|
+
* Validate cookies and headers against the target URL and load the storage
|
|
212
|
+
* state or saved session. Throws on bad input (names only in messages).
|
|
213
|
+
*/
|
|
214
|
+
export function resolveAuth(url, opts, dir = sessionsDir()) {
|
|
215
|
+
const origin = originOf(url);
|
|
216
|
+
const secrets = [];
|
|
217
|
+
const summary = {};
|
|
218
|
+
const allowed = [];
|
|
219
|
+
if (origin)
|
|
220
|
+
allowed.push(origin);
|
|
221
|
+
for (const o of opts.allowOrigins ?? []) {
|
|
222
|
+
const n = toOrigin(o);
|
|
223
|
+
if (!allowed.includes(n))
|
|
224
|
+
allowed.push(n);
|
|
225
|
+
}
|
|
226
|
+
if (opts.allowOrigins?.length)
|
|
227
|
+
summary.allowOrigins = allowed.slice(1);
|
|
228
|
+
const wantsInjection = Boolean(opts.cookies?.length || (opts.headers && Object.keys(opts.headers).length));
|
|
229
|
+
if (!origin && (wantsInjection || opts.storageState)) {
|
|
230
|
+
throw new Error('Cookies, headers and storage state need an http(s) URL');
|
|
231
|
+
}
|
|
232
|
+
// Cookies: host-only on the target unless a Domain that matches it (or an allowed origin) is given.
|
|
233
|
+
const cookies = [];
|
|
234
|
+
for (const c of opts.cookies ?? []) {
|
|
235
|
+
const host = hostOf(origin);
|
|
236
|
+
let domain = host;
|
|
237
|
+
if (c.domain) {
|
|
238
|
+
const ok = allowed.some(o => domainMatches(hostOf(o), c.domain));
|
|
239
|
+
if (!ok) {
|
|
240
|
+
throw new Error(`Cookie "${c.name}" has Domain=${c.domain}, which doesn't match ${host}. Cookies only go to the target origin; add --allow-origin to widen it.`);
|
|
241
|
+
}
|
|
242
|
+
// A Domain attribute means "this host and its subdomains", as in a browser.
|
|
243
|
+
domain = `.${c.domain.replace(/^\./, '')}`;
|
|
244
|
+
}
|
|
245
|
+
cookies.push({
|
|
246
|
+
name: c.name,
|
|
247
|
+
value: c.value,
|
|
248
|
+
domain,
|
|
249
|
+
path: c.path ?? '/',
|
|
250
|
+
expires: -1,
|
|
251
|
+
httpOnly: c.httpOnly ?? false,
|
|
252
|
+
secure: c.secure ?? false,
|
|
253
|
+
sameSite: c.sameSite ?? 'Lax',
|
|
254
|
+
});
|
|
255
|
+
secrets.push(c.value);
|
|
256
|
+
}
|
|
257
|
+
if (cookies.length) {
|
|
258
|
+
summary.cookies = cookies.map(c => ({ name: c.name, value: MASK, domain: c.domain, path: c.path }));
|
|
259
|
+
}
|
|
260
|
+
const headers = {};
|
|
261
|
+
for (const [k, v] of Object.entries(opts.headers ?? {})) {
|
|
262
|
+
if (!TOKEN.test(k))
|
|
263
|
+
throw new Error(`Invalid header name "${k}"`);
|
|
264
|
+
headers[k] = String(v);
|
|
265
|
+
secrets.push(String(v));
|
|
266
|
+
}
|
|
267
|
+
if (Object.keys(headers).length) {
|
|
268
|
+
summary.headers = Object.fromEntries(Object.keys(headers).map(k => [k, MASK]));
|
|
269
|
+
}
|
|
270
|
+
let state;
|
|
271
|
+
if (opts.storageState) {
|
|
272
|
+
const path = resolve(opts.storageState);
|
|
273
|
+
if (!existsSync(path))
|
|
274
|
+
throw new Error(`Storage state file not found: ${path}`);
|
|
275
|
+
state = scopeState(readState(path), allowed);
|
|
276
|
+
summary.storageState = path;
|
|
277
|
+
}
|
|
278
|
+
else if (origin) {
|
|
279
|
+
const path = sessionPath(origin, dir);
|
|
280
|
+
if (existsSync(path)) {
|
|
281
|
+
const file = readState(path);
|
|
282
|
+
const info = describeSession(path, file, origin);
|
|
283
|
+
const load = opts.useSession !== false && !info.expired;
|
|
284
|
+
const { localStorage: _ls, ...rest } = info;
|
|
285
|
+
summary.session = { ...rest, loaded: load };
|
|
286
|
+
if (load)
|
|
287
|
+
state = scopeState(file, allowed);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const c of state?.cookies ?? [])
|
|
291
|
+
secrets.push(c.value);
|
|
292
|
+
for (const o of state?.origins ?? [])
|
|
293
|
+
for (const kv of o.localStorage)
|
|
294
|
+
secrets.push(kv.value);
|
|
295
|
+
const used = Object.keys(summary).length > 0;
|
|
296
|
+
return {
|
|
297
|
+
...(origin ? { origin } : {}),
|
|
298
|
+
allowed,
|
|
299
|
+
...(state ? { state } : {}),
|
|
300
|
+
cookies,
|
|
301
|
+
headers,
|
|
302
|
+
...(used ? { summary } : {}),
|
|
303
|
+
secrets: [...new Set(secrets.filter(s => s.length >= 4))].sort((a, b) => b.length - a.length),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/** `2026-10-29 22:55` in the local time zone. */
|
|
307
|
+
export function localTime(iso) {
|
|
308
|
+
if (!iso)
|
|
309
|
+
return '';
|
|
310
|
+
const d = new Date(iso);
|
|
311
|
+
if (Number.isNaN(d.getTime()))
|
|
312
|
+
return '';
|
|
313
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
314
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
315
|
+
}
|
|
316
|
+
/** Replace every secret value in text with •••. */
|
|
317
|
+
export function redact(text, secrets) {
|
|
318
|
+
let out = text;
|
|
319
|
+
for (const s of secrets)
|
|
320
|
+
if (out.includes(s))
|
|
321
|
+
out = out.split(s).join(MASK);
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
/** One line for plain output: `session http://x (saved …), cookie sid=•••, header Authorization: •••`. */
|
|
325
|
+
export function describeAuth(s) {
|
|
326
|
+
const parts = [];
|
|
327
|
+
if (s.session) {
|
|
328
|
+
const when = s.session.savedAt ? `saved ${localTime(s.session.savedAt)}` : '';
|
|
329
|
+
if (s.session.loaded)
|
|
330
|
+
parts.push(`session ${s.session.origin}${when ? ` (${when})` : ''}`);
|
|
331
|
+
else if (s.session.expired)
|
|
332
|
+
parts.push(`session ${s.session.origin} expired, not loaded`);
|
|
333
|
+
else
|
|
334
|
+
parts.push(`session ${s.session.origin} skipped (--no-session)`);
|
|
335
|
+
}
|
|
336
|
+
if (s.storageState)
|
|
337
|
+
parts.push(`storage state ${s.storageState}`);
|
|
338
|
+
for (const c of s.cookies ?? [])
|
|
339
|
+
parts.push(`cookie ${c.name}=${MASK}`);
|
|
340
|
+
for (const h of Object.keys(s.headers ?? {}))
|
|
341
|
+
parts.push(`header ${h}: ${MASK}`);
|
|
342
|
+
if (s.allowOrigins?.length)
|
|
343
|
+
parts.push(`also ${s.allowOrigins.join(', ')}`);
|
|
344
|
+
return parts.join(', ');
|
|
345
|
+
}
|
|
346
|
+
// ------------------------------------------------------------------ apply
|
|
347
|
+
/**
|
|
348
|
+
* Send the extra headers on requests to allowed origins only. Uses the CDP
|
|
349
|
+
* Fetch domain rather than page.route: route() header overrides follow
|
|
350
|
+
* redirects, so a 302 to another host would carry the header there. Fetch
|
|
351
|
+
* pauses each redirect hop separately, so every hop is checked.
|
|
352
|
+
*/
|
|
353
|
+
export async function applyHeaders(context, page, auth) {
|
|
354
|
+
const extra = Object.entries(auth.headers);
|
|
355
|
+
if (extra.length === 0)
|
|
356
|
+
return;
|
|
357
|
+
const lower = new Set(extra.map(([k]) => k.toLowerCase()));
|
|
358
|
+
const cdp = await context.newCDPSession(page);
|
|
359
|
+
cdp.on('Fetch.requestPaused', e => {
|
|
360
|
+
const allowed = auth.allowed.includes(originOf(e.request.url) ?? '');
|
|
361
|
+
const params = { requestId: e.requestId };
|
|
362
|
+
if (allowed) {
|
|
363
|
+
params.headers = [
|
|
364
|
+
...Object.entries(e.request.headers)
|
|
365
|
+
.filter(([k]) => !lower.has(k.toLowerCase()))
|
|
366
|
+
.map(([name, value]) => ({ name, value: String(value) })),
|
|
367
|
+
...extra.map(([name, value]) => ({ name, value })),
|
|
368
|
+
];
|
|
369
|
+
}
|
|
370
|
+
cdp.send('Fetch.continueRequest', params).catch(() => { });
|
|
371
|
+
});
|
|
372
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
373
|
+
}
|
|
374
|
+
// ------------------------------------------------------------------ login pages
|
|
375
|
+
const LOGIN_SEGMENT = /^(log-?in|sign-?in|sign_in|signin|auth|authenticate|oauth2?|sso)(\.\w+)?$/i;
|
|
376
|
+
/** True when a path segment looks like a login page (`/login`, `/sign-in`, `/auth/...`). */
|
|
377
|
+
export function isLoginUrl(url) {
|
|
378
|
+
try {
|
|
379
|
+
return new URL(url).pathname.split('/').some(seg => LOGIN_SEGMENT.test(seg));
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** The page ended on a login URL that isn't the one requested. */
|
|
386
|
+
export function redirectedToLogin(requested, final) {
|
|
387
|
+
try {
|
|
388
|
+
const a = new URL(requested);
|
|
389
|
+
const b = new URL(final);
|
|
390
|
+
if (b.protocol !== 'http:' && b.protocol !== 'https:')
|
|
391
|
+
return false;
|
|
392
|
+
return isLoginUrl(final) && a.origin + a.pathname !== b.origin + b.pathname;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
export function loginHint(origin) {
|
|
399
|
+
return `Session missing or expired: run \`rpc login ${origin}\``;
|
|
400
|
+
}
|
|
401
|
+
export class AuthRequiredError extends Error {
|
|
402
|
+
requested;
|
|
403
|
+
finalUrl;
|
|
404
|
+
status;
|
|
405
|
+
name = 'AuthRequiredError';
|
|
406
|
+
code = 'AUTH_REQUIRED';
|
|
407
|
+
hint;
|
|
408
|
+
constructor(requested, finalUrl, status) {
|
|
409
|
+
const final = new URL(finalUrl);
|
|
410
|
+
super(`Redirected to a login page (${final.origin}${final.pathname}) instead of ${requested}`);
|
|
411
|
+
this.requested = requested;
|
|
412
|
+
this.finalUrl = finalUrl;
|
|
413
|
+
this.status = status;
|
|
414
|
+
this.hint = loginHint(new URL(requested).origin);
|
|
415
|
+
}
|
|
416
|
+
}
|
package/dist/browser.js
CHANGED
|
@@ -63,17 +63,19 @@ export async function doctor() {
|
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
65
|
* Run `playwright install --only-shell chromium` with this package's
|
|
66
|
-
* Playwright, streaming its output.
|
|
66
|
+
* Playwright, streaming its output. Captures always run headless, which uses
|
|
67
67
|
* the headless shell, so the full Chrome for Testing build (about 370 MB on
|
|
68
|
-
* disk)
|
|
68
|
+
* disk) is only fetched with `headed` (for `rpc login`'s browser window).
|
|
69
69
|
*/
|
|
70
|
-
export function installChromium() {
|
|
70
|
+
export function installChromium(opts = {}) {
|
|
71
71
|
return new Promise((res, rej) => {
|
|
72
72
|
// Load the CLI through -e with a plain argv[1]. Under npx the real path is
|
|
73
73
|
// in the `_npx` cache, and Playwright then prints a box telling the user
|
|
74
74
|
// to install their project's dependencies, which doesn't apply here.
|
|
75
75
|
const loader = `process.argv.splice(1, 0, 'playwright'); require(${JSON.stringify(playwrightCli())});`;
|
|
76
|
-
|
|
76
|
+
// --headed: full Chromium as well, for `rpc login`'s visible window.
|
|
77
|
+
const args = opts.headed ? ['install', 'chromium'] : ['install', '--only-shell', 'chromium'];
|
|
78
|
+
const child = spawn(process.execPath, ['-e', loader, ...args], { stdio: 'inherit' });
|
|
77
79
|
child.on('error', rej);
|
|
78
80
|
child.on('exit', code => res(code ?? 1));
|
|
79
81
|
});
|
package/dist/capture.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, extname, resolve } from 'node:path';
|
|
3
|
+
import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
|
|
3
4
|
import { launchBrowser } from './browser.js';
|
|
4
5
|
import { renderFramed, renderSlices } from './composite.js';
|
|
5
6
|
import { explainError } from './errors.js';
|
|
@@ -11,7 +12,10 @@ export { launchBrowser } from './browser.js';
|
|
|
11
12
|
export async function capture(opts, hooks = {}) {
|
|
12
13
|
const devices = resolveDevices(opts.devices);
|
|
13
14
|
const outDir = resolve(opts.outDir);
|
|
15
|
+
const auth = hooks.auth ?? resolveAuth(opts.url, opts.auth ?? {});
|
|
14
16
|
await mkdir(outDir, { recursive: true });
|
|
17
|
+
if (auth.summary)
|
|
18
|
+
hooks.onAuth?.(auth.summary);
|
|
15
19
|
const browser = hooks.browser ?? (await launchBrowser());
|
|
16
20
|
try {
|
|
17
21
|
const shots = [];
|
|
@@ -19,28 +23,38 @@ export async function capture(opts, hooks = {}) {
|
|
|
19
23
|
let shot;
|
|
20
24
|
hooks.onDeviceStart?.(device);
|
|
21
25
|
try {
|
|
22
|
-
shot = await captureDevice(browser, device, opts, outDir, hooks);
|
|
26
|
+
shot = await captureDevice(browser, device, opts, outDir, hooks, auth);
|
|
23
27
|
}
|
|
24
28
|
catch (err) {
|
|
25
|
-
shot = baseShot(device);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
shot.
|
|
29
|
-
if (e.hint)
|
|
30
|
-
shot.hint = e.hint;
|
|
29
|
+
shot = { ...baseShot(device), ...describeFailure(err, opts, auth) };
|
|
30
|
+
}
|
|
31
|
+
if (shot.consoleErrors?.length && auth.secrets.length) {
|
|
32
|
+
shot.consoleErrors = shot.consoleErrors.map(l => redact(l, auth.secrets));
|
|
31
33
|
}
|
|
32
34
|
if (!hooks.keepBuffers)
|
|
33
35
|
delete shot.buffers;
|
|
34
36
|
hooks.onDevice?.(shot);
|
|
35
37
|
shots.push(shot);
|
|
36
38
|
}
|
|
37
|
-
return { url: opts.url, outDir, shots };
|
|
39
|
+
return { url: opts.url, outDir, shots, ...(auth.summary ? { auth: auth.summary } : {}) };
|
|
38
40
|
}
|
|
39
41
|
finally {
|
|
40
42
|
if (!hooks.browser)
|
|
41
43
|
await browser.close();
|
|
42
44
|
}
|
|
43
45
|
}
|
|
46
|
+
/** A device's navigation/capture failure as `{ error, code, hint }`, secrets scrubbed. */
|
|
47
|
+
export function describeFailure(err, opts, auth) {
|
|
48
|
+
if (err instanceof AuthRequiredError) {
|
|
49
|
+
return { status: err.status, finalUrl: err.finalUrl, error: err.message, code: err.code, hint: err.hint };
|
|
50
|
+
}
|
|
51
|
+
const e = explainError(err, { url: opts.url, timeoutMs: opts.timeoutMs, waitFor: opts.waitFor });
|
|
52
|
+
return {
|
|
53
|
+
error: redact(e.error, auth.secrets),
|
|
54
|
+
code: e.code,
|
|
55
|
+
...(e.hint ? { hint: redact(e.hint, auth.secrets) } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
44
58
|
function baseShot(device) {
|
|
45
59
|
return {
|
|
46
60
|
device: device.id,
|
|
@@ -49,7 +63,8 @@ function baseShot(device) {
|
|
|
49
63
|
dpr: device.dpr,
|
|
50
64
|
};
|
|
51
65
|
}
|
|
52
|
-
|
|
66
|
+
/** A fresh context for one device: viewport, DPR, mobile emulation, media features and auth state. */
|
|
67
|
+
export async function newDeviceContext(browser, device, opts, auth) {
|
|
53
68
|
const emu = opts.emulate ? emulationFor(device) : undefined;
|
|
54
69
|
const context = await browser.newContext({
|
|
55
70
|
viewport: { width: device.width, height: device.height },
|
|
@@ -60,11 +75,19 @@ async function captureDevice(browser, device, opts, outDir, hooks) {
|
|
|
60
75
|
...(opts.colorScheme ? { colorScheme: opts.colorScheme } : {}),
|
|
61
76
|
...(opts.reducedMotion ? { reducedMotion: 'reduce' } : {}),
|
|
62
77
|
ignoreHTTPSErrors: true, // self-signed local dev certs
|
|
78
|
+
...(auth.state ? { storageState: auth.state } : {}),
|
|
63
79
|
});
|
|
80
|
+
if (auth.cookies.length)
|
|
81
|
+
await context.addCookies(auth.cookies);
|
|
82
|
+
return context;
|
|
83
|
+
}
|
|
84
|
+
async function captureDevice(browser, device, opts, outDir, hooks, auth) {
|
|
85
|
+
const context = await newDeviceContext(browser, device, opts, auth);
|
|
64
86
|
const result = baseShot(device);
|
|
65
87
|
const errors = new Set();
|
|
66
88
|
try {
|
|
67
89
|
const page = await context.newPage();
|
|
90
|
+
await applyHeaders(context, page, auth);
|
|
68
91
|
page.on('pageerror', e => errors.add(`pageerror: ${e.message}`));
|
|
69
92
|
page.on('console', m => {
|
|
70
93
|
if (m.type() === 'error')
|
|
@@ -73,8 +96,20 @@ async function captureDevice(browser, device, opts, outDir, hooks) {
|
|
|
73
96
|
const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
74
97
|
result.status = response?.status() ?? null;
|
|
75
98
|
await settle(page, opts.timeoutMs);
|
|
99
|
+
// A login page instead of the requested one: say so rather than capture it.
|
|
100
|
+
const checkLogin = () => {
|
|
101
|
+
if (redirectedToLogin(opts.url, page.url()))
|
|
102
|
+
throw new AuthRequiredError(opts.url, page.url(), result.status ?? null);
|
|
103
|
+
};
|
|
104
|
+
checkLogin();
|
|
76
105
|
if (opts.waitFor) {
|
|
77
|
-
|
|
106
|
+
try {
|
|
107
|
+
await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
checkLogin();
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
78
113
|
}
|
|
79
114
|
if (opts.fullPage) {
|
|
80
115
|
await warmUpScroll(page, opts.maxHeight);
|
|
@@ -82,6 +117,7 @@ async function captureDevice(browser, device, opts, outDir, hooks) {
|
|
|
82
117
|
}
|
|
83
118
|
if (opts.waitMs > 0)
|
|
84
119
|
await page.waitForTimeout(opts.waitMs);
|
|
120
|
+
checkLogin();
|
|
85
121
|
result.finalUrl = page.url();
|
|
86
122
|
result.title = await page.title();
|
|
87
123
|
result.scrollHeight = await page.evaluate(SCROLL_HEIGHT_JS);
|
|
@@ -211,7 +247,7 @@ const SCROLL_HEIGHT_JS = `Math.max(document.documentElement.scrollHeight, docume
|
|
|
211
247
|
* Network idle (bounded: dev servers with HMR or analytics beacons may never
|
|
212
248
|
* go idle), web fonts, and pending <img> loads.
|
|
213
249
|
*/
|
|
214
|
-
async function settle(page, timeoutMs) {
|
|
250
|
+
export async function settle(page, timeoutMs) {
|
|
215
251
|
await page
|
|
216
252
|
.waitForLoadState('networkidle', { timeout: Math.min(timeoutMs, 10_000) })
|
|
217
253
|
.catch(() => { });
|