@monoes/monobrowse 1.0.22 → 1.0.25
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/dist/src/__tests__/browse-concurrency.test.js +94 -1
- package/dist/src/__tests__/browse-concurrency.test.js.map +1 -1
- package/dist/src/__tests__/browser-launch.test.js +137 -7
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/__tests__/session-discovery.test.js +81 -1
- package/dist/src/__tests__/session-discovery.test.js.map +1 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +34 -13
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +6 -0
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +4 -0
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/cli/attention-wall.d.ts +12 -0
- package/dist/src/cli/attention-wall.d.ts.map +1 -0
- package/dist/src/cli/attention-wall.js +166 -0
- package/dist/src/cli/attention-wall.js.map +1 -0
- package/dist/src/cli/commands-session.d.ts.map +1 -1
- package/dist/src/cli/commands-session.js +24 -11
- package/dist/src/cli/commands-session.js.map +1 -1
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +21 -8
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +17 -3
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +72 -162
- package/dist/src/cli/session.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/browse-concurrency.test.ts +106 -1
- package/src/__tests__/browser-launch.test.ts +142 -7
- package/src/__tests__/session-discovery.test.ts +112 -2
- package/src/browser/browser.ts +32 -11
- package/src/browser/ref-cache.ts +16 -1
- package/src/cli/attention-wall.ts +202 -0
- package/src/cli/commands-session.ts +25 -12
- package/src/cli/commands.ts +22 -8
- package/src/cli/session.ts +78 -198
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login / CAPTCHA walls — how `open` notices one, and the round trip to a
|
|
3
|
+
* visible browser window that lets a person get past it before the session
|
|
4
|
+
* carries on headless with the cookies and localStorage they earned.
|
|
5
|
+
*
|
|
6
|
+
* Split out of session.ts, which keeps the session runtime itself.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { CdpClient } from '../index.js';
|
|
13
|
+
import { output } from './output.js';
|
|
14
|
+
import { getBrowser, session } from './session.js';
|
|
15
|
+
|
|
16
|
+
// URL patterns that signal an unambiguous login/auth wall.
|
|
17
|
+
// Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
|
|
18
|
+
// (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
|
|
19
|
+
// DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
|
|
20
|
+
const ATTENTION_URL_RE =
|
|
21
|
+
/\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
|
|
22
|
+
|
|
23
|
+
export async function detectAttentionNeeded(
|
|
24
|
+
client: CdpClient,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
url: string,
|
|
27
|
+
): Promise<'login' | 'captcha' | null> {
|
|
28
|
+
if (ATTENTION_URL_RE.test(url)) return 'login';
|
|
29
|
+
try {
|
|
30
|
+
const hasPassword = await client
|
|
31
|
+
.send<{ result: { value: boolean } }>(
|
|
32
|
+
'Runtime.evaluate',
|
|
33
|
+
{
|
|
34
|
+
expression: '!!document.querySelector("input[type=password]")',
|
|
35
|
+
returnByValue: true,
|
|
36
|
+
},
|
|
37
|
+
sessionId,
|
|
38
|
+
)
|
|
39
|
+
.then((r) => r.result?.value === true)
|
|
40
|
+
.catch(() => false);
|
|
41
|
+
if (hasPassword) return 'login';
|
|
42
|
+
|
|
43
|
+
const hasCaptcha = await client
|
|
44
|
+
.send<{ result: { value: boolean } }>(
|
|
45
|
+
'Runtime.evaluate',
|
|
46
|
+
{
|
|
47
|
+
expression:
|
|
48
|
+
'!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
|
|
49
|
+
returnByValue: true,
|
|
50
|
+
},
|
|
51
|
+
sessionId,
|
|
52
|
+
)
|
|
53
|
+
.then((r) => r.result?.value === true)
|
|
54
|
+
.catch(() => false);
|
|
55
|
+
if (hasCaptcha) return 'captcha';
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore CDP errors */
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function waitForEnter(): Promise<void> {
|
|
63
|
+
if (!process.stdin.isTTY) {
|
|
64
|
+
// Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
|
|
65
|
+
// Auto-continue after a short grace period so automation isn't blocked.
|
|
66
|
+
output.printWarning(
|
|
67
|
+
'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
|
|
68
|
+
);
|
|
69
|
+
return new Promise((resolve) => setTimeout(resolve, 30_000));
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
process.stdin.resume();
|
|
73
|
+
process.stdin.setEncoding('utf8');
|
|
74
|
+
const onData = () => {
|
|
75
|
+
process.stdin.pause();
|
|
76
|
+
process.stdin.off('data', onData);
|
|
77
|
+
resolve();
|
|
78
|
+
};
|
|
79
|
+
process.stdin.once('data', onData);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function switchToHeaded(url: string, port: number): Promise<void> {
|
|
84
|
+
const browser = await getBrowser();
|
|
85
|
+
|
|
86
|
+
// Snapshot cookies from current headless session before closing
|
|
87
|
+
let savedCookies: unknown[] = [];
|
|
88
|
+
if (session.client && session.sessionId) {
|
|
89
|
+
try {
|
|
90
|
+
savedCookies = await browser.getCookies(session.client, session.sessionId);
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
browser.teardownRouteInterception(session.sessionId);
|
|
95
|
+
browser.stopRequestCapture(session.sessionId);
|
|
96
|
+
browser.teardownDialogHandling(session.sessionId);
|
|
97
|
+
browser.teardownConsoleCapture(session.sessionId);
|
|
98
|
+
session.client.close();
|
|
99
|
+
session.client = null;
|
|
100
|
+
session.sessionId = '';
|
|
101
|
+
session.parentSessionId = '';
|
|
102
|
+
session.targetId = '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Kernel-assigned free port and a profile of its own, so this temporary
|
|
106
|
+
// headed window cannot collide with any other session (#318) — a
|
|
107
|
+
// neighbouring port (port + 10) could well be another session's.
|
|
108
|
+
let headedPort = 0;
|
|
109
|
+
try {
|
|
110
|
+
headedPort = await browser.launchBrowser({
|
|
111
|
+
port: 0,
|
|
112
|
+
headless: false,
|
|
113
|
+
userDataDir: join(
|
|
114
|
+
tmpdir(),
|
|
115
|
+
`monomind-browse-headed-${process.pid}-${randomUUID().slice(0, 8)}`,
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Headed launch failed (no display, locked down env). Restore headless and rethrow.
|
|
120
|
+
session.port = await browser.launchBrowser({ port, headless: true });
|
|
121
|
+
const fallback = await browser.connectToTarget(session.port);
|
|
122
|
+
session.client = fallback.client;
|
|
123
|
+
session.sessionId = fallback.sessionId;
|
|
124
|
+
session.targetId = fallback.target.id;
|
|
125
|
+
session.refs = new Map();
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const conn = await browser.connectToTarget(headedPort);
|
|
131
|
+
session.client = conn.client;
|
|
132
|
+
session.sessionId = conn.sessionId;
|
|
133
|
+
session.targetId = conn.target.id;
|
|
134
|
+
session.refs = new Map();
|
|
135
|
+
|
|
136
|
+
if (savedCookies.length) {
|
|
137
|
+
try {
|
|
138
|
+
await browser.setCookies(
|
|
139
|
+
session.client,
|
|
140
|
+
session.sessionId,
|
|
141
|
+
savedCookies as Parameters<typeof browser.setCookies>[2],
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await browser.openUrl(session.client, session.sessionId, url);
|
|
148
|
+
|
|
149
|
+
output.printInfo(
|
|
150
|
+
'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
|
|
151
|
+
);
|
|
152
|
+
await waitForEnter();
|
|
153
|
+
|
|
154
|
+
// Capture post-auth cookies
|
|
155
|
+
const authCookies = await browser
|
|
156
|
+
.getCookies(session.client, session.sessionId)
|
|
157
|
+
.catch(() => [] as unknown[]);
|
|
158
|
+
const authLocalStorage = await browser
|
|
159
|
+
.getLocalStorage(session.client, session.sessionId)
|
|
160
|
+
.catch(() => ({}) as Record<string, string>);
|
|
161
|
+
|
|
162
|
+
// Close headed session — actually terminate the underlying Chrome process
|
|
163
|
+
// (Browser.close CDP command, PID-kill fallback), not just our CDP client
|
|
164
|
+
// connection, so the visible authenticated window doesn't linger forever.
|
|
165
|
+
browser.teardownRouteInterception(session.sessionId);
|
|
166
|
+
browser.stopRequestCapture(session.sessionId);
|
|
167
|
+
browser.teardownDialogHandling(session.sessionId);
|
|
168
|
+
browser.teardownConsoleCapture(session.sessionId);
|
|
169
|
+
await browser.closeBrowser(session.client, headedPort);
|
|
170
|
+
session.client.close();
|
|
171
|
+
session.client = null;
|
|
172
|
+
session.sessionId = '';
|
|
173
|
+
session.parentSessionId = '';
|
|
174
|
+
session.targetId = '';
|
|
175
|
+
|
|
176
|
+
// Relaunch headless and restore session
|
|
177
|
+
session.port = await browser.launchBrowser({ port, headless: true });
|
|
178
|
+
const headlessConn = await browser.connectToTarget(session.port);
|
|
179
|
+
session.client = headlessConn.client;
|
|
180
|
+
session.sessionId = headlessConn.sessionId;
|
|
181
|
+
session.targetId = headlessConn.target.id;
|
|
182
|
+
session.refs = new Map();
|
|
183
|
+
|
|
184
|
+
if (authCookies.length) {
|
|
185
|
+
try {
|
|
186
|
+
await browser.setCookies(
|
|
187
|
+
session.client,
|
|
188
|
+
session.sessionId,
|
|
189
|
+
authCookies as Parameters<typeof browser.setCookies>[2],
|
|
190
|
+
);
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (authLocalStorage && Object.keys(authLocalStorage).length) {
|
|
196
|
+
try {
|
|
197
|
+
await browser.setLocalStorage(session.client, session.sessionId, authLocalStorage);
|
|
198
|
+
} catch {
|
|
199
|
+
/* ignore */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -8,17 +8,16 @@
|
|
|
8
8
|
* differently: it must never kill a Chrome it did not launch.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { detectAttentionNeeded, switchToHeaded } from './attention-wall.js';
|
|
11
12
|
import { output } from './output.js';
|
|
12
13
|
import {
|
|
13
14
|
adoptLegacySession,
|
|
14
|
-
detectAttentionNeeded,
|
|
15
15
|
ensureConnected,
|
|
16
16
|
getBrowser,
|
|
17
17
|
launchSessionBrowser,
|
|
18
18
|
pinnedPort,
|
|
19
19
|
resolveLiveSession,
|
|
20
20
|
session,
|
|
21
|
-
switchToHeaded,
|
|
22
21
|
} from './session.js';
|
|
23
22
|
import type { Command, CommandContext, CommandResult } from './types.js';
|
|
24
23
|
|
|
@@ -39,7 +38,13 @@ export const openCommand: Command = {
|
|
|
39
38
|
description: 'Force visible browser window',
|
|
40
39
|
default: false,
|
|
41
40
|
},
|
|
42
|
-
{
|
|
41
|
+
{
|
|
42
|
+
name: 'session',
|
|
43
|
+
short: 's',
|
|
44
|
+
type: 'string',
|
|
45
|
+
description:
|
|
46
|
+
'Named session: re-open the live one with this name or start it, and restore saved state of that name if any',
|
|
47
|
+
},
|
|
43
48
|
{ name: 'state', type: 'string', description: 'State file to load' },
|
|
44
49
|
],
|
|
45
50
|
action: async (ctx: CommandContext): Promise<CommandResult> => {
|
|
@@ -48,10 +53,14 @@ export const openCommand: Command = {
|
|
|
48
53
|
|
|
49
54
|
// No --port means "start a session of my own on a free port" (#318) —
|
|
50
55
|
// never "join whatever is on 9222", which is what made two uncoordinated
|
|
51
|
-
// `open` calls race for one browser and one profile directory.
|
|
52
|
-
|
|
53
|
-
const forceHeaded = ctx.flags.headed as boolean;
|
|
56
|
+
// `open` calls race for one browser and one profile directory. A
|
|
57
|
+
// --session name re-opens the live session of that name, if there is one.
|
|
54
58
|
const browser = await getBrowser();
|
|
59
|
+
const name = session.name || undefined;
|
|
60
|
+
const port =
|
|
61
|
+
pinnedPort(ctx.flags) ??
|
|
62
|
+
(name ? (await resolveLiveSession(browser, { name }))?.port : undefined);
|
|
63
|
+
const forceHeaded = ctx.flags.headed as boolean;
|
|
55
64
|
|
|
56
65
|
if (session.client) {
|
|
57
66
|
const prevSid = session.sessionId;
|
|
@@ -91,7 +100,7 @@ export const openCommand: Command = {
|
|
|
91
100
|
|
|
92
101
|
// Starts this session's own browser (and records it) unless --port named
|
|
93
102
|
// one to attach to — see the session rule in session.ts.
|
|
94
|
-
session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded });
|
|
103
|
+
session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded, name });
|
|
95
104
|
const conn = await browser.connectToTarget(session.port);
|
|
96
105
|
session.client = conn.client;
|
|
97
106
|
session.sessionId = conn.sessionId;
|
|
@@ -107,8 +116,10 @@ export const openCommand: Command = {
|
|
|
107
116
|
}
|
|
108
117
|
if (ctx.flags.state) {
|
|
109
118
|
await browser.loadStateFile(session.client, session.sessionId, ctx.flags.state as string);
|
|
110
|
-
} else if (
|
|
111
|
-
|
|
119
|
+
} else if (name && (await browser.listSessions()).includes(name)) {
|
|
120
|
+
// Saved state of the same name (`state save <name>`) is restored; a
|
|
121
|
+
// name with none is simply a fresh named session.
|
|
122
|
+
await browser.loadSession(session.client, session.sessionId, name);
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
await browser.openUrl(session.client, session.sessionId, url);
|
|
@@ -228,7 +239,7 @@ export const closeCommand: Command = {
|
|
|
228
239
|
port: pinned,
|
|
229
240
|
launched: true,
|
|
230
241
|
})
|
|
231
|
-
: await resolveLiveSession(browser, { strict: false });
|
|
242
|
+
: await resolveLiveSession(browser, { strict: false, name: session.name || undefined });
|
|
232
243
|
if (persisted?.launched) {
|
|
233
244
|
try {
|
|
234
245
|
const conn = await browser.connectToTarget(persisted.port);
|
|
@@ -271,7 +282,9 @@ export const closeCommand: Command = {
|
|
|
271
282
|
output.printInfo(
|
|
272
283
|
persisted
|
|
273
284
|
? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
|
|
274
|
-
:
|
|
285
|
+
: session.name
|
|
286
|
+
? `No live browse session named "${session.name}"`
|
|
287
|
+
: 'No active browser session',
|
|
275
288
|
);
|
|
276
289
|
}
|
|
277
290
|
if (persisted) {
|
|
@@ -370,7 +383,7 @@ export const connectCommand: Command = {
|
|
|
370
383
|
// launches a browser of its own instead of reusing this session.
|
|
371
384
|
// launched:false marks this browser as someone else's — close must never
|
|
372
385
|
// kill it, and a dead endpoint must not be silently relaunched.
|
|
373
|
-
await browser.saveSessionRecord(port, { launched: false });
|
|
386
|
+
await browser.saveSessionRecord(port, { launched: false, name: session.name || undefined });
|
|
374
387
|
const url = await browser.getCurrentUrl(session.client, session.sessionId);
|
|
375
388
|
const title = await browser.getCurrentTitle(session.client, session.sessionId);
|
|
376
389
|
output.printSuccess(`Connected: ${title} (${url})`);
|
package/src/cli/commands.ts
CHANGED
|
@@ -90,7 +90,7 @@ import {
|
|
|
90
90
|
} from './commands-trace.js';
|
|
91
91
|
import { output } from './output.js';
|
|
92
92
|
import { createPlatformCommand } from './platform.js';
|
|
93
|
-
import { applySessionPortFlag } from './session.js';
|
|
93
|
+
import { applySessionNameFlag, applySessionPortFlag } from './session.js';
|
|
94
94
|
import type { Command, CommandContext, CommandResult } from './types.js';
|
|
95
95
|
|
|
96
96
|
// Re-exported for direct unit testing — these were exported from this file
|
|
@@ -230,16 +230,33 @@ const PORT_OPTION = {
|
|
|
230
230
|
description: 'Act on the browse session on this CDP port (default: the newest live session)',
|
|
231
231
|
} as const;
|
|
232
232
|
|
|
233
|
+
const SESSION_OPTION = {
|
|
234
|
+
name: 'session',
|
|
235
|
+
short: 's',
|
|
236
|
+
type: 'string',
|
|
237
|
+
description: 'Act on the live browse session with this name (open/connect start it)',
|
|
238
|
+
} as const;
|
|
239
|
+
|
|
233
240
|
function withSessionSelector(cmd: Command): Command {
|
|
234
241
|
const action = cmd.action;
|
|
242
|
+
const options = cmd.options?.some((o) => o.name === 'port')
|
|
243
|
+
? (cmd.options ?? [])
|
|
244
|
+
: [...(cmd.options ?? []), { ...PORT_OPTION }];
|
|
235
245
|
return {
|
|
236
246
|
...cmd,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
247
|
+
// `snapshot`/`scroll` already use -s for --selector: long form only there.
|
|
248
|
+
options: options.some((o) => o.name === 'session')
|
|
249
|
+
? options
|
|
250
|
+
: [
|
|
251
|
+
...options,
|
|
252
|
+
options.some((o) => o.short === 's')
|
|
253
|
+
? { ...SESSION_OPTION, short: undefined }
|
|
254
|
+
: { ...SESSION_OPTION },
|
|
255
|
+
],
|
|
240
256
|
action: action
|
|
241
257
|
? async (ctx: CommandContext) => {
|
|
242
258
|
applySessionPortFlag(ctx.flags);
|
|
259
|
+
applySessionNameFlag(ctx.flags);
|
|
243
260
|
return action(ctx);
|
|
244
261
|
}
|
|
245
262
|
: undefined,
|
|
@@ -324,10 +341,7 @@ const browseCommand: Command = {
|
|
|
324
341
|
name: 'browse',
|
|
325
342
|
description: 'Native browser automation via Chrome DevTools Protocol',
|
|
326
343
|
subcommands,
|
|
327
|
-
options: [
|
|
328
|
-
{ ...PORT_OPTION },
|
|
329
|
-
{ name: 'session', short: 's', type: 'string', description: 'Named session to use' },
|
|
330
|
-
],
|
|
344
|
+
options: [{ ...PORT_OPTION }, { ...SESSION_OPTION }],
|
|
331
345
|
examples: [
|
|
332
346
|
{ command: 'monomind browse open https://example.com', description: 'Open a URL' },
|
|
333
347
|
{
|