browser-debugger-cli 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.claude/skills/bdg/SKILL.md +2 -1
  2. package/dist/cdp/methodTarget.d.ts +92 -0
  3. package/dist/cdp/methodTarget.js +159 -0
  4. package/dist/cdp/protocol.d.ts +16 -1
  5. package/dist/cdp/protocol.js +21 -0
  6. package/dist/cdp/schema.d.ts +55 -1
  7. package/dist/cdp/schema.js +134 -25
  8. package/dist/cdp/types.d.ts +3 -1
  9. package/dist/commands/cdp.d.ts +38 -1
  10. package/dist/commands/cdp.js +200 -133
  11. package/dist/commands/cleanup.js +18 -4
  12. package/dist/commands/dom/formInteraction.js +8 -4
  13. package/dist/commands/dom/helpers/index.d.ts +4 -4
  14. package/dist/commands/dom/helpers/index.js +3 -3
  15. package/dist/commands/dom/helpers/query.d.ts +2 -2
  16. package/dist/commands/dom/helpers/query.js +2 -2
  17. package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
  18. package/dist/commands/dom/helpers/screenshot.js +50 -668
  19. package/dist/commands/dom/screenshot.js +56 -36
  20. package/dist/commands/optionBehaviors.js +18 -8
  21. package/dist/commands/shared/CommandRunner.d.ts +5 -0
  22. package/dist/commands/shared/CommandRunner.js +18 -3
  23. package/dist/commands/shared/interrupt.d.ts +40 -0
  24. package/dist/commands/shared/interrupt.js +73 -0
  25. package/dist/commands/shared/optionTypes.d.ts +2 -0
  26. package/dist/commands/shared/startHelpers.d.ts +26 -3
  27. package/dist/commands/shared/startHelpers.js +145 -23
  28. package/dist/commands/types.d.ts +5 -0
  29. package/dist/connection/cdp.js +1 -16
  30. package/dist/connection/chromeIdentity.d.ts +24 -5
  31. package/dist/connection/chromeIdentity.js +53 -22
  32. package/dist/connection/launcher.d.ts +34 -1
  33. package/dist/connection/launcher.js +98 -10
  34. package/dist/connection/typed-cdp.d.ts +3 -2
  35. package/dist/constants.d.ts +1 -1
  36. package/dist/constants.js +1 -1
  37. package/dist/daemon/SessionController.d.ts +10 -5
  38. package/dist/daemon/SessionController.js +15 -8
  39. package/dist/daemon/ipcServer.js +1 -1
  40. package/dist/daemon/launcher.d.ts +5 -0
  41. package/dist/daemon/launcher.js +8 -1
  42. package/dist/daemon/session/Session.d.ts +5 -1
  43. package/dist/daemon/session/Session.js +9 -8
  44. package/dist/daemon/session/TelemetryStore.d.ts +5 -0
  45. package/dist/daemon/session/TelemetryStore.js +4 -0
  46. package/dist/daemon/session/captureGate.d.ts +59 -0
  47. package/dist/daemon/session/captureGate.js +96 -0
  48. package/dist/daemon/session/chromeConnection.d.ts +16 -1
  49. package/dist/daemon/session/chromeConnection.js +34 -4
  50. package/dist/daemon/session/collectors.d.ts +15 -0
  51. package/dist/daemon/session/collectors.js +39 -2
  52. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  53. package/dist/daemon/session/commandRegistry.js +46 -11
  54. package/dist/daemon/session/downloads.d.ts +32 -0
  55. package/dist/daemon/session/downloads.js +96 -0
  56. package/dist/daemon/session/interactions.d.ts +3 -2
  57. package/dist/daemon/session/interactions.js +7 -2
  58. package/dist/daemon/session/plugins.js +6 -0
  59. package/dist/daemon.js +12843 -11482
  60. package/dist/errors/CommandError.d.ts +2 -0
  61. package/dist/errors/issues.d.ts +1 -1
  62. package/dist/errors/messages.d.ts +58 -0
  63. package/dist/errors/messages.js +112 -0
  64. package/dist/index.js +999 -1020
  65. package/dist/ipc/client.d.ts +14 -1
  66. package/dist/ipc/client.js +21 -4
  67. package/dist/ipc/protocol/commands.d.ts +32 -2
  68. package/dist/ipc/protocol/commands.js +1 -0
  69. package/dist/ipc/protocol/domTypes.d.ts +24 -1
  70. package/dist/ipc/session/queries.d.ts +3 -0
  71. package/dist/ipc/session/types.d.ts +5 -0
  72. package/dist/ipc/transport/IPCError.d.ts +9 -0
  73. package/dist/ipc/transport/IPCError.js +12 -0
  74. package/dist/ipc/transport/errors.d.ts +2 -1
  75. package/dist/ipc/transport/errors.js +4 -1
  76. package/dist/ipc/transport/index.d.ts +4 -2
  77. package/dist/ipc/transport/index.js +13 -3
  78. package/dist/runtime/dom/actionEffects.d.ts +48 -9
  79. package/dist/runtime/dom/actionEffects.js +269 -34
  80. package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
  81. package/dist/runtime/dom/actionEffectsScripts.js +101 -2
  82. package/dist/runtime/dom/captureArea.d.ts +35 -0
  83. package/dist/runtime/dom/captureArea.js +203 -0
  84. package/dist/runtime/dom/elementInfo.d.ts +10 -8
  85. package/dist/runtime/dom/elementInfo.js +8 -6
  86. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  87. package/dist/runtime/page/bdgWorld.d.ts +9 -0
  88. package/dist/runtime/page/bdgWorld.js +11 -0
  89. package/dist/runtime/page/captureEmulation.d.ts +119 -0
  90. package/dist/runtime/page/captureEmulation.js +189 -0
  91. package/dist/runtime/page/captureScroll.d.ts +24 -0
  92. package/dist/runtime/page/captureScroll.js +124 -0
  93. package/dist/runtime/page/screenshot.d.ts +41 -0
  94. package/dist/runtime/page/screenshot.js +394 -0
  95. package/dist/session/paths.d.ts +14 -0
  96. package/dist/session/paths.js +25 -0
  97. package/dist/telemetry/downloads.d.ts +127 -0
  98. package/dist/telemetry/downloads.js +265 -0
  99. package/dist/telemetry/har/builder.js +22 -7
  100. package/dist/telemetry/har/sanitize.d.ts +7 -3
  101. package/dist/telemetry/har/sanitize.js +52 -6
  102. package/dist/telemetry/har/sanitizeBody.d.ts +47 -7
  103. package/dist/telemetry/har/sanitizeBody.js +429 -56
  104. package/dist/telemetry/har/types.d.ts +2 -0
  105. package/dist/telemetry/network.d.ts +4 -4
  106. package/dist/telemetry/network.js +38 -4
  107. package/dist/telemetry/networkRetention.d.ts +35 -14
  108. package/dist/telemetry/networkRetention.js +62 -26
  109. package/dist/types.d.ts +9 -14
  110. package/dist/ui/OutputBuilder.d.ts +3 -2
  111. package/dist/ui/OutputBuilder.js +4 -3
  112. package/dist/ui/formatters/cdp.d.ts +32 -9
  113. package/dist/ui/formatters/cdp.js +77 -6
  114. package/dist/ui/formatters/details.js +7 -15
  115. package/dist/ui/formatters/preview.d.ts +2 -0
  116. package/dist/ui/formatters/preview.js +7 -1
  117. package/dist/ui/formatters/status.js +6 -1
  118. package/dist/ui/formatting.d.ts +7 -0
  119. package/dist/ui/formatting.js +13 -0
  120. package/dist/ui/logging/logger.d.ts +1 -1
  121. package/dist/ui/messages/chrome.d.ts +13 -0
  122. package/dist/ui/messages/chrome.js +26 -0
  123. package/dist/ui/messages/commands.d.ts +71 -3
  124. package/dist/ui/messages/commands.js +98 -3
  125. package/dist/ui/messages/networkMessages.d.ts +24 -5
  126. package/dist/ui/messages/networkMessages.js +31 -8
  127. package/dist/utils/async.d.ts +3 -2
  128. package/dist/utils/async.js +16 -3
  129. package/dist/utils/http.d.ts +11 -4
  130. package/dist/utils/http.js +5 -3
  131. package/package.json +18 -4
  132. /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
  133. /package/dist/{commands/dom → runtime/page}/screenshotResize.js +0 -0
@@ -5,11 +5,12 @@
5
5
  * Handles IPC communication with daemon to start browser sessions.
6
6
  */
7
7
  import { timeoutError } from './CommandRunner.js';
8
+ import { interruptExitCode, interruptSignal, } from './interrupt.js';
8
9
  import { landingPage } from './landingPage.js';
9
10
  import { DaemonError, SessionDirError } from '../../daemon/errors.js';
10
11
  import { launchDaemon } from '../../daemon/launcher.js';
11
12
  import { LAUNCHED_CHROME_DESCRIPTION, sessionAlreadyRunningError, alreadyRunningSuggestion, sessionTargetMismatchError, daemonNotRunningError, invalidResponseError, genericError, } from '../../errors/messages.js';
12
- import { startSession as sendStartSessionRequest } from '../../ipc/client.js';
13
+ import { startSession as sendStartSessionRequest, stopSession as sendStopSessionRequest, } from '../../ipc/client.js';
13
14
  import { IPCErrorCode, } from '../../ipc/index.js';
14
15
  import { IPCTimeoutError } from '../../ipc/transport/index.js';
15
16
  import { isConnectionError } from '../../ipc/utils/errors.js';
@@ -24,12 +25,14 @@ import { getExitCodeForIPCError } from '../../utils/errorMapping.js';
24
25
  import { getErrorMessage } from '../../utils/errors.js';
25
26
  import { EXIT_CODES } from '../../utils/exitCodes.js';
26
27
  import { filterDefined } from '../../utils/objects.js';
28
+ import { isProcessAlive } from '../../utils/process.js';
27
29
  const log = createLogger('bdg');
28
30
  /** How long a failed start waits for the daemons it spawned to exit */
29
31
  export const SPAWNED_DAEMON_EXIT_WAIT_MS = 3000;
30
32
  const DEFAULT_START_DEPS = {
31
33
  launch: launchDaemon,
32
34
  send: sendStartSessionRequest,
35
+ stop: sendStopSessionRequest,
33
36
  exitWaitMs: SPAWNED_DAEMON_EXIT_WAIT_MS,
34
37
  };
35
38
  /**
@@ -38,30 +41,55 @@ const DEFAULT_START_DEPS = {
38
41
  * Spawns the daemon if needed, sends `start_session_request`, then prints the
39
42
  * result as a JSON envelope (`--json`) or human-readable text, and exits.
40
43
  *
44
+ * Ctrl-C (or SIGTERM) cancels the start at any point until the outcome is
45
+ * printed: the connection is closed, so the daemon abandons the session (a
46
+ * daemon spawned but not yet asked is told to shut down, a session that has
47
+ * just started is stopped), and the command exits with 130 (143) once the
48
+ * daemon it spawned has exited (bounded, like a failed start), so a command
49
+ * run right after sees no session. A second signal exits at once.
50
+ *
51
+ * Race-free: {@link attemptStart} checks the interrupt last, synchronously,
52
+ * and only microtasks run between that check and the exit in
53
+ * {@link reportStartOutcome}; a signal handler runs as a macrotask, so a
54
+ * signal either lands before the check (the start is cancelled) or after the
55
+ * outcome has been printed.
56
+ *
41
57
  * @param url - Target URL to navigate to
42
58
  * @param options - Session configuration options
43
59
  * @param telemetry - Array of telemetry types to enable
44
60
  */
45
61
  export async function startSessionViaDaemon(url, options, telemetry) {
46
- process.once('SIGINT', () => reportStartOutcome(interruptedOutcome('SIGINT'), options));
47
- process.once('SIGTERM', () => reportStartOutcome(interruptedOutcome('SIGTERM'), options));
48
- reportStartOutcome(await attemptStart(url, options, telemetry), options);
62
+ const interrupt = new AbortController();
63
+ for (const signal of ['SIGINT', 'SIGTERM']) {
64
+ process.on(signal, () => {
65
+ if (interrupt.signal.aborted)
66
+ reportStartOutcome(interruptedOutcome(signal), options);
67
+ interrupt.abort(signal);
68
+ });
69
+ }
70
+ reportStartOutcome(await attemptStart(url, options, telemetry, DEFAULT_START_DEPS, interrupt.signal), options);
49
71
  }
50
72
  /**
51
73
  * Start a session, retrying while the previous session shuts down. After a
52
74
  * failure the daemon reported (or a dropped connection), it waits for every
53
75
  * daemon the attempts spawned to exit ({@link afterSpawnedDaemonExit}).
54
76
  *
77
+ * An interrupt cancels the start whenever it arrives: a start that succeeded
78
+ * meanwhile is stopped ({@link cancelStartedSession}), and a failure keeps its
79
+ * message but exits 130 (143), also when the signal came during the wait.
80
+ *
55
81
  * @param url - Target URL
56
82
  * @param options - Session options
57
83
  * @param telemetry - Telemetry types
58
84
  * @param deps - How to reach the daemon (tests replace it)
59
- * @returns Start outcome, ready to report
85
+ * @param interrupt - Aborted (reason: the signal) on Ctrl-C or SIGTERM: the
86
+ * start is cancelled and the daemon it spawned waited for like after a failure
87
+ * @returns Start outcome, ready to report (the interrupt is checked last)
60
88
  */
61
- export async function attemptStart(url, options, telemetry, deps = DEFAULT_START_DEPS) {
89
+ export async function attemptStart(url, options, telemetry, deps = DEFAULT_START_DEPS, interrupt) {
62
90
  const spawned = [];
63
91
  const attempt = async () => {
64
- const outcome = await requestSession(url, options, telemetry, deps);
92
+ const outcome = await requestSession(url, options, telemetry, deps, interrupt);
65
93
  if (!outcome.ok && outcome.spawned)
66
94
  spawned.push(outcome.spawned);
67
95
  return outcome;
@@ -69,10 +97,69 @@ export async function attemptStart(url, options, telemetry, deps = DEFAULT_START
69
97
  let outcome = await attempt();
70
98
  const deadline = Date.now() + SHUTDOWN_WAIT_MS;
71
99
  while (isShuttingDown(outcome) && Date.now() < deadline) {
72
- await delay(SHUTDOWN_POLL_MS);
100
+ await delay(SHUTDOWN_POLL_MS, interrupt);
73
101
  outcome = await attempt();
74
102
  }
75
- return afterSpawnedDaemonExit(outcome, spawned, deps.exitWaitMs);
103
+ return settleOutcome(outcome, spawned, deps, interrupt);
104
+ }
105
+ /**
106
+ * Settle the last attempt's outcome: a success is returned as is, or stopped
107
+ * when interrupted; a failure waits for the daemons the attempts spawned and
108
+ * takes the signal's exit code when interrupted.
109
+ *
110
+ * Race-free: for a success the interrupt is checked last, with no await
111
+ * between the check and the return (see {@link startSessionViaDaemon}).
112
+ *
113
+ * @param outcome - The last attempt's outcome
114
+ * @param spawned - Exiting daemons the attempts spawned
115
+ * @param deps - How to reach the daemon
116
+ * @param interrupt - Aborted on Ctrl-C or SIGTERM
117
+ * @returns Start outcome, ready to report
118
+ */
119
+ async function settleOutcome(outcome, spawned, deps, interrupt) {
120
+ if (outcome.ok) {
121
+ const { spawned: daemon, ...started } = outcome;
122
+ return interrupt?.aborted
123
+ ? cancelStartedSession(started.data, daemon, deps, interrupt)
124
+ : started;
125
+ }
126
+ const failure = await afterSpawnedDaemonExit(outcome, spawned, deps.exitWaitMs);
127
+ return interrupt?.aborted ? withInterruptExitCode(failure, interrupt) : failure;
128
+ }
129
+ /**
130
+ * Cancel a start that succeeded after Ctrl-C (the response came before the
131
+ * interrupt closed the connection): the session is stopped, since nobody
132
+ * learns that it exists, and its daemon waited for like after a failure.
133
+ *
134
+ * @param data - The started session
135
+ * @param spawned - The daemon this attempt spawned, if any
136
+ * @param deps - How to reach the daemon
137
+ * @param interrupt - The aborted interrupt
138
+ * @returns The cancelled outcome (130, or 143 for SIGTERM)
139
+ */
140
+ function cancelStartedSession(data, spawned, deps, interrupt) {
141
+ log.debug('Interrupted as the session started; stopping it');
142
+ deps.stop().catch((error) => {
143
+ log.debug(`Stopping the interrupted session failed: ${getErrorMessage(error)}`);
144
+ });
145
+ const daemon = spawned ?? {
146
+ pid: data.daemonPid,
147
+ hasExited: () => !isProcessAlive(data.daemonPid),
148
+ };
149
+ return afterSpawnedDaemonExit(cancelledOutcome(interrupt), [daemon], deps.exitWaitMs);
150
+ }
151
+ /**
152
+ * A failed start that was also interrupted: its message is kept (it says what
153
+ * went wrong), its exit code is the signal's.
154
+ *
155
+ * @param outcome - Failed start outcome
156
+ * @param interrupt - The aborted interrupt
157
+ * @returns The outcome exiting with 130 (143 for SIGTERM)
158
+ */
159
+ function withInterruptExitCode(outcome, interrupt) {
160
+ return outcome.ok
161
+ ? outcome
162
+ : { ...outcome, exitCode: interruptExitCode(interruptSignal(interrupt)) };
76
163
  }
77
164
  /**
78
165
  * Let the daemons a failed start spawned finish exiting before the error is
@@ -110,16 +197,24 @@ export async function afterSpawnedDaemonExit(outcome, spawned, waitMs) {
110
197
  };
111
198
  }
112
199
  /**
113
- * The outcome of a start interrupted with Ctrl-C (the daemon notices the
114
- * closed connection and cancels the start).
200
+ * The outcome of a start interrupted with Ctrl-C or SIGTERM (the daemon
201
+ * notices the closed connection and cancels the start).
115
202
  *
116
203
  * @param signal - The signal that stopped the start
117
- * @returns Failed start outcome (exit 130)
204
+ * @returns Failed start outcome (exit 130 or 143)
118
205
  */
119
206
  function interruptedOutcome(signal) {
120
207
  const message = `Start cancelled (${signal === 'SIGINT' ? 'interrupted' : 'terminated'})`;
121
- const exitCode = signal === 'SIGINT' ? EXIT_CODES.INTERRUPTED : EXIT_CODES.TERMINATED;
122
- return { ok: false, error: message, human: message, exitCode };
208
+ return { ok: false, error: message, human: message, exitCode: interruptExitCode(signal) };
209
+ }
210
+ /**
211
+ * The outcome for a start whose interrupt signal has aborted.
212
+ *
213
+ * @param interrupt - Aborted interrupt signal (reason: the signal name)
214
+ * @returns Failed start outcome (exit 130, or 143 for SIGTERM)
215
+ */
216
+ function cancelledOutcome(interrupt) {
217
+ return interruptedOutcome(interruptSignal(interrupt));
123
218
  }
124
219
  /** How long a new start waits for the previous session to finish shutting down */
125
220
  const SHUTDOWN_WAIT_MS = 15000;
@@ -144,9 +239,15 @@ function isShuttingDown(outcome) {
144
239
  * @param url - Target URL
145
240
  * @param options - Session options
146
241
  * @param telemetry - Telemetry types
147
- * @returns Start outcome
242
+ * @param deps - How to reach the daemon
243
+ * @param interrupt - Cancels the start when aborted
244
+ * @returns Start outcome, with the daemon it spawned for a started session or
245
+ * an exiting daemon; interrupted before the request was sent, a daemon it
246
+ * spawned is told to shut down (it hosts nothing) and waited for
148
247
  */
149
- async function requestSession(url, options, telemetry, deps) {
248
+ async function requestSession(url, options, telemetry, deps, interrupt) {
249
+ if (interrupt?.aborted)
250
+ return cancelledOutcome(interrupt);
150
251
  let spawned;
151
252
  try {
152
253
  spawned = await deps.launch();
@@ -165,8 +266,25 @@ async function requestSession(url, options, telemetry, deps) {
165
266
  const exitCode = error instanceof DaemonError ? error.exitCode : EXIT_CODES.SOFTWARE_ERROR;
166
267
  return { ok: false, error: message, human: genericError(message), exitCode };
167
268
  }
168
- const outcome = await sendStart(url, options, telemetry, deps.send);
169
- return !outcome.ok && outcome.daemonExiting && spawned ? { ...outcome, spawned } : outcome;
269
+ if (interrupt?.aborted)
270
+ return cancelBeforeRequest(spawned, interrupt);
271
+ const outcome = await sendStart(url, options, telemetry, deps.send, interrupt);
272
+ return spawned && (outcome.ok || outcome.daemonExiting) ? { ...outcome, spawned } : outcome;
273
+ }
274
+ /**
275
+ * Cancel a start interrupted while its daemon was being spawned: the daemon
276
+ * would otherwise sit idle (and answer) until its idle timeout, so it is told
277
+ * to shut down and waited for like after a failure.
278
+ *
279
+ * @param spawned - The daemon this attempt spawned (undefined: one was already running)
280
+ * @param interrupt - The aborted interrupt
281
+ * @returns The cancelled outcome
282
+ */
283
+ function cancelBeforeRequest(spawned, interrupt) {
284
+ if (!spawned)
285
+ return cancelledOutcome(interrupt);
286
+ spawned.stop();
287
+ return { ...cancelledOutcome(interrupt), daemonExiting: true, spawned };
170
288
  }
171
289
  /**
172
290
  * Ask the running daemon to start a session.
@@ -175,11 +293,13 @@ async function requestSession(url, options, telemetry, deps) {
175
293
  * @param options - Session options
176
294
  * @param telemetry - Telemetry types
177
295
  * @param send - Sends the request
178
- * @returns Start outcome; `daemonExiting` for a failure the daemon reported
179
- * or a dropped connection, not for a timeout or an unexpected error (the
180
- * daemon may still be starting the session then)
296
+ * @param interrupt - Cancels the request when aborted
297
+ * @returns Start outcome; `daemonExiting` for a failure the daemon reported,
298
+ * a dropped connection or a cancelled start (the daemon abandons it), not
299
+ * for a timeout or an unexpected error (the daemon may still be starting
300
+ * the session then)
181
301
  */
182
- async function sendStart(url, options, telemetry, send) {
302
+ async function sendStart(url, options, telemetry, send, interrupt) {
183
303
  try {
184
304
  log.debug('Connecting to daemon...');
185
305
  const response = await send(url, filterDefined({
@@ -194,7 +314,7 @@ async function sendStart(url, options, telemetry, send) {
194
314
  chromeFlags: options.chromeFlags,
195
315
  viewport: options.viewport,
196
316
  colorScheme: options.colorScheme,
197
- }));
317
+ }), interrupt);
198
318
  if (response.status === 'error') {
199
319
  return { ...describeStartFailure(response, options), daemonExiting: true };
200
320
  }
@@ -210,6 +330,8 @@ async function sendStart(url, options, telemetry, send) {
210
330
  return { ok: true, data: response.data };
211
331
  }
212
332
  catch (error) {
333
+ if (interrupt?.aborted)
334
+ return { ...cancelledOutcome(interrupt), daemonExiting: true };
213
335
  if (error instanceof IPCTimeoutError) {
214
336
  const timeout = timeoutError(error);
215
337
  return {
@@ -36,6 +36,11 @@ export interface CleanupResult {
36
36
  };
37
37
  /** Directory deleted by `--purge` */
38
38
  purged?: string;
39
+ /** Downloaded files cleanup left in place (not with `--purge`) */
40
+ downloadsKept?: {
41
+ dir: string;
42
+ files: number;
43
+ };
39
44
  message: string;
40
45
  warnings?: string[];
41
46
  }
@@ -1,4 +1,3 @@
1
- import { setTimeout as sleep } from 'node:timers/promises';
2
1
  import WebSocket from 'ws';
3
2
  import { delay as asyncDelay } from '../utils/async.js';
4
3
  import { getErrorMessage } from '../utils/errors.js';
@@ -16,20 +15,6 @@ const NO_PONG_RECEIVED_REASON = 'No pong received';
16
15
  const NORMAL_CLOSURE_REASON = 'Normal closure';
17
16
  const CONNECTION_ATTEMPT_FAILED_MESSAGE = (attempt, delay) => `Connection attempt ${attempt + 1} failed, retrying in ${delay}ms...`;
18
17
  const CONNECTION_ABORTED_ERROR = 'Connection attempts aborted';
19
- /**
20
- * Wait between connection attempts, ending early when `signal` aborts.
21
- *
22
- * @param ms - Delay in milliseconds
23
- * @param signal - Optional abort signal
24
- */
25
- async function abortableDelay(ms, signal) {
26
- if (!signal)
27
- return asyncDelay(ms);
28
- await sleep(ms, undefined, { signal }).catch((error) => {
29
- if (!signal.aborted)
30
- throw error;
31
- });
32
- }
33
18
  const FAILED_CONNECT_ATTEMPTS_ERROR = (maxRetries, lastErrorMessage) => `Failed to connect after ${maxRetries} attempts: ${lastErrorMessage}`;
34
19
  const WEBSOCKET_CLOSED_MESSAGE = (code, reason) => `WebSocket closed: ${code} - ${reason}`;
35
20
  const RECONNECTING_MESSAGE = (delay, attempt, maxAttempts) => `Reconnecting in ${delay}ms... (attempt ${attempt}/${maxAttempts})`;
@@ -130,7 +115,7 @@ export class CDPConnection {
130
115
  if (attempt < maxRetries - 1) {
131
116
  const delay = this.calculateBackoffDelay(attempt, this.config.maxRetryDelay);
132
117
  this.logger.info(CONNECTION_ATTEMPT_FAILED_MESSAGE(attempt, delay));
133
- await abortableDelay(delay, options.signal);
118
+ await asyncDelay(delay, options.signal);
134
119
  }
135
120
  }
136
121
  }
@@ -17,6 +17,7 @@
17
17
  * on 127.0.0.1 and nothing listens on [::1]: the port was free on both right
18
18
  * before the launch, and a second listener is how a conflict shows.
19
19
  */
20
+ import { ChromeLaunchError } from './errors.js';
20
21
  import { type StartupLogs } from './startupExit.js';
21
22
  /**
22
23
  * The debugging endpoint a Chrome announced for itself.
@@ -42,9 +43,24 @@ export declare function parseDevToolsListening(lines: readonly string[]): DevToo
42
43
  * @param logs - Chrome's log positions from before the launch
43
44
  * @param isRunning - Whether Chrome is still running (waiting stops when not)
44
45
  * @param timeoutMs - Longest wait
45
- * @returns Endpoint, or null if Chrome exited or announced none in time
46
+ * @param signal - Ends the wait early when aborted
47
+ * @returns Endpoint, or null if Chrome exited, announced none in time or the
48
+ * wait was aborted
46
49
  */
47
- export declare function waitForDevToolsEndpoint(logs: StartupLogs, isRunning: () => boolean, timeoutMs?: number): Promise<DevToolsEndpoint | null>;
50
+ export declare function waitForDevToolsEndpoint(logs: StartupLogs, isRunning: () => boolean, timeoutMs?: number, signal?: AbortSignal): Promise<DevToolsEndpoint | null>;
51
+ /**
52
+ * The error for a launch that was aborted (the session was stopped).
53
+ *
54
+ * @returns Launch error
55
+ */
56
+ export declare function launchAbortedError(): ChromeLaunchError;
57
+ /**
58
+ * End a launch whose session was stopped.
59
+ *
60
+ * @param signal - The launch's abort signal
61
+ * @throws ChromeLaunchError if the signal is aborted
62
+ */
63
+ export declare function throwIfLaunchAborted(signal: AbortSignal | undefined): void;
48
64
  /**
49
65
  * Check that the Chrome answering on 127.0.0.1:<port> is the one just
50
66
  * launched, so bdg never drives another session's browser.
@@ -55,9 +71,11 @@ export declare function waitForDevToolsEndpoint(logs: StartupLogs, isRunning: ()
55
71
  * that fell back to [::1] is asked once: 127.0.0.1 is held by something else.
56
72
  *
57
73
  * @param options - Chrome's log positions from before the launch, requested
58
- * port (free on 127.0.0.1 and ::1 right before the launch), Chrome's PID and
59
- * longest wait for its announcement and its answer
60
- * @throws ChromeLaunchError: CHROME_DIED_AFTER_LAUNCH if Chrome exits first;
74
+ * port (free on 127.0.0.1 and ::1 right before the launch), Chrome's PID,
75
+ * longest wait for its announcement and its answer, and a signal that ends
76
+ * the waits and a pending request at once (the session was stopped)
77
+ * @throws ChromeLaunchError: without an issue if `signal` aborts;
78
+ * CHROME_DIED_AFTER_LAUNCH if Chrome exits first;
61
79
  * PORT_IN_USE if another browser or process answers on the port;
62
80
  * CHROME_LAUNCH_FAILED if Chrome announced nothing and nothing answers, or
63
81
  * announced the port but did not answer on it in time
@@ -67,5 +85,6 @@ export declare function verifyLaunchedChrome(options: {
67
85
  port: number;
68
86
  pid: number;
69
87
  timeoutMs?: number;
88
+ signal?: AbortSignal | undefined;
70
89
  }): Promise<void>;
71
90
  //# sourceMappingURL=chromeIdentity.d.ts.map
@@ -18,7 +18,7 @@
18
18
  * before the launch, and a second listener is how a conflict shows.
19
19
  */
20
20
  import { createLogger } from '../ui/logging/index.js';
21
- import { chromeNotAnsweringReason, portTakenByReason } from '../ui/messages/chrome.js';
21
+ import { CHROME_LAUNCH_ABORTED_MESSAGE, chromeNotAnsweringReason, portTakenByReason, } from '../ui/messages/chrome.js';
22
22
  import { delay } from '../utils/async.js';
23
23
  import { CDP_HTTP_TIMEOUT_MS, fetchBrowserWsUrl, probeDevToolsEndpoint, } from '../utils/http.js';
24
24
  import { isProcessAlive } from '../utils/process.js';
@@ -63,17 +63,37 @@ export function parseDevToolsListening(lines) {
63
63
  * @param logs - Chrome's log positions from before the launch
64
64
  * @param isRunning - Whether Chrome is still running (waiting stops when not)
65
65
  * @param timeoutMs - Longest wait
66
- * @returns Endpoint, or null if Chrome exited or announced none in time
66
+ * @param signal - Ends the wait early when aborted
67
+ * @returns Endpoint, or null if Chrome exited, announced none in time or the
68
+ * wait was aborted
67
69
  */
68
- export async function waitForDevToolsEndpoint(logs, isRunning, timeoutMs = ENDPOINT_WAIT_MS) {
70
+ export async function waitForDevToolsEndpoint(logs, isRunning, timeoutMs = ENDPOINT_WAIT_MS, signal) {
69
71
  const deadline = Date.now() + timeoutMs;
70
72
  for (;;) {
71
73
  const endpoint = parseDevToolsListening(readStartupLines(logs));
72
- if (endpoint || !isRunning() || Date.now() >= deadline)
74
+ if (endpoint || !isRunning() || Date.now() >= deadline || signal?.aborted)
73
75
  return endpoint;
74
- await delay(ENDPOINT_POLL_MS);
76
+ await delay(ENDPOINT_POLL_MS, signal);
75
77
  }
76
78
  }
79
+ /**
80
+ * The error for a launch that was aborted (the session was stopped).
81
+ *
82
+ * @returns Launch error
83
+ */
84
+ export function launchAbortedError() {
85
+ return new ChromeLaunchError(CHROME_LAUNCH_ABORTED_MESSAGE);
86
+ }
87
+ /**
88
+ * End a launch whose session was stopped.
89
+ *
90
+ * @param signal - The launch's abort signal
91
+ * @throws ChromeLaunchError if the signal is aborted
92
+ */
93
+ export function throwIfLaunchAborted(signal) {
94
+ if (signal?.aborted)
95
+ throw launchAbortedError();
96
+ }
77
97
  /**
78
98
  * Check that the Chrome answering on 127.0.0.1:<port> is the one just
79
99
  * launched, so bdg never drives another session's browser.
@@ -84,28 +104,32 @@ export async function waitForDevToolsEndpoint(logs, isRunning, timeoutMs = ENDPO
84
104
  * that fell back to [::1] is asked once: 127.0.0.1 is held by something else.
85
105
  *
86
106
  * @param options - Chrome's log positions from before the launch, requested
87
- * port (free on 127.0.0.1 and ::1 right before the launch), Chrome's PID and
88
- * longest wait for its announcement and its answer
89
- * @throws ChromeLaunchError: CHROME_DIED_AFTER_LAUNCH if Chrome exits first;
107
+ * port (free on 127.0.0.1 and ::1 right before the launch), Chrome's PID,
108
+ * longest wait for its announcement and its answer, and a signal that ends
109
+ * the waits and a pending request at once (the session was stopped)
110
+ * @throws ChromeLaunchError: without an issue if `signal` aborts;
111
+ * CHROME_DIED_AFTER_LAUNCH if Chrome exits first;
90
112
  * PORT_IN_USE if another browser or process answers on the port;
91
113
  * CHROME_LAUNCH_FAILED if Chrome announced nothing and nothing answers, or
92
114
  * announced the port but did not answer on it in time
93
115
  */
94
116
  export async function verifyLaunchedChrome(options) {
95
- const { logs, port, pid, timeoutMs = ENDPOINT_WAIT_MS } = options;
117
+ const { logs, port, pid, timeoutMs = ENDPOINT_WAIT_MS, signal } = options;
96
118
  const started = Date.now();
97
119
  const isRunning = () => isProcessAlive(pid);
98
- const endpoint = await waitForDevToolsEndpoint(logs, isRunning, timeoutMs);
120
+ const endpoint = await waitForDevToolsEndpoint(logs, isRunning, timeoutMs, signal);
121
+ throwIfLaunchAborted(signal);
99
122
  if (!endpoint && !isRunning())
100
123
  throw diedError(port, pid);
101
124
  if (!endpoint)
102
- return acceptUnannouncedChrome(logs, port);
125
+ return acceptUnannouncedChrome(logs, port, signal);
103
126
  if (endpoint.port !== port) {
104
127
  throw portTakenError(port, `Chrome listens on port ${endpoint.port} instead`);
105
128
  }
106
129
  const onLoopback = endpoint.host === '127.0.0.1';
107
130
  const deadline = onLoopback ? started + timeoutMs : Date.now();
108
- const answer = await waitForAnswer(port, deadline, isRunning);
131
+ const answer = await waitForAnswer(port, deadline, isRunning, signal);
132
+ throwIfLaunchAborted(signal);
109
133
  if (answer.kind === 'devtools' && new URL(answer.wsUrl).pathname === endpoint.browserPath) {
110
134
  log.debug(`Chrome on port ${port} is the launched one (${endpoint.browserPath})`);
111
135
  return;
@@ -119,22 +143,25 @@ export async function verifyLaunchedChrome(options) {
119
143
  }
120
144
  /**
121
145
  * Ask 127.0.0.1:<port> for its DevTools version until something answers,
122
- * Chrome exits or the deadline passes (at least once). Each request waits at
123
- * most until the deadline (but at least MIN_ANSWER_WAIT_MS).
146
+ * Chrome exits, the deadline passes (at least once) or `signal` aborts (which
147
+ * also ends a pending request). Each request waits at most until the deadline
148
+ * (but at least MIN_ANSWER_WAIT_MS).
124
149
  *
125
150
  * @param port - Requested port
126
151
  * @param deadline - Time (ms since epoch) to stop asking
127
152
  * @param isRunning - Whether Chrome is still running
153
+ * @param signal - Ends the wait early when aborted
128
154
  * @returns The first answer, or the last `unreachable` result
129
155
  */
130
- async function waitForAnswer(port, deadline, isRunning) {
156
+ async function waitForAnswer(port, deadline, isRunning, signal) {
131
157
  for (;;) {
132
158
  const remaining = Math.max(deadline - Date.now(), MIN_ANSWER_WAIT_MS);
133
159
  const timeoutMs = Math.min(CDP_HTTP_TIMEOUT_MS, remaining);
134
- const answer = await probeDevToolsEndpoint(port, undefined, { timeoutMs });
135
- if (answer.kind !== 'unreachable' || !isRunning() || Date.now() >= deadline)
160
+ const answer = await probeDevToolsEndpoint(port, undefined, { timeoutMs, signal });
161
+ const done = answer.kind !== 'unreachable' || !isRunning() || Date.now() >= deadline;
162
+ if (done || signal?.aborted)
136
163
  return answer;
137
- await delay(ENDPOINT_POLL_MS);
164
+ await delay(ENDPOINT_POLL_MS, signal);
138
165
  }
139
166
  }
140
167
  /**
@@ -155,13 +182,17 @@ function answerSource(answer) {
155
182
  *
156
183
  * @param logs - Chrome's log positions (the stderr log is named in messages)
157
184
  * @param port - Requested port
158
- * @throws ChromeLaunchError: PORT_IN_USE for a second listener;
159
- * CHROME_LAUNCH_FAILED if no browser answers
185
+ * @param signal - Ends the request early when aborted
186
+ * @throws ChromeLaunchError: without an issue if `signal` aborts;
187
+ * PORT_IN_USE for a second listener; CHROME_LAUNCH_FAILED if no browser
188
+ * answers
160
189
  */
161
- async function acceptUnannouncedChrome(logs, port) {
190
+ async function acceptUnannouncedChrome(logs, port, signal) {
162
191
  const errLog = logs.files[0]?.file ?? 'chrome-err.log';
163
192
  const missing = `Chrome did not print "DevTools listening on" to ${errLog}`;
164
- if (!(await fetchBrowserWsUrl(port, log))) {
193
+ const browserWsUrl = await fetchBrowserWsUrl(port, log, { signal });
194
+ throwIfLaunchAborted(signal);
195
+ if (!browserWsUrl) {
165
196
  throw new ChromeLaunchError(`${missing}, and no browser answers on port ${port}`, {
166
197
  issue: {
167
198
  code: 'CHROME_LAUNCH_FAILED',
@@ -1,5 +1,6 @@
1
1
  import type { LaunchedChrome, Logger } from './types.js';
2
2
  import type { Options as ChromeLaunchOptions } from 'chrome-launcher';
3
+ import { ChromeLaunchError } from './errors.js';
3
4
  /**
4
5
  * Options that control how Chrome is launched for CDP sessions.
5
6
  * Extended to support chrome-launcher advanced features.
@@ -29,6 +30,11 @@ export interface LaunchOptions extends Pick<ChromeLaunchOptions, 'logLevel' | 'c
29
30
  chromePath?: string;
30
31
  /** bdg session directory: recorded as a marker flag on the Chrome command line, and holds the default profile */
31
32
  sessionDir?: string | undefined;
33
+ /**
34
+ * Ends the launch when aborted (the session was stopped): bdg stops waiting
35
+ * and kills Chrome as soon as chrome-launcher has spawned it
36
+ */
37
+ signal?: AbortSignal | undefined;
32
38
  }
33
39
  /**
34
40
  * Launch Chrome with remote debugging enabled using chrome-launcher.
@@ -42,7 +48,7 @@ export interface LaunchOptions extends Pick<ChromeLaunchOptions, 'logLevel' | 'c
42
48
  *
43
49
  * @param options - Launch configuration options
44
50
  * @returns LaunchedChrome instance with PID and kill method
45
- * @throws ChromeLaunchError if Chrome fails to launch, process dies immediately, or CDP doesn't become available
51
+ * @throws ChromeLaunchError if Chrome fails to launch, process dies immediately, CDP doesn't become available, or `options.signal` aborts
46
52
  * @throws Error if user data directory cannot be created
47
53
  *
48
54
  * @remarks
@@ -50,6 +56,32 @@ export interface LaunchOptions extends Pick<ChromeLaunchOptions, 'logLevel' | 'c
50
56
  * Uses chrome-launcher for cross-platform Chrome detection and launching.
51
57
  */
52
58
  export declare function launchChrome(options?: LaunchOptions): Promise<LaunchedChrome>;
59
+ /**
60
+ * State of the launch when chrome-launcher rejected.
61
+ */
62
+ interface LaunchFailureContext {
63
+ /** Debugging port */
64
+ port: number;
65
+ /** Chrome's PID (0 if it was not spawned) */
66
+ pid: number;
67
+ /** Whether Chrome was still running when chrome-launcher rejected */
68
+ chromeAlive: boolean;
69
+ /** How long chrome-launcher waited for the port to open */
70
+ readyBudgetMs: number;
71
+ }
72
+ /**
73
+ * The error for a launch chrome-launcher rejected.
74
+ *
75
+ * A refused connection while Chrome is still running means Chrome did not
76
+ * open its port within the readiness budget (a slow start, e.g. the first
77
+ * one on a cold machine): the port was free right before the launch and
78
+ * nothing answers on it, so it is not a conflict, and Chrome did not crash.
79
+ *
80
+ * @param error - chrome-launcher's rejection
81
+ * @param context - Port, Chrome's PID and liveness, and the readiness budget
82
+ * @returns CHROME_PORT_NOT_OPENED for a slow start, else CHROME_LAUNCH_FAILED
83
+ */
84
+ export declare function launchFailedError(error: unknown, { port, pid, chromeAlive, readyBudgetMs }: LaunchFailureContext): ChromeLaunchError;
53
85
  /**
54
86
  * Preferences for the launched profile: bdg's defaults (no translate prompt,
55
87
  * no password manager or leak check, whose bubbles capture input) for
@@ -65,4 +97,5 @@ export declare function launchChrome(options?: LaunchOptions): Promise<LaunchedC
65
97
  * @throws ChromeLaunchError if prefs file cannot be read or parsed, or prefs are not JSON
66
98
  */
67
99
  export declare function resolveChromePrefs(options: LaunchOptions): Record<string, unknown>;
100
+ export {};
68
101
  //# sourceMappingURL=launcher.d.ts.map