@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.
Files changed (68) hide show
  1. package/dist/src/__tests__/browse-concurrency.test.d.ts +15 -0
  2. package/dist/src/__tests__/browse-concurrency.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/browse-concurrency.test.js +198 -0
  4. package/dist/src/__tests__/browse-concurrency.test.js.map +1 -0
  5. package/dist/src/__tests__/browser-launch.test.js +132 -1
  6. package/dist/src/__tests__/browser-launch.test.js.map +1 -1
  7. package/dist/src/__tests__/close-browser.test.js +2 -2
  8. package/dist/src/__tests__/close-browser.test.js.map +1 -1
  9. package/dist/src/__tests__/frame-command.test.js +12 -2
  10. package/dist/src/__tests__/frame-command.test.js.map +1 -1
  11. package/dist/src/__tests__/reap-idle-browser.test.js +13 -13
  12. package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -1
  13. package/dist/src/__tests__/ref-cache.test.js +96 -58
  14. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  15. package/dist/src/__tests__/report-exit-code.test.js +1 -1
  16. package/dist/src/__tests__/session-discovery.test.d.ts +13 -0
  17. package/dist/src/__tests__/session-discovery.test.d.ts.map +1 -0
  18. package/dist/src/__tests__/session-discovery.test.js +218 -0
  19. package/dist/src/__tests__/session-discovery.test.js.map +1 -0
  20. package/dist/src/browser/browser.d.ts +2 -1
  21. package/dist/src/browser/browser.d.ts.map +1 -1
  22. package/dist/src/browser/browser.js +145 -42
  23. package/dist/src/browser/browser.js.map +1 -1
  24. package/dist/src/browser/ref-cache.d.ts +50 -22
  25. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  26. package/dist/src/browser/ref-cache.js +122 -57
  27. package/dist/src/browser/ref-cache.js.map +1 -1
  28. package/dist/src/browser/types.d.ts +2 -0
  29. package/dist/src/browser/types.d.ts.map +1 -1
  30. package/dist/src/browser/types.js.map +1 -1
  31. package/dist/src/cli/commands-context.js +1 -1
  32. package/dist/src/cli/commands-context.js.map +1 -1
  33. package/dist/src/cli/commands-navigate.js +2 -2
  34. package/dist/src/cli/commands-navigate.js.map +1 -1
  35. package/dist/src/cli/commands-page.js +2 -2
  36. package/dist/src/cli/commands-page.js.map +1 -1
  37. package/dist/src/cli/commands-report.js +2 -2
  38. package/dist/src/cli/commands-report.js.map +1 -1
  39. package/dist/src/cli/commands-session.d.ts.map +1 -1
  40. package/dist/src/cli/commands-session.js +70 -56
  41. package/dist/src/cli/commands-session.js.map +1 -1
  42. package/dist/src/cli/commands.d.ts.map +1 -1
  43. package/dist/src/cli/commands.js +102 -66
  44. package/dist/src/cli/commands.js.map +1 -1
  45. package/dist/src/cli/session.d.ts +66 -2
  46. package/dist/src/cli/session.d.ts.map +1 -1
  47. package/dist/src/cli/session.js +190 -33
  48. package/dist/src/cli/session.js.map +1 -1
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +1 -1
  51. package/src/__tests__/browse-concurrency.test.ts +233 -0
  52. package/src/__tests__/browser-launch.test.ts +142 -1
  53. package/src/__tests__/close-browser.test.ts +2 -2
  54. package/src/__tests__/frame-command.test.ts +12 -2
  55. package/src/__tests__/reap-idle-browser.test.ts +15 -14
  56. package/src/__tests__/ref-cache.test.ts +112 -59
  57. package/src/__tests__/report-exit-code.test.ts +1 -1
  58. package/src/__tests__/session-discovery.test.ts +259 -0
  59. package/src/browser/browser.ts +149 -39
  60. package/src/browser/ref-cache.ts +144 -72
  61. package/src/browser/types.ts +2 -0
  62. package/src/cli/commands-context.ts +1 -1
  63. package/src/cli/commands-navigate.ts +2 -2
  64. package/src/cli/commands-page.ts +2 -2
  65. package/src/cli/commands-report.ts +2 -2
  66. package/src/cli/commands-session.ts +74 -55
  67. package/src/cli/commands.ts +106 -66
  68. package/src/cli/session.ts +208 -33
@@ -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 { clearActivePort, loadActivePortInfo } from './ref-cache.js';
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
- // Validate port is in a safe range for localhost CDP debugging
132
- if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
133
- throw new Error(`Invalid port: ${rawPort}. Must be an integer between 1024 and 65535.`);
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
- // Attach-if-already-Chrome only applies to the EXACT requested port — the
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 (!(await isTcpPortOpen(candidate))) return launchOnFreePort(config, candidate);
183
- // Occupied (by anything — not just non-Chrome, per the note above) —
184
- // try the next candidate instead of failing outright, same as a normal
185
- // EADDRINUSE retry would.
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
- const userDataDir = config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}`);
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
- if (child.pid) {
268
- launchedPids.set(port, child.pid);
269
- launchedUserDataDirs.set(port, userDataDir);
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
- if (await isPortOpen(port)) {
279
- if (await isChromeIdentity(port)) return port;
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 ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
282
- `Refusing to attach — pass a different port or free port ${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 saveActivePort().
498
+ // Fall back to the PID that process persisted via saveSessionRecord().
398
499
  try {
399
- const persisted = await loadActivePortInfo();
400
- if (persisted && persisted.port === port && persisted.launched && persisted.pid) {
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 active-port.json ever being cleared, the OS can recycle
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
- const session = await loadActivePortInfo();
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 clearActivePort();
653
+ await removeSessionRecord(session.port);
544
654
  return session.port;
545
655
  } catch {
546
656
  return null;
@@ -1,5 +1,12 @@
1
1
  /**
2
- * AX-tree ref cache persistence.
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 CACHE_FILE = join(CACHE_DIR, 'ax-snapshot.json');
19
- const PORT_FILE = join(CACHE_DIR, 'active-port.json');
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 it back. */
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(CACHE_FILE, JSON.stringify(data));
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(CACHE_FILE, 'utf8');
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(CACHE_FILE, { force: true });
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
- * Persist the "active" CDP port so a later CLI invocation (each command is a
95
- * fresh process — see module header) can find the browser a prior `open
96
- * --port N` attached to, instead of every subsequent command silently
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 async function saveActivePort(
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(CACHE_DIR, { recursive: true });
106
- // `launched` records provenance: true = monobrowse spawned this Chrome
107
- // (safe to Browser.close later); false = attached to a browser someone
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
- // `pid`/`userDataDir` are persisted so a LATER, fresh CLI process (each
112
- // command is its own node process — see module header) can still find
113
- // and kill the Chrome this one launched. Without this, closeBrowser()'s
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
- PORT_FILE,
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 just means the next process falls
128
- // back to the hardcoded default port, matching prior behavior.
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 the persisted active port (session closed) so later invocations
133
- * fall back to the default instead of chasing a dead endpoint. */
134
- export async function clearActivePort(): Promise<void> {
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(PORT_FILE, { force: true });
165
+ await rm(sessionFile(port), { force: true });
137
166
  } catch {
138
- // Best-effort — a stale port file only costs one failed probe later.
167
+ // Best-effort — a stale record only costs one failed probe later.
139
168
  }
140
169
  }
141
170
 
142
- /** Load the persisted active port, or null if none was ever saved / it's unreadable. */
143
- export async function loadActivePort(): Promise<number | null> {
144
- return (await loadActivePortInfo())?.port ?? null;
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
- /** Load the persisted active port with its provenance flag, PID, user-data-dir, and save timestamp. */
148
- export async function loadActivePortInfo(): Promise<{
149
- port: number;
150
- launched: boolean;
151
- pid?: number;
152
- userDataDir?: string;
153
- savedAt?: number;
154
- } | null> {
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
- const raw = await readFile(PORT_FILE, 'utf8');
157
- const data = JSON.parse(raw) as {
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
  }
@@ -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
  },
@@ -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.clearActivePort();
214
- await browser.clearRefCache();
213
+ await browser.removeSessionRecord(session.port);
214
+ await browser.clearRefCache(session.port);
215
215
  };
216
216
 
217
217
  let result: