@monoes/monobrowse 1.0.17 → 1.0.21
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.d.ts +15 -0
- package/dist/src/__tests__/browse-concurrency.test.d.ts.map +1 -0
- package/dist/src/__tests__/browse-concurrency.test.js +198 -0
- package/dist/src/__tests__/browse-concurrency.test.js.map +1 -0
- package/dist/src/__tests__/browser-launch.test.js +132 -1
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/__tests__/close-browser.test.js +2 -2
- package/dist/src/__tests__/close-browser.test.js.map +1 -1
- package/dist/src/__tests__/frame-command.test.js +12 -2
- package/dist/src/__tests__/frame-command.test.js.map +1 -1
- package/dist/src/__tests__/reap-idle-browser.test.js +13 -13
- package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -1
- package/dist/src/__tests__/ref-cache.test.js +96 -58
- package/dist/src/__tests__/ref-cache.test.js.map +1 -1
- package/dist/src/__tests__/report-exit-code.test.js +1 -1
- package/dist/src/__tests__/session-discovery.test.d.ts +13 -0
- package/dist/src/__tests__/session-discovery.test.d.ts.map +1 -0
- package/dist/src/__tests__/session-discovery.test.js +218 -0
- package/dist/src/__tests__/session-discovery.test.js.map +1 -0
- package/dist/src/browser/browser.d.ts +2 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +145 -42
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +50 -22
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +122 -57
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/browser/types.d.ts +2 -0
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.js.map +1 -1
- package/dist/src/cli/commands-context.js +1 -1
- package/dist/src/cli/commands-context.js.map +1 -1
- package/dist/src/cli/commands-navigate.js +2 -2
- package/dist/src/cli/commands-navigate.js.map +1 -1
- package/dist/src/cli/commands-page.js +2 -2
- package/dist/src/cli/commands-page.js.map +1 -1
- package/dist/src/cli/commands-report.js +2 -2
- package/dist/src/cli/commands-report.js.map +1 -1
- package/dist/src/cli/commands-session.d.ts.map +1 -1
- package/dist/src/cli/commands-session.js +70 -56
- 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 +102 -66
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +66 -2
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +190 -33
- 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 +233 -0
- package/src/__tests__/browser-launch.test.ts +142 -1
- package/src/__tests__/close-browser.test.ts +2 -2
- package/src/__tests__/frame-command.test.ts +12 -2
- package/src/__tests__/reap-idle-browser.test.ts +15 -14
- package/src/__tests__/ref-cache.test.ts +112 -59
- package/src/__tests__/report-exit-code.test.ts +1 -1
- package/src/__tests__/session-discovery.test.ts +259 -0
- package/src/browser/browser.ts +149 -39
- package/src/browser/ref-cache.ts +144 -72
- package/src/browser/types.ts +2 -0
- package/src/cli/commands-context.ts +1 -1
- package/src/cli/commands-navigate.ts +2 -2
- package/src/cli/commands-page.ts +2 -2
- package/src/cli/commands-report.ts +2 -2
- package/src/cli/commands-session.ts +74 -55
- package/src/cli/commands.ts +106 -66
- package/src/cli/session.ts +208 -33
package/src/browser/browser.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { execSync, spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { connect } from 'node:net';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { CdpClient, fetchNewTarget, fetchTargets } from './cdp.js';
|
|
7
7
|
import { enableConsoleCapture, setupConsoleCapture } from './console-log.js';
|
|
8
8
|
import { setupDialogAutoHandling } from './dialog.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
listSessionRecords,
|
|
11
|
+
loadSessionRecord,
|
|
12
|
+
removeSessionRecord,
|
|
13
|
+
type SessionRecord,
|
|
14
|
+
} from './ref-cache.js';
|
|
10
15
|
import type { BrowserConfig, CdpTarget } from './types.js';
|
|
11
16
|
import { CHROME_EXECUTABLES } from './types.js';
|
|
12
17
|
|
|
@@ -128,9 +133,17 @@ const LAUNCH_PORT_SCAN_TRIES = 10;
|
|
|
128
133
|
|
|
129
134
|
export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
|
|
130
135
|
const rawPort = config.port ?? DEFAULT_PORT;
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
136
|
+
// Port 0 means "let Chrome bind a free port and tell us which" — see
|
|
137
|
+
// launchOnFreePort. Otherwise validate port is in a safe range for
|
|
138
|
+
// localhost CDP debugging.
|
|
139
|
+
if (rawPort === 0) {
|
|
140
|
+
if (!config.userDataDir) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'port 0 requires a dedicated userDataDir (Chrome reports the port it bound inside it).',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} else if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
|
|
146
|
+
throw new Error(`Invalid port: ${rawPort}. Must be 0 or an integer between 1024 and 65535.`);
|
|
134
147
|
}
|
|
135
148
|
|
|
136
149
|
// Every launch/attach is this tool's only chance to notice that a previous
|
|
@@ -139,6 +152,8 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
139
152
|
// instance on the port we are about to use is freed before we probe it.
|
|
140
153
|
await reapIdleLaunchedBrowser();
|
|
141
154
|
|
|
155
|
+
if (rawPort === 0) return launchOnFreePort(config, 0);
|
|
156
|
+
|
|
142
157
|
// strictPort: fail fast on the exact requested port, matching the old
|
|
143
158
|
// behavior (Vite has the same escape hatch for the same reason) — for
|
|
144
159
|
// callers that treat the error as a signal ("this port is taken by
|
|
@@ -158,19 +173,7 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
158
173
|
for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++)
|
|
159
174
|
candidates.push(rawPort + i);
|
|
160
175
|
|
|
161
|
-
|
|
162
|
-
// original, deliberate, single-port risk ("don't silently take over an
|
|
163
|
-
// unrelated real browser that happens to be on this port"). Scanning past
|
|
164
|
-
// an occupied default must not let that same shortcut attach to a
|
|
165
|
-
// DIFFERENT Chrome instance the caller never named; forward candidates are
|
|
166
|
-
// launch-only (skip if anything is there, Chrome or not).
|
|
167
|
-
if (await isTcpPortOpen(rawPort)) {
|
|
168
|
-
if (await isChromeIdentity(rawPort)) return rawPort;
|
|
169
|
-
} else {
|
|
170
|
-
return launchOnFreePort(config, rawPort);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
for (const candidate of candidates.slice(1)) {
|
|
176
|
+
for (const candidate of candidates) {
|
|
174
177
|
// TCP-level check for "is anything at all listening" — isPortOpen()
|
|
175
178
|
// does a full CDP /json fetch, which returns false BOTH for a genuinely
|
|
176
179
|
// free port and for one occupied by a non-CDP process (that ambiguity is
|
|
@@ -179,10 +182,39 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
179
182
|
// socket first tells free and occupied apart up front, so the scan can
|
|
180
183
|
// skip an occupied candidate instead of trying to spawn Chrome on top of
|
|
181
184
|
// it and only discovering the conflict after a timeout.
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
if (await isTcpPortOpen(candidate)) {
|
|
186
|
+
// Attach-if-already-Chrome only applies to the EXACT requested port —
|
|
187
|
+
// the original, deliberate, single-port risk ("don't silently take
|
|
188
|
+
// over an unrelated real browser that happens to be on this port").
|
|
189
|
+
// Scanning past an occupied default must not let that same shortcut
|
|
190
|
+
// attach to a DIFFERENT Chrome instance the caller never named;
|
|
191
|
+
// forward candidates are launch-only (skip if anything is there,
|
|
192
|
+
// Chrome or not).
|
|
193
|
+
if (candidate === rawPort && (await isChromeIdentity(candidate))) return candidate;
|
|
194
|
+
// Occupied (by anything — not just non-Chrome, per the note above) —
|
|
195
|
+
// try the next candidate instead of failing outright, same as a
|
|
196
|
+
// normal EADDRINUSE retry would.
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return await launchOnFreePort(config, candidate);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
const isLastCandidate = candidate === candidates[candidates.length - 1];
|
|
203
|
+
// isTcpPortOpen() above is a connect() probe, not an atomic claim: two
|
|
204
|
+
// concurrent launchBrowser() calls with no explicit --port can both
|
|
205
|
+
// observe the same candidate as free and both spawn Chrome on it
|
|
206
|
+
// before either binds. One wins; the other's Chrome exits before its
|
|
207
|
+
// CDP endpoint opens ("Chrome exited before the CDP endpoint opened
|
|
208
|
+
// ... code=21"). Something is listening on the candidate NOW that
|
|
209
|
+
// was not a moment ago — a losing race, not a broken Chrome install —
|
|
210
|
+
// so try the next candidate exactly like an already-occupied one,
|
|
211
|
+
// instead of failing the whole launch outright. A candidate that
|
|
212
|
+
// fails with nothing now listening (a real launch failure — bad
|
|
213
|
+
// executable, sandbox refusal, etc.) is not a race: retrying the
|
|
214
|
+
// next candidate would only fail the same way, so surface it as-is.
|
|
215
|
+
if (!isLastCandidate && (await isTcpPortOpen(candidate))) continue;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
186
218
|
}
|
|
187
219
|
throw new Error(
|
|
188
220
|
`Ports ${candidates[0]}-${candidates[candidates.length - 1]} are all occupied and port ${candidates[0]} ` +
|
|
@@ -190,9 +222,50 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
190
222
|
);
|
|
191
223
|
}
|
|
192
224
|
|
|
225
|
+
/** The port Chrome wrote to `<userDataDir>/DevToolsActivePort` once its
|
|
226
|
+
* DevTools server was listening, or null if it has not (yet). */
|
|
227
|
+
function readDevToolsActivePort(file: string): number | null {
|
|
228
|
+
try {
|
|
229
|
+
const port = Number.parseInt(readFileSync(file, 'utf8').split('\n')[0], 10);
|
|
230
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Spawn Chrome and wait for its CDP endpoint.
|
|
238
|
+
*
|
|
239
|
+
* With a fixed `port`, "our Chrome is up" is inferred from *some* Chrome
|
|
240
|
+
* answering on 127.0.0.1:<port> — which is not necessarily ours. When the port
|
|
241
|
+
* is already bound on 127.0.0.1 (a concurrent launch picked the same "free"
|
|
242
|
+
* port between its probe and Chrome's bind), Chrome does not fail: it logs
|
|
243
|
+
* `bind() failed: Address already in use` and listens on [::1]:<port>
|
|
244
|
+
* instead. The poll then accepts the OTHER launcher's browser, both callers
|
|
245
|
+
* drive one Chrome, and whichever closes first kills the other's connections
|
|
246
|
+
* ("CDP connection closed") while this launch's own Chrome is orphaned.
|
|
247
|
+
*
|
|
248
|
+
* `port: 0` removes the race instead of narrowing it: Chrome asks the kernel
|
|
249
|
+
* for a free port at bind time — atomic, nothing to collide on — and writes
|
|
250
|
+
* the port it got to DevToolsActivePort in its own (caller-dedicated) profile
|
|
251
|
+
* directory, which is proof the endpoint is the process we spawned.
|
|
252
|
+
*/
|
|
193
253
|
async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
|
|
194
254
|
const chromePath = findChrome(config.executablePath);
|
|
195
|
-
|
|
255
|
+
// Suffixed with our own pid: two concurrent launches that both probe the
|
|
256
|
+
// same candidate port as free (the race this function exists to survive —
|
|
257
|
+
// see the retry-on-collision caller above) briefly run Chrome with
|
|
258
|
+
// *identical* --user-data-dir values if it were derived from the port
|
|
259
|
+
// alone, sharing a profile directory (lock files, preferences, the
|
|
260
|
+
// DevToolsActivePort file itself) between two unrelated Chrome processes
|
|
261
|
+
// for as long as the loser stays alive. Different processes always have
|
|
262
|
+
// different pids, so this can never collide even when the port does.
|
|
263
|
+
const userDataDir =
|
|
264
|
+
config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}-${process.pid}`);
|
|
265
|
+
const activePortFile = join(userDataDir, 'DevToolsActivePort');
|
|
266
|
+
// A reused profile dir may hold a previous run's file naming a port some
|
|
267
|
+
// other process now owns.
|
|
268
|
+
if (port === 0) rmSync(activePortFile, { force: true });
|
|
196
269
|
|
|
197
270
|
const defaultArgs = [
|
|
198
271
|
`--remote-debugging-port=${port}`,
|
|
@@ -264,10 +337,21 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
264
337
|
});
|
|
265
338
|
|
|
266
339
|
child.unref();
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
340
|
+
const track = (boundPort: number) => {
|
|
341
|
+
if (!child.pid) return;
|
|
342
|
+
launchedPids.set(boundPort, child.pid);
|
|
343
|
+
launchedUserDataDirs.set(boundPort, userDataDir);
|
|
344
|
+
};
|
|
345
|
+
// Tracked only once Chrome is CONFIRMED up on `boundPort`, not right after
|
|
346
|
+
// spawn(). For port !== 0 this used to track(port) unconditionally the
|
|
347
|
+
// moment the child was spawned, before knowing whether it would actually
|
|
348
|
+
// win the port — when a racing launchBrowser() call retries a candidate
|
|
349
|
+
// that a concurrent process also spawned Chrome on (see the retry loop in
|
|
350
|
+
// launchBrowser), BOTH child processes called track() for the SAME
|
|
351
|
+
// candidate, and whichever call's spawn happened to run last clobbered the
|
|
352
|
+
// map entry with its own pid — including the LOSING, about-to-exit
|
|
353
|
+
// process's pid overwriting the real winner's, corrupting the very map
|
|
354
|
+
// closeBrowser()'s kill fallback depends on.
|
|
271
355
|
|
|
272
356
|
const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
|
|
273
357
|
const deadline = Date.now() + launchTimeout;
|
|
@@ -275,17 +359,34 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
275
359
|
if (earlyFailure) throw earlyFailure;
|
|
276
360
|
await sleep(POLL_INTERVAL);
|
|
277
361
|
if (earlyFailure) throw earlyFailure;
|
|
278
|
-
|
|
279
|
-
|
|
362
|
+
const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
|
|
363
|
+
if (boundPort !== null && (await isPortOpen(boundPort))) {
|
|
364
|
+
if (await isChromeIdentity(boundPort)) {
|
|
365
|
+
track(boundPort);
|
|
366
|
+
return boundPort;
|
|
367
|
+
}
|
|
280
368
|
throw new Error(
|
|
281
|
-
`Port ${
|
|
282
|
-
`Refusing to attach — pass a different port or free port ${
|
|
369
|
+
`Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
|
|
370
|
+
`Refusing to attach — pass a different port or free port ${boundPort}.`,
|
|
283
371
|
);
|
|
284
372
|
}
|
|
285
373
|
}
|
|
286
374
|
|
|
287
375
|
if (earlyFailure) throw earlyFailure;
|
|
288
376
|
|
|
377
|
+
if (port === 0) {
|
|
378
|
+
// Not tracked under any port yet, so no closeBrowser() could ever reach
|
|
379
|
+
// it — stop it here rather than leave it running.
|
|
380
|
+
try {
|
|
381
|
+
if (child.pid) process.kill(child.pid, 'SIGKILL');
|
|
382
|
+
} catch {
|
|
383
|
+
/* already gone */
|
|
384
|
+
}
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
289
390
|
// Timed out waiting for our Chrome to come up on the port. Distinguish
|
|
290
391
|
// "nothing is listening" (real launch failure) from "something non-CDP is
|
|
291
392
|
// squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
|
|
@@ -394,13 +495,13 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
394
495
|
// Fresh process (each CLI invocation is its own node process — see
|
|
395
496
|
// module header) — launchedPids is per-process and empty here even
|
|
396
497
|
// though a prior `open` in a DIFFERENT process launched this Chrome.
|
|
397
|
-
// Fall back to the PID that process persisted via
|
|
498
|
+
// Fall back to the PID that process persisted via saveSessionRecord().
|
|
398
499
|
try {
|
|
399
|
-
const persisted = await
|
|
400
|
-
if (persisted
|
|
500
|
+
const persisted = await loadSessionRecord(port);
|
|
501
|
+
if (persisted?.launched && persisted.pid) {
|
|
401
502
|
// Bound how far we trust a cross-process PID: if the Chrome we
|
|
402
503
|
// launched died outside this flow (OOM-killed, host slept, etc.)
|
|
403
|
-
// without
|
|
504
|
+
// without its session record ever being cleared, the OS can recycle
|
|
404
505
|
// that PID for an unrelated process. There is no cmdline/start-time
|
|
405
506
|
// check available portably, so a coarse age bound is the practical
|
|
406
507
|
// guard — a PID persisted more than a day ago is far more likely to
|
|
@@ -498,12 +599,21 @@ async function waitForProcessExit(pid: number): Promise<boolean> {
|
|
|
498
599
|
* ever used for closeBrowser's own age-bounded force-kill.
|
|
499
600
|
*
|
|
500
601
|
* Never throws: a failed reap must not stop a launch. Returns the reaped
|
|
501
|
-
* port, or null when there was nothing to reap.
|
|
602
|
+
* port, or null when there was nothing to reap. Sessions are per-port
|
|
603
|
+
* (#318), so every recorded one is considered, not just the newest.
|
|
502
604
|
*/
|
|
503
605
|
export async function reapIdleLaunchedBrowser(): Promise<number | null> {
|
|
606
|
+
let reaped: number | null = null;
|
|
607
|
+
for (const session of await listSessionRecords().catch(() => [])) {
|
|
608
|
+
const port = await reapSession(session);
|
|
609
|
+
reaped ??= port;
|
|
610
|
+
}
|
|
611
|
+
return reaped;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function reapSession(session: SessionRecord): Promise<number | null> {
|
|
504
615
|
try {
|
|
505
|
-
|
|
506
|
-
if (!session?.launched || session.savedAt === undefined) return null;
|
|
616
|
+
if (!session.launched || session.savedAt === undefined) return null;
|
|
507
617
|
if (Date.now() - session.savedAt < IDLE_REAP_AFTER_MS) return null;
|
|
508
618
|
|
|
509
619
|
const wsUrl = await fetchBrowserWebSocketUrl(session.port);
|
|
@@ -540,7 +650,7 @@ export async function reapIdleLaunchedBrowser(): Promise<number | null> {
|
|
|
540
650
|
}
|
|
541
651
|
launchedPids.delete(session.port);
|
|
542
652
|
launchedUserDataDirs.delete(session.port);
|
|
543
|
-
await
|
|
653
|
+
await removeSessionRecord(session.port);
|
|
544
654
|
return session.port;
|
|
545
655
|
} catch {
|
|
546
656
|
return null;
|
package/src/browser/ref-cache.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AX-tree ref cache
|
|
2
|
+
* Per-session CLI state: the browse session index, and the AX-tree ref cache
|
|
3
|
+
* each session owns.
|
|
4
|
+
*
|
|
5
|
+
* Both are keyed by CDP port and scoped to the working directory: one file
|
|
6
|
+
* per browse session under `sessions/`, one ref cache per session beside it.
|
|
7
|
+
* A single shared `active-port.json` / `ax-snapshot.json` pair used to stand
|
|
8
|
+
* for "the" session, which is why two concurrent `open` invocations in one
|
|
9
|
+
* directory overwrote each other's handle (#318).
|
|
3
10
|
*
|
|
4
11
|
* `snapshot` and `find` are separate CLI invocations — each starts a fresh
|
|
5
12
|
* node process, so the in-memory ElementRef Map built by captureSnapshot()
|
|
@@ -10,13 +17,19 @@
|
|
|
10
17
|
* The full AX-tree text/dump is intentionally NOT persisted — only the
|
|
11
18
|
* per-ref index, to keep the file small.
|
|
12
19
|
*/
|
|
13
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
14
21
|
import { join } from 'node:path';
|
|
15
22
|
import type { ElementRef } from './types.js';
|
|
16
23
|
|
|
17
24
|
const CACHE_DIR = join(process.cwd(), '.monomind', 'monobrowse');
|
|
18
|
-
const
|
|
19
|
-
const
|
|
25
|
+
const SESSIONS_DIR = join(CACHE_DIR, 'sessions');
|
|
26
|
+
const refCacheFile = (port: number) => join(CACHE_DIR, `ax-snapshot-${port}.json`);
|
|
27
|
+
const sessionFile = (port: number) => join(SESSIONS_DIR, `${port}.json`);
|
|
28
|
+
// The pre-#318 layout: one session per directory, plus its one ref cache.
|
|
29
|
+
// Still read (see loadLegacySessionRecord) so a session opened by an older
|
|
30
|
+
// CLI is adopted rather than orphaned when monobrowse is upgraded under it.
|
|
31
|
+
const LEGACY_SESSION_FILE = join(CACHE_DIR, 'active-port.json');
|
|
32
|
+
const LEGACY_REF_CACHE_FILE = join(CACHE_DIR, 'ax-snapshot.json');
|
|
20
33
|
|
|
21
34
|
/** Snapshot older than this is flagged as possibly stale (page may have changed). */
|
|
22
35
|
export const REF_CACHE_STALE_MS = 30_000;
|
|
@@ -36,8 +49,11 @@ export interface RefCacheEntry {
|
|
|
36
49
|
stale: boolean;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
/** Persist the current ref index for a target so a later CLI process can read
|
|
52
|
+
/** Persist the current ref index for a target so a later CLI process can read
|
|
53
|
+
* it back. Scoped to the session's port so two sessions open in the same
|
|
54
|
+
* directory keep their own refs instead of clobbering one file. */
|
|
40
55
|
export async function saveRefCache(
|
|
56
|
+
port: number,
|
|
41
57
|
targetId: string,
|
|
42
58
|
url: string,
|
|
43
59
|
refs: Map<string, ElementRef>,
|
|
@@ -50,7 +66,7 @@ export async function saveRefCache(
|
|
|
50
66
|
savedAt: Date.now(),
|
|
51
67
|
refs: [...refs.values()],
|
|
52
68
|
};
|
|
53
|
-
await writeFile(
|
|
69
|
+
await writeFile(refCacheFile(port), JSON.stringify(data));
|
|
54
70
|
} catch {
|
|
55
71
|
// Best-effort — persistence failure just means cross-process rehydration
|
|
56
72
|
// won't work; in-process (same run) behavior is unaffected.
|
|
@@ -62,9 +78,9 @@ export async function saveRefCache(
|
|
|
62
78
|
* different tab/target is ignored). Returns null if no usable cache exists —
|
|
63
79
|
* callers fall back to an empty in-memory Map, matching prior behavior.
|
|
64
80
|
*/
|
|
65
|
-
export async function loadRefCache(targetId: string): Promise<RefCacheEntry | null> {
|
|
81
|
+
export async function loadRefCache(port: number, targetId: string): Promise<RefCacheEntry | null> {
|
|
66
82
|
try {
|
|
67
|
-
const raw = await readFile(
|
|
83
|
+
const raw = await readFile(refCacheFile(port), 'utf8');
|
|
68
84
|
const data: RefCacheFile = JSON.parse(raw);
|
|
69
85
|
if (!data || !Array.isArray(data.refs) || data.targetId !== targetId) return null;
|
|
70
86
|
|
|
@@ -82,104 +98,160 @@ export async function loadRefCache(targetId: string): Promise<RefCacheEntry | nu
|
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/** Drop the persisted ref index — call whenever refs are invalidated (navigation, tab switch, close). */
|
|
85
|
-
export async function clearRefCache(): Promise<void> {
|
|
101
|
+
export async function clearRefCache(port: number): Promise<void> {
|
|
86
102
|
try {
|
|
87
|
-
await rm(
|
|
103
|
+
await rm(refCacheFile(port), { force: true });
|
|
88
104
|
} catch {
|
|
89
105
|
// Nothing to clear, or not writable — non-fatal.
|
|
90
106
|
}
|
|
91
107
|
}
|
|
92
108
|
|
|
93
109
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* falling back to the hardcoded default port and launching/attaching to a
|
|
98
|
-
* second, unrelated Chrome instance.
|
|
110
|
+
* A browse session: one Chrome instance, identified by the CDP port it
|
|
111
|
+
* listens on, recorded so a LATER CLI invocation (each command is its own
|
|
112
|
+
* node process — see module header) can find it again.
|
|
99
113
|
*/
|
|
100
|
-
export
|
|
114
|
+
export interface SessionRecord {
|
|
115
|
+
port: number;
|
|
116
|
+
/** true = monobrowse launched this Chrome (safe to Browser.close later);
|
|
117
|
+
* false = we attached to a browser someone else owns (never kill it). */
|
|
118
|
+
launched: boolean;
|
|
119
|
+
pid?: number;
|
|
120
|
+
userDataDir?: string;
|
|
121
|
+
savedAt?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record a browse session under its own port, so concurrent sessions in the
|
|
126
|
+
* same working directory coexist instead of overwriting one shared handle.
|
|
127
|
+
*
|
|
128
|
+
* `pid`/`userDataDir` are persisted so a later, fresh CLI process can still
|
|
129
|
+
* find and kill the Chrome this one launched. Without them, closeBrowser()'s
|
|
130
|
+
* PID-kill fallback can never fire outside the launching process, since the
|
|
131
|
+
* in-memory launchedPids Map (browser.ts) is empty there.
|
|
132
|
+
*/
|
|
133
|
+
export async function saveSessionRecord(
|
|
101
134
|
port: number,
|
|
102
|
-
opts?: { launched?: boolean; pid?: number; userDataDir?: string },
|
|
135
|
+
opts?: { launched?: boolean; pid?: number; userDataDir?: string; savedAt?: number },
|
|
103
136
|
): Promise<void> {
|
|
104
137
|
try {
|
|
105
|
-
await mkdir(
|
|
106
|
-
// `launched`
|
|
107
|
-
//
|
|
108
|
-
// else owns (must never be killed). Absent (old files) reads as launched
|
|
109
|
-
// — matches pre-flag behavior where every persisted port came from open.
|
|
138
|
+
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
139
|
+
// `launched` absent (older files) reads as launched — matches the
|
|
140
|
+
// pre-flag behavior where every persisted port came from `open`.
|
|
110
141
|
//
|
|
111
|
-
// `
|
|
112
|
-
//
|
|
113
|
-
// and
|
|
114
|
-
// PID-kill fallback can never fire outside the launching process, since
|
|
115
|
-
// the in-memory launchedPids Map (browser.ts) is empty there.
|
|
142
|
+
// `savedAt` is passed only when re-recording a session that already
|
|
143
|
+
// existed (adopting a legacy record), so its real age — which is what
|
|
144
|
+
// the idle reaper and the newest-first ordering read — is preserved.
|
|
116
145
|
await writeFile(
|
|
117
|
-
|
|
146
|
+
sessionFile(port),
|
|
118
147
|
JSON.stringify({
|
|
119
148
|
port,
|
|
120
149
|
launched: opts?.launched !== false,
|
|
121
150
|
pid: opts?.pid,
|
|
122
151
|
userDataDir: opts?.userDataDir,
|
|
123
|
-
savedAt: Date.now(),
|
|
152
|
+
savedAt: opts?.savedAt ?? Date.now(),
|
|
124
153
|
}),
|
|
125
154
|
);
|
|
126
155
|
} catch {
|
|
127
|
-
// Best-effort — persistence failure
|
|
128
|
-
//
|
|
156
|
+
// Best-effort — persistence failure costs the next process the ability to
|
|
157
|
+
// find this session, not this command's own correctness.
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
|
|
132
|
-
/** Forget
|
|
133
|
-
*
|
|
134
|
-
export async function
|
|
161
|
+
/** Forget one session (it was closed, or its browser is gone) so later
|
|
162
|
+
* invocations don't chase a dead endpoint. */
|
|
163
|
+
export async function removeSessionRecord(port: number): Promise<void> {
|
|
135
164
|
try {
|
|
136
|
-
await rm(
|
|
165
|
+
await rm(sessionFile(port), { force: true });
|
|
137
166
|
} catch {
|
|
138
|
-
// Best-effort — a stale
|
|
167
|
+
// Best-effort — a stale record only costs one failed probe later.
|
|
139
168
|
}
|
|
140
169
|
}
|
|
141
170
|
|
|
142
|
-
/**
|
|
143
|
-
export async function
|
|
144
|
-
|
|
171
|
+
/** The session recorded for `port`, or null if there is none / it's unreadable. */
|
|
172
|
+
export async function loadSessionRecord(port: number): Promise<SessionRecord | null> {
|
|
173
|
+
try {
|
|
174
|
+
return parseSessionRecord(await readFile(sessionFile(port), 'utf8'));
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
145
178
|
}
|
|
146
179
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
180
|
+
/**
|
|
181
|
+
* The session a pre-#318 CLI left behind in this directory, if any.
|
|
182
|
+
*
|
|
183
|
+
* That layout held exactly one session in `active-port.json`. Upgrading
|
|
184
|
+
* monobrowse while such a session was open would otherwise strand its
|
|
185
|
+
* Chrome: nothing reads that file any more, so the new CLI could neither
|
|
186
|
+
* find nor close it. It is read here as one more candidate session and then
|
|
187
|
+
* either adopted (rewritten as `sessions/<port>.json`) or dropped — see
|
|
188
|
+
* adoptLegacySession() in cli/session.ts. Nothing ever writes it again.
|
|
189
|
+
*/
|
|
190
|
+
export async function loadLegacySessionRecord(): Promise<SessionRecord | null> {
|
|
155
191
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
port?: unknown;
|
|
159
|
-
launched?: unknown;
|
|
160
|
-
pid?: unknown;
|
|
161
|
-
userDataDir?: unknown;
|
|
162
|
-
savedAt?: unknown;
|
|
163
|
-
};
|
|
164
|
-
if (
|
|
165
|
-
typeof data.port === 'number' &&
|
|
166
|
-
Number.isInteger(data.port) &&
|
|
167
|
-
data.port >= 1024 &&
|
|
168
|
-
data.port <= 65535
|
|
169
|
-
) {
|
|
170
|
-
return {
|
|
171
|
-
port: data.port,
|
|
172
|
-
launched: data.launched !== false,
|
|
173
|
-
pid:
|
|
174
|
-
typeof data.pid === 'number' && Number.isInteger(data.pid) && data.pid > 0
|
|
175
|
-
? data.pid
|
|
176
|
-
: undefined,
|
|
177
|
-
userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
|
|
178
|
-
savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
192
|
+
return parseSessionRecord(await readFile(LEGACY_SESSION_FILE, 'utf8'));
|
|
193
|
+
} catch {
|
|
181
194
|
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drop the pre-#318 files, once their session has been adopted or found
|
|
199
|
+
* dead. The single ref cache goes with them: it belonged to that one
|
|
200
|
+
* session, and nothing reads it any more either. */
|
|
201
|
+
export async function removeLegacySessionRecord(): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
await rm(LEGACY_SESSION_FILE, { force: true });
|
|
204
|
+
await rm(LEGACY_REF_CACHE_FILE, { force: true });
|
|
182
205
|
} catch {
|
|
206
|
+
// Best-effort — a leftover file is inert; it is only ever read again.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Every recorded session in this working directory, newest first. */
|
|
211
|
+
export async function listSessionRecords(): Promise<SessionRecord[]> {
|
|
212
|
+
let entries: string[];
|
|
213
|
+
try {
|
|
214
|
+
entries = await readdir(SESSIONS_DIR);
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const records: SessionRecord[] = [];
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (!entry.endsWith('.json')) continue;
|
|
221
|
+
try {
|
|
222
|
+
const record = parseSessionRecord(await readFile(join(SESSIONS_DIR, entry), 'utf8'));
|
|
223
|
+
if (record) records.push(record);
|
|
224
|
+
} catch {
|
|
225
|
+
// Unreadable file — skip it rather than failing the whole listing.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return records.sort((a, b) => (b.savedAt ?? 0) - (a.savedAt ?? 0));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseSessionRecord(raw: string): SessionRecord | null {
|
|
232
|
+
const data = JSON.parse(raw) as {
|
|
233
|
+
port?: unknown;
|
|
234
|
+
launched?: unknown;
|
|
235
|
+
pid?: unknown;
|
|
236
|
+
userDataDir?: unknown;
|
|
237
|
+
savedAt?: unknown;
|
|
238
|
+
};
|
|
239
|
+
if (
|
|
240
|
+
typeof data.port !== 'number' ||
|
|
241
|
+
!Number.isInteger(data.port) ||
|
|
242
|
+
data.port < 1024 ||
|
|
243
|
+
data.port > 65535
|
|
244
|
+
) {
|
|
183
245
|
return null;
|
|
184
246
|
}
|
|
247
|
+
return {
|
|
248
|
+
port: data.port,
|
|
249
|
+
launched: data.launched !== false,
|
|
250
|
+
pid:
|
|
251
|
+
typeof data.pid === 'number' && Number.isInteger(data.pid) && data.pid > 0
|
|
252
|
+
? data.pid
|
|
253
|
+
: undefined,
|
|
254
|
+
userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
|
|
255
|
+
savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
|
|
256
|
+
};
|
|
185
257
|
}
|
package/src/browser/types.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export interface BrowserConfig {
|
|
2
|
+
/** CDP port. 0 lets Chrome bind a free one (requires `userDataDir`); the
|
|
3
|
+
* port it bound is launchBrowser's return value. */
|
|
2
4
|
port?: number;
|
|
3
5
|
/** Fail fast if `port` is occupied by anything other than an attachable
|
|
4
6
|
* Chrome, instead of scanning forward to the next free port. Default false
|
|
@@ -94,7 +94,7 @@ export const stateCommand: Command = {
|
|
|
94
94
|
await browser.clearLocalStorage(c, sid);
|
|
95
95
|
await browser.clearSessionStorage(c, sid);
|
|
96
96
|
session.refs = new Map();
|
|
97
|
-
await browser.clearRefCache();
|
|
97
|
+
await browser.clearRefCache(session.port);
|
|
98
98
|
output.printSuccess('Browser state cleared (cookies, localStorage, sessionStorage, refs)');
|
|
99
99
|
return { success: true };
|
|
100
100
|
}
|
|
@@ -322,7 +322,7 @@ export const navigateCommand: Command = {
|
|
|
322
322
|
|
|
323
323
|
// Refs captured before this navigation may now point at different content.
|
|
324
324
|
session.refs = new Map();
|
|
325
|
-
await browser.clearRefCache();
|
|
325
|
+
await browser.clearRefCache(session.port);
|
|
326
326
|
|
|
327
327
|
output.printSuccess(`Navigated: ${direction}`);
|
|
328
328
|
return { success: true };
|
|
@@ -435,7 +435,7 @@ export const pushstateCommand: Command = {
|
|
|
435
435
|
// SPA navigation changes what's on the page without a full page load —
|
|
436
436
|
// refs captured before this pushState call may now resolve to different content.
|
|
437
437
|
session.refs = new Map();
|
|
438
|
-
await browser.clearRefCache();
|
|
438
|
+
await browser.clearRefCache(session.port);
|
|
439
439
|
output.printSuccess(`pushState: ${url}`);
|
|
440
440
|
return { success: true };
|
|
441
441
|
},
|
package/src/cli/commands-page.ts
CHANGED
|
@@ -71,7 +71,7 @@ export const snapshotCommand: Command = {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
session.refs = result.refs;
|
|
74
|
-
await browser.saveRefCache(session.targetId, result.url, session.refs);
|
|
74
|
+
await browser.saveRefCache(session.port, session.targetId, result.url, session.refs);
|
|
75
75
|
|
|
76
76
|
const applyOutputLimits = (text: string): string => {
|
|
77
77
|
const maxOutput = ctx.flags['max-output'] as number | undefined;
|
|
@@ -375,7 +375,7 @@ export const diffCommand: Command = {
|
|
|
375
375
|
});
|
|
376
376
|
|
|
377
377
|
session.refs = snap2.refs;
|
|
378
|
-
await browser.saveRefCache(session.targetId, snap2.url, session.refs);
|
|
378
|
+
await browser.saveRefCache(session.port, session.targetId, snap2.url, session.refs);
|
|
379
379
|
|
|
380
380
|
// Text diff
|
|
381
381
|
const lines1 = snap1.text.split('\n');
|
|
@@ -210,8 +210,8 @@ export const reportCommand: Command = {
|
|
|
210
210
|
session.sessionId = '';
|
|
211
211
|
session.targetId = '';
|
|
212
212
|
session.refs = new Map();
|
|
213
|
-
await browser.
|
|
214
|
-
await browser.clearRefCache();
|
|
213
|
+
await browser.removeSessionRecord(session.port);
|
|
214
|
+
await browser.clearRefCache(session.port);
|
|
215
215
|
};
|
|
216
216
|
|
|
217
217
|
let result:
|