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
@@ -3,11 +3,13 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import * as chromeLauncher from 'chrome-launcher';
5
5
  import { BDG_CHROME_PREFS, DEFAULT_CDP_PORT, CHROME_PROFILE_DIR, DEFAULT_CHROME_LOG_LEVEL, } from '../constants.js';
6
+ import { chromePortNotOpenedMessage } from '../ui/messages/chrome.js';
7
+ import { delay } from '../utils/async.js';
6
8
  import { makeDirectory } from '../utils/directories.js';
7
9
  import { getErrorMessage } from '../utils/errors.js';
8
10
  import { filterDefined } from '../utils/objects.js';
9
11
  import { isProcessAlive } from '../utils/process.js';
10
- import { verifyLaunchedChrome } from './chromeIdentity.js';
12
+ import { launchAbortedError, throwIfLaunchAborted, verifyLaunchedChrome, } from './chromeIdentity.js';
11
13
  import { ChromeLaunchError } from './errors.js';
12
14
  import { resolveChromeBinary } from './launcher/binaryResolver.js';
13
15
  import { buildChromeFlags } from './launcher/flagsBuilder.js';
@@ -27,6 +29,8 @@ import { markStartupLogs, watchStartupExit } from './startupExit.js';
27
29
  const CHROME_READY_POLL_MS = 50;
28
30
  /** Checks before giving up: 25 s in all, as with chrome-launcher's defaults */
29
31
  const CHROME_READY_POLL_ATTEMPTS = 500;
32
+ /** How often an aborted launch checks whether chrome-launcher has spawned Chrome yet */
33
+ const SPAWN_POLL_MS = 10;
30
34
  const defaultLogger = {
31
35
  info: (msg) => console.error(msg),
32
36
  debug: () => { }, // No-op by default
@@ -43,7 +47,7 @@ const defaultLogger = {
43
47
  *
44
48
  * @param options - Launch configuration options
45
49
  * @returns LaunchedChrome instance with PID and kill method
46
- * @throws ChromeLaunchError if Chrome fails to launch, process dies immediately, or CDP doesn't become available
50
+ * @throws ChromeLaunchError if Chrome fails to launch, process dies immediately, CDP doesn't become available, or `options.signal` aborts
47
51
  * @throws Error if user data directory cannot be created
48
52
  *
49
53
  * @remarks
@@ -79,13 +83,14 @@ export async function launchChrome(options = {}) {
79
83
  logger.debug(`User data directory: ${userDataDir}`);
80
84
  applyProfilePreferences(userDataDir, options, logger);
81
85
  const chromeOptions = buildChromeOptions({ ...options, port });
86
+ throwIfLaunchAborted(options.signal);
82
87
  const launcher = new chromeLauncher.Launcher(chromeOptions);
83
88
  const logs = markStartupLogs(userDataDir);
84
89
  const startup = watchStartupExit(() => launcher.chromeProcess, logs, userDataDir);
85
90
  try {
86
91
  const launchStart = Date.now();
87
92
  logger.info('Waiting for Chrome to be ready...');
88
- await Promise.race([launcher.launch(), startup.exited]);
93
+ await launchUnlessAborted(launcher, startup.exited, options.signal);
89
94
  startup.stop();
90
95
  const launchDurationMs = Date.now() - launchStart;
91
96
  logger.info(`✓ Chrome ready (${launchDurationMs}ms)`);
@@ -101,7 +106,7 @@ export async function launchChrome(options = {}) {
101
106
  },
102
107
  });
103
108
  }
104
- await verifyLaunchedChrome({ logs, port, pid: chromeProcessPid });
109
+ await verifyLaunchedChrome({ logs, port, pid: chromeProcessPid, signal: options.signal });
105
110
  logger.info(`Chrome launched successfully (PID: ${chromeProcessPid}, ${launchDurationMs}ms)`);
106
111
  return {
107
112
  pid: chromeProcessPid,
@@ -117,20 +122,103 @@ export async function launchChrome(options = {}) {
117
122
  }
118
123
  catch (error) {
119
124
  startup.stop();
125
+ const pid = launcher.chromeProcess?.pid ?? 0;
126
+ const chromeAlive = pid > 0 && isProcessAlive(pid);
120
127
  launcher.kill();
121
128
  launcher.destroyTmp();
122
129
  if (error instanceof ChromeLaunchError) {
123
130
  throw error;
124
131
  }
125
- throw new ChromeLaunchError(`Failed to launch Chrome: ${getErrorMessage(error)}`, {
126
- ...(error instanceof Error && { cause: error }),
127
- issue: {
128
- code: 'CHROME_LAUNCH_FAILED',
129
- context: { port, reason: getErrorMessage(error) },
130
- },
132
+ throw launchFailedError(error, {
133
+ port,
134
+ pid,
135
+ chromeAlive,
136
+ readyBudgetMs: readyBudgetMs(chromeOptions),
131
137
  });
132
138
  }
133
139
  }
140
+ /**
141
+ * The error for a launch chrome-launcher rejected.
142
+ *
143
+ * A refused connection while Chrome is still running means Chrome did not
144
+ * open its port within the readiness budget (a slow start, e.g. the first
145
+ * one on a cold machine): the port was free right before the launch and
146
+ * nothing answers on it, so it is not a conflict, and Chrome did not crash.
147
+ *
148
+ * @param error - chrome-launcher's rejection
149
+ * @param context - Port, Chrome's PID and liveness, and the readiness budget
150
+ * @returns CHROME_PORT_NOT_OPENED for a slow start, else CHROME_LAUNCH_FAILED
151
+ */
152
+ export function launchFailedError(error, { port, pid, chromeAlive, readyBudgetMs }) {
153
+ const cause = error instanceof Error ? { cause: error } : {};
154
+ if (chromeAlive && isConnectionRefused(error)) {
155
+ return new ChromeLaunchError(chromePortNotOpenedMessage(pid, port, readyBudgetMs), {
156
+ ...cause,
157
+ issue: { code: 'CHROME_PORT_NOT_OPENED', context: { port, pid, waitedMs: readyBudgetMs } },
158
+ });
159
+ }
160
+ return new ChromeLaunchError(`Failed to launch Chrome: ${getErrorMessage(error)}`, {
161
+ ...cause,
162
+ issue: { code: 'CHROME_LAUNCH_FAILED', context: { port, reason: getErrorMessage(error) } },
163
+ });
164
+ }
165
+ /**
166
+ * Whether an error is a refused TCP connection.
167
+ *
168
+ * @param error - Error to check
169
+ * @returns True for ECONNREFUSED
170
+ */
171
+ function isConnectionRefused(error) {
172
+ return error?.code === 'ECONNREFUSED';
173
+ }
174
+ /**
175
+ * How long chrome-launcher waits for Chrome's debugging port to open.
176
+ *
177
+ * @param chromeOptions - chrome-launcher options
178
+ * @returns Poll interval times the number of polls
179
+ */
180
+ function readyBudgetMs(chromeOptions) {
181
+ return ((chromeOptions.connectionPollInterval ?? CHROME_READY_POLL_MS) *
182
+ (chromeOptions.maxConnectionRetries ?? CHROME_READY_POLL_ATTEMPTS));
183
+ }
184
+ /**
185
+ * Run chrome-launcher's launch (spawn Chrome, wait for its port to open),
186
+ * ending early when Chrome exits or `signal` aborts.
187
+ *
188
+ * chrome-launcher cannot be cancelled: after an abort its port poller keeps
189
+ * running, bdg just stops waiting for it. An abort can arrive before
190
+ * chrome-launcher has spawned Chrome (it first checks whether the port
191
+ * answers), so this waits until Chrome is spawned or the launch has ended
192
+ * without one; the caller's kill then reaches that Chrome.
193
+ *
194
+ * @param launcher - chrome-launcher instance, not launched yet
195
+ * @param exited - Rejects when Chrome exits during startup
196
+ * @param signal - The launch's abort signal
197
+ * @throws ChromeLaunchError if `signal` aborts (before or during the launch)
198
+ * @throws Error from chrome-launcher or `exited`
199
+ */
200
+ async function launchUnlessAborted(launcher, exited, signal) {
201
+ throwIfLaunchAborted(signal);
202
+ let settled = false;
203
+ const launching = launcher.launch().finally(() => {
204
+ settled = true;
205
+ });
206
+ let onAbort = () => { };
207
+ const aborted = new Promise((resolve) => {
208
+ onAbort = () => resolve('aborted');
209
+ signal?.addEventListener('abort', onAbort, { once: true });
210
+ });
211
+ try {
212
+ if ((await Promise.race([launching, exited, aborted])) !== 'aborted')
213
+ return;
214
+ }
215
+ finally {
216
+ signal?.removeEventListener('abort', onAbort);
217
+ }
218
+ while (!launcher.chromeProcess && !settled)
219
+ await delay(SPAWN_POLL_MS);
220
+ throw launchAbortedError();
221
+ }
134
222
  /**
135
223
  * Get the default persistent user-data-dir path.
136
224
  *
@@ -18,9 +18,10 @@ import type { Protocol } from 'devtools-protocol/types/protocol.js';
18
18
  * Extract parameter type from a CDP command.
19
19
  *
20
20
  * If command has no parameters, returns empty object type.
21
- * If command has single parameter array, returns first element.
21
+ * If command has a single parameter (required, or optional when all its
22
+ * fields are), returns its type.
22
23
  */
23
- type CommandParams<T extends keyof ProtocolMapping.Commands> = ProtocolMapping.Commands[T]['paramsType'] extends [infer P] ? P : ProtocolMapping.Commands[T]['paramsType'] extends [] ? Record<string, never> : never;
24
+ type CommandParams<T extends keyof ProtocolMapping.Commands> = ProtocolMapping.Commands[T]['paramsType'] extends [] ? Record<string, never> : ProtocolMapping.Commands[T]['paramsType'] extends [(infer P)?] ? NonNullable<P> : never;
24
25
  /**
25
26
  * Extract return type from a CDP command.
26
27
  */
@@ -86,7 +86,7 @@ export declare const OBJECT_EXPANSION_FAILURE_THRESHOLD = 5;
86
86
  */
87
87
  export declare const MAX_RESPONSE_SIZE: number;
88
88
  /**
89
- * Total size of the response bodies a session keeps (100MB)
89
+ * Total size of the request and response bodies a session keeps (100MB)
90
90
  * Past this the oldest bodies are replaced by a placeholder; their requests stay
91
91
  */
92
92
  export declare const MAX_TOTAL_BODY_BYTES: number;
package/dist/constants.js CHANGED
@@ -118,7 +118,7 @@ export const OBJECT_EXPANSION_FAILURE_THRESHOLD = 5;
118
118
  */
119
119
  export const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB
120
120
  /**
121
- * Total size of the response bodies a session keeps (100MB)
121
+ * Total size of the request and response bodies a session keeps (100MB)
122
122
  * Past this the oldest bodies are replaced by a placeholder; their requests stay
123
123
  */
124
124
  export const MAX_TOTAL_BODY_BYTES = 100 * 1024 * 1024;
@@ -41,7 +41,8 @@ export declare class SessionController {
41
41
  stopActiveSession(): Promise<boolean>;
42
42
  /**
43
43
  * Error fields for a request that needs the session when there is none:
44
- * still starting (85), or none at all.
44
+ * still starting (85), or none at all (also while an abandoned start is
45
+ * torn down).
45
46
  *
46
47
  * @returns Error message, plus exit code and suggestion while starting
47
48
  */
@@ -78,15 +79,19 @@ export declare class SessionController {
78
79
  * Execute a session command (dom_*, cdp_call, session_details, ...).
79
80
  *
80
81
  * @param request - Command request
82
+ * @param abandoned - Aborted when the requesting client disconnects
81
83
  * @returns Command response, forwarding exit code and suggestion on failure
82
84
  */
83
- command(request: ClientRequestUnion): Promise<unknown>;
85
+ command(request: ClientRequestUnion, abandoned?: AbortSignal): Promise<unknown>;
84
86
  /**
85
87
  * Start a session, or report the one already running.
86
88
  *
87
- * A start whose client disconnects (Ctrl-C) is abandoned: the session is
88
- * stopped, whether it is still launching or has just started, since nobody
89
- * learns that it exists.
89
+ * A start whose client disconnects (Ctrl-C, or a client killed without a
90
+ * clean disconnect) is abandoned: the session is stopped, whether it is
91
+ * still launching or has just started, since nobody learns that it exists.
92
+ * From then on the daemon reports itself shutting down (a new start gets
93
+ * the retryable `SESSION_SHUTTING_DOWN`, status shows the session ending),
94
+ * not starting, while Chrome is torn down.
90
95
  *
91
96
  * @param request - Start session request
92
97
  * @param abandoned - Aborted when the requesting client disconnects
@@ -149,12 +149,13 @@ export class SessionController {
149
149
  }
150
150
  /**
151
151
  * Error fields for a request that needs the session when there is none:
152
- * still starting (85), or none at all.
152
+ * still starting (85), or none at all (also while an abandoned start is
153
+ * torn down).
153
154
  *
154
155
  * @returns Error message, plus exit code and suggestion while starting
155
156
  */
156
157
  noSessionError() {
157
- if (!this.launching)
158
+ if (!this.launching || this.closing)
158
159
  return { error: noActiveSessionMessage() };
159
160
  return {
160
161
  error: STARTING_ERROR,
@@ -190,7 +191,7 @@ export class SessionController {
190
191
  };
191
192
  const base = { type: 'status_response', sessionId: request.sessionId };
192
193
  if (!this.session || this.session.stopRequested()) {
193
- if (this.launching) {
194
+ if (this.launching && !this.closing) {
194
195
  data.starting = { url: this.launching.url, since: this.launching.since };
195
196
  }
196
197
  if (this.session || this.closing)
@@ -250,6 +251,7 @@ export class SessionController {
250
251
  },
251
252
  currentNavigationId: data.currentNavigationId,
252
253
  ...(data.pageCrashedAt !== undefined && { pageCrashedAt: data.pageCrashedAt }),
254
+ ...(data.downloads && { downloads: data.downloads }),
253
255
  partial: true,
254
256
  },
255
257
  },
@@ -282,9 +284,10 @@ export class SessionController {
282
284
  * Execute a session command (dom_*, cdp_call, session_details, ...).
283
285
  *
284
286
  * @param request - Command request
287
+ * @param abandoned - Aborted when the requesting client disconnects
285
288
  * @returns Command response, forwarding exit code and suggestion on failure
286
289
  */
287
- async command(request) {
290
+ async command(request, abandoned) {
288
291
  const name = request.type.slice(0, -'_request'.length);
289
292
  const base = { type: `${name}_response`, sessionId: request.sessionId };
290
293
  if (!this.session) {
@@ -293,7 +296,7 @@ export class SessionController {
293
296
  const { sessionId: _sessionId, type: _type, ...params } = request;
294
297
  try {
295
298
  const execute = this.session.execute.bind(this.session);
296
- const data = await withTimeout(execute(name, params), commandTimeoutMs(name, params), 'Command');
299
+ const data = await withTimeout(execute(name, params, abandoned), commandTimeoutMs(name, params), 'Command');
297
300
  return { ...base, status: 'ok', data };
298
301
  }
299
302
  catch (error) {
@@ -303,9 +306,12 @@ export class SessionController {
303
306
  /**
304
307
  * Start a session, or report the one already running.
305
308
  *
306
- * A start whose client disconnects (Ctrl-C) is abandoned: the session is
307
- * stopped, whether it is still launching or has just started, since nobody
308
- * learns that it exists.
309
+ * A start whose client disconnects (Ctrl-C, or a client killed without a
310
+ * clean disconnect) is abandoned: the session is stopped, whether it is
311
+ * still launching or has just started, since nobody learns that it exists.
312
+ * From then on the daemon reports itself shutting down (a new start gets
313
+ * the retryable `SESSION_SHUTTING_DOWN`, status shows the session ending),
314
+ * not starting, while Chrome is torn down.
309
315
  *
310
316
  * @param request - Start session request
311
317
  * @param abandoned - Aborted when the requesting client disconnects
@@ -330,6 +336,7 @@ export class SessionController {
330
336
  this.launching = launching;
331
337
  const stopAbandoned = () => {
332
338
  log.info('Client disconnected during start; stopping the session');
339
+ this.closing = true;
333
340
  void launching.session?.stop('normal');
334
341
  };
335
342
  abandoned?.addEventListener('abort', stopAbandoned, { once: true });
@@ -196,7 +196,7 @@ export class IPCServer {
196
196
  */
197
197
  async route(message, disconnected) {
198
198
  if (isCommandRequest(message.type)) {
199
- return this.controller.command(message);
199
+ return this.controller.command(message, disconnected);
200
200
  }
201
201
  switch (message.type) {
202
202
  case 'handshake_request':
@@ -11,6 +11,11 @@ export interface SpawnedDaemon {
11
11
  pid: number | undefined;
12
12
  /** Whether it has exited (from the child process's exit event) */
13
13
  hasExited: () => boolean;
14
+ /**
15
+ * Tell it to shut down (SIGTERM, unless it has exited): it ends what it
16
+ * hosts and exits. For a start interrupted before its request was sent.
17
+ */
18
+ stop: () => void;
14
19
  }
15
20
  /**
16
21
  * Ensure a daemon is running, spawning one if needed.
@@ -58,7 +58,14 @@ export async function launchDaemon() {
58
58
  });
59
59
  daemon.unref();
60
60
  await waitForDaemonReady(() => exited);
61
- return { pid: daemon.pid, hasExited: () => exited };
61
+ return {
62
+ pid: daemon.pid,
63
+ hasExited: () => exited,
64
+ stop: () => {
65
+ if (!exited)
66
+ daemon.kill('SIGTERM');
67
+ },
68
+ };
62
69
  }
63
70
  /** How long a daemon that lost its socket gets to end its session before it is killed */
64
71
  const UNREACHABLE_DAEMON_EXIT_MS = 8000;
@@ -39,6 +39,7 @@ export declare class Session {
39
39
  private readonly onEnded;
40
40
  private readonly store;
41
41
  private readonly registry;
42
+ private readonly captures;
42
43
  private readonly notify;
43
44
  private chrome;
44
45
  private cdp;
@@ -85,12 +86,15 @@ export declare class Session {
85
86
  * {@link CRASH_SAFE_CDP} methods); others fail with exit 107 instead of
86
87
  * waiting for a page that cannot answer. Commands that may change the page
87
88
  * drop `dom inspect`'s kept matched rules ({@link withMatchedStylesReset}).
89
+ * Page commands run once a screenshot running before them has put the
90
+ * page's emulation back ({@link CaptureGate}).
88
91
  *
89
92
  * @param name - Command name
90
93
  * @param params - Command parameters
94
+ * @param abandoned - Aborted when the requesting client disconnects
91
95
  * @returns Command result
92
96
  */
93
- execute<K extends CommandName>(name: K, params: CommandSchemas[K]['requestSchema']): Promise<CommandSchemas[K]['responseSchema']>;
97
+ execute<K extends CommandName>(name: K, params: CommandSchemas[K]['requestSchema'], abandoned?: AbortSignal): Promise<CommandSchemas[K]['responseSchema']>;
94
98
  /**
95
99
  * Summary of this session for start responses.
96
100
  *
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { ChromeLaunchError } from '../../connection/errors.js';
9
9
  import { TelemetryStore } from './TelemetryStore.js';
10
+ import { CaptureGate, TELEMETRY_READS } from './captureGate.js';
10
11
  import { connectCDP, navigateToTarget } from './cdpSetup.js';
11
12
  import { externalChromePort, findPageTarget, setupChromeConnection, } from './chromeConnection.js';
12
13
  import { startTelemetryCollectors } from './collectors.js';
@@ -40,11 +41,7 @@ const PORT_ATTEMPTS = 3;
40
41
  * send raw CDP. Every other command needs the page and fails at once.
41
42
  */
42
43
  const RUN_ON_CRASHED_PAGE = new Set([
43
- 'session_peek',
44
- 'session_details',
45
- 'session_status',
46
- 'session_har_data',
47
- 'session_network_headers',
44
+ ...TELEMETRY_READS,
48
45
  'page_navigate',
49
46
  'cdp_call',
50
47
  ]);
@@ -83,6 +80,7 @@ export class Session {
83
80
  get: () => filterDefined({ viewport: this.config.viewport, colorScheme: this.config.colorScheme }),
84
81
  set: (emulation) => this.setEmulation(emulation),
85
82
  });
83
+ captures = new CaptureGate();
86
84
  notify = (notice) => log.info(formatChromeNotice(notice));
87
85
  chrome = null;
88
86
  cdp = null;
@@ -155,12 +153,15 @@ export class Session {
155
153
  * {@link CRASH_SAFE_CDP} methods); others fail with exit 107 instead of
156
154
  * waiting for a page that cannot answer. Commands that may change the page
157
155
  * drop `dom inspect`'s kept matched rules ({@link withMatchedStylesReset}).
156
+ * Page commands run once a screenshot running before them has put the
157
+ * page's emulation back ({@link CaptureGate}).
158
158
  *
159
159
  * @param name - Command name
160
160
  * @param params - Command parameters
161
+ * @param abandoned - Aborted when the requesting client disconnects
161
162
  * @returns Command result
162
163
  */
163
- execute(name, params) {
164
+ execute(name, params, abandoned) {
164
165
  if (!this.cdp || !this.started || this.stopping) {
165
166
  return Promise.reject(new Error('No active session'));
166
167
  }
@@ -176,7 +177,7 @@ export class Session {
176
177
  }
177
178
  const handler = this.registry[name];
178
179
  const cdp = this.cdp;
179
- return withMatchedStylesReset(cdp, name, () => handler(cdp, params));
180
+ return this.captures.run(name, () => withMatchedStylesReset(cdp, name, () => handler(cdp, params, abandoned)));
180
181
  }
181
182
  /**
182
183
  * Summary of this session for start responses.
@@ -315,7 +316,7 @@ export class Session {
315
316
  this.config = { ...this.config, port: await getSessionPort(explicitPort) };
316
317
  this.throwIfStopping();
317
318
  try {
318
- this.chrome = await setupChromeConnection(this.config, this.store, log, this.notify);
319
+ this.chrome = await setupChromeConnection(this.config, this.store, log, this.notify, this.launchAbort.signal);
319
320
  return;
320
321
  }
321
322
  catch (error) {
@@ -1,4 +1,5 @@
1
1
  import type { DialogInfo } from '../../ipc/protocol/domTypes.js';
2
+ import type { TrackedDownload } from '../../telemetry/downloads.js';
2
3
  import type { NavigationEvent } from '../../telemetry/navigation.js';
3
4
  import type { PendingRequest } from '../../telemetry/network.js';
4
5
  import type { NetworkEvictions } from '../../telemetry/networkRetention.js';
@@ -23,6 +24,10 @@ export declare class TelemetryStore {
23
24
  readonly websocketConnections: WebSocketConnection[];
24
25
  /** JavaScript dialogs accepted during the session */
25
26
  readonly dialogs: DialogInfo[];
27
+ /** Downloads that began during the session, oldest first, updated as they progress */
28
+ readonly downloads: TrackedDownload[];
29
+ /** Set while downloads do not go where bdg meant them to (refused, or not redirected) */
30
+ downloadsWarning: string | undefined;
26
31
  activeTelemetry: TelemetryType[];
27
32
  /** When the page's renderer crashed (epoch ms); undefined while the page is alive */
28
33
  pageCrashedAt: number | undefined;
@@ -20,6 +20,10 @@ export class TelemetryStore {
20
20
  websocketConnections = [];
21
21
  /** JavaScript dialogs accepted during the session */
22
22
  dialogs = [];
23
+ /** Downloads that began during the session, oldest first, updated as they progress */
24
+ downloads = [];
25
+ /** Set while downloads do not go where bdg meant them to (refused, or not redirected) */
26
+ downloadsWarning = undefined;
23
27
  activeTelemetry = [];
24
28
  /** When the page's renderer crashed (epoch ms); undefined while the page is alive */
25
29
  pageCrashedAt;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A screenshot changes the page's emulation for its capture (pixel ratio,
3
+ * touch, viewport, scrollbars) and puts it back once the capture ended. Its
4
+ * client may be gone long before that: interrupted (Ctrl-C), killed, or timed
5
+ * out by the daemon's command timeout. Page commands therefore run only once
6
+ * the captures before them are done, restore included, so no command, from
7
+ * any client, sees the capture's emulation. Commands that read only the
8
+ * telemetry run at once. The wait counts toward the command's own timeout
9
+ * (30 s), like any time the command takes.
10
+ */
11
+ import type { CommandName } from '../../ipc/index.js';
12
+ /** Commands that read only what the session collected, never the page */
13
+ export declare const TELEMETRY_READS: readonly CommandName[];
14
+ /**
15
+ * How long a page command waits for a capture before it runs anyway. A
16
+ * capture of a very tall page takes a few seconds; one that never ends (CDP
17
+ * calls have no timeout) must not stall the session. A capture waits for the
18
+ * one before it without this limit (bounded by its command timeout): run
19
+ * during it, it would record that capture's emulation as the page's own and
20
+ * put that back.
21
+ */
22
+ export declare const CAPTURE_WAIT_MS = 15000;
23
+ /**
24
+ * Orders page commands after the captures before them.
25
+ */
26
+ export declare class CaptureGate {
27
+ private readonly waitMs;
28
+ private capture;
29
+ /**
30
+ * @param waitMs - How long a command waits for a capture at most
31
+ */
32
+ constructor(waitMs?: number);
33
+ /**
34
+ * Run a command: at once when it reads only the telemetry, else once the
35
+ * capture running before it has ended (or, unless it is a capture itself,
36
+ * {@link waitMs} passed). A
37
+ * capture is waited for by the commands after it until it ends, however it
38
+ * ends.
39
+ *
40
+ * @param name - Command name
41
+ * @param command - The command
42
+ * @returns Its result
43
+ */
44
+ run<T>(name: CommandName, command: () => Promise<T>): Promise<T>;
45
+ /**
46
+ * Wait for the running capture, if any: until it ends for a capture, else
47
+ * at most {@link waitMs}.
48
+ *
49
+ * @param isCapture - Whether the waiting command is a capture
50
+ */
51
+ private captureEnded;
52
+ /**
53
+ * Make a capture the one later commands wait for.
54
+ *
55
+ * @param result - The capture's result
56
+ */
57
+ private track;
58
+ }
59
+ //# sourceMappingURL=captureGate.d.ts.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A screenshot changes the page's emulation for its capture (pixel ratio,
3
+ * touch, viewport, scrollbars) and puts it back once the capture ended. Its
4
+ * client may be gone long before that: interrupted (Ctrl-C), killed, or timed
5
+ * out by the daemon's command timeout. Page commands therefore run only once
6
+ * the captures before them are done, restore included, so no command, from
7
+ * any client, sees the capture's emulation. Commands that read only the
8
+ * telemetry run at once. The wait counts toward the command's own timeout
9
+ * (30 s), like any time the command takes.
10
+ */
11
+ import { settledWithin } from '../../runtime/dom/evalHelpers.js';
12
+ import { createLogger } from '../../ui/logging/index.js';
13
+ const log = createLogger('session');
14
+ /** Commands that read only what the session collected, never the page */
15
+ export const TELEMETRY_READS = [
16
+ 'session_peek',
17
+ 'session_details',
18
+ 'session_status',
19
+ 'session_har_data',
20
+ 'session_network_headers',
21
+ ];
22
+ const IGNORES_CAPTURE = new Set(TELEMETRY_READS);
23
+ /**
24
+ * How long a page command waits for a capture before it runs anyway. A
25
+ * capture of a very tall page takes a few seconds; one that never ends (CDP
26
+ * calls have no timeout) must not stall the session. A capture waits for the
27
+ * one before it without this limit (bounded by its command timeout): run
28
+ * during it, it would record that capture's emulation as the page's own and
29
+ * put that back.
30
+ */
31
+ export const CAPTURE_WAIT_MS = 15_000;
32
+ /** Command that changes the page's emulation until it ends */
33
+ const CAPTURE_COMMAND = 'dom_screenshot';
34
+ /**
35
+ * Orders page commands after the captures before them.
36
+ */
37
+ export class CaptureGate {
38
+ waitMs;
39
+ capture;
40
+ /**
41
+ * @param waitMs - How long a command waits for a capture at most
42
+ */
43
+ constructor(waitMs = CAPTURE_WAIT_MS) {
44
+ this.waitMs = waitMs;
45
+ }
46
+ /**
47
+ * Run a command: at once when it reads only the telemetry, else once the
48
+ * capture running before it has ended (or, unless it is a capture itself,
49
+ * {@link waitMs} passed). A
50
+ * capture is waited for by the commands after it until it ends, however it
51
+ * ends.
52
+ *
53
+ * @param name - Command name
54
+ * @param command - The command
55
+ * @returns Its result
56
+ */
57
+ run(name, command) {
58
+ if (IGNORES_CAPTURE.has(name))
59
+ return command();
60
+ const isCapture = name === CAPTURE_COMMAND;
61
+ const result = this.captureEnded(isCapture).then(command);
62
+ if (isCapture)
63
+ this.track(result);
64
+ return result;
65
+ }
66
+ /**
67
+ * Wait for the running capture, if any: until it ends for a capture, else
68
+ * at most {@link waitMs}.
69
+ *
70
+ * @param isCapture - Whether the waiting command is a capture
71
+ */
72
+ async captureEnded(isCapture) {
73
+ const capture = this.capture;
74
+ if (!capture)
75
+ return;
76
+ if (isCapture)
77
+ return capture;
78
+ const ended = await settledWithin(capture, this.waitMs);
79
+ if (!ended.settled)
80
+ log.info(`Capture still running after ${this.waitMs} ms; not waiting`);
81
+ }
82
+ /**
83
+ * Make a capture the one later commands wait for.
84
+ *
85
+ * @param result - The capture's result
86
+ */
87
+ track(result) {
88
+ const ended = result.then(() => undefined, () => undefined);
89
+ this.capture = ended;
90
+ void ended.then(() => {
91
+ if (this.capture === ended)
92
+ this.capture = undefined;
93
+ });
94
+ }
95
+ }
96
+ //# sourceMappingURL=captureGate.js.map
@@ -12,9 +12,24 @@ import type { Logger } from '../../ui/logging/index.js';
12
12
  /**
13
13
  * Setup Chrome connection - either launch new instance or connect to existing.
14
14
  *
15
+ * @param config - Session configuration
16
+ * @param telemetryStore - Store receiving the target info
17
+ * @param log - Logger
18
+ * @param notify - Receives notices about an external Chrome
19
+ * @param signal - Ends a launch at once when aborted (the session was stopped)
15
20
  * @returns Launched Chrome instance (null if connecting to external Chrome)
16
21
  */
17
- export declare function setupChromeConnection(config: SessionConfig, telemetryStore: TelemetryStore, log: Logger, notify: NoticeSink<ChromeNoticeCode>): Promise<LaunchedChrome | null>;
22
+ export declare function setupChromeConnection(config: SessionConfig, telemetryStore: TelemetryStore, log: Logger, notify: NoticeSink<ChromeNoticeCode>, signal?: AbortSignal): Promise<LaunchedChrome | null>;
23
+ /**
24
+ * Browser-level DevTools WebSocket URL of the session's Chrome: a launched
25
+ * Chrome's, or for `--chrome-ws-url` the URL itself when it is browser-level,
26
+ * else the one Chrome reports, reached the way the user's URL is.
27
+ *
28
+ * @param config - Session configuration (port resolved)
29
+ * @param log - Logger
30
+ * @returns The URL, or null when Chrome does not answer `/json/version`
31
+ */
32
+ export declare function browserWebSocketUrl(config: SessionConfig, log: Logger): Promise<string | null>;
18
33
  /**
19
34
  * Debugging port of an external Chrome, taken from its WebSocket URL.
20
35
  *