feat: Allow late browser connection after timeout (no-changelog) (#30092)

This commit is contained in:
Dimitri Lavrenük 2026-05-08 16:42:53 +02:00 committed by GitHub
parent f709e53824
commit 1e8f89bd5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 52 deletions

4
.github/CODEOWNERS vendored
View File

@ -205,6 +205,10 @@ packages/@n8n/imap/ @n8n-io/iam
packages/@n8n/syslog-client/ @n8n-io/iam
packages/@n8n/scan-community-package/ @n8n-io/nodes
packages/@n8n/eslint-plugin-community-nodes/ @n8n-io/nodes
packages/@n8n/computer-use/ @n8n-io/nodes
packages/@n8n/local-gateway/ @n8n-io/nodes
packages/@n8n/mcp-browser/ @n8n-io/nodes
packages/@n8n/mcp-browser-extension/ @n8n-io/nodes
# IAM

View File

@ -192,8 +192,11 @@ describe('CDPRelayServer', () => {
relay.onExtensionDisconnect = (r) => resolve(r);
});
// Advance past heartbeat interval (5s) + timeout (15s)
await jest.advanceTimersByTimeAsync(20_000);
// Advance past heartbeat interval (5s) + timeout (15s) + one extra interval.
// With the sleep-aware heartbeat the first termination check that passes
// requires a ping to have been sent AFTER the last pong, which adds one
// extra 5s interval compared to the naive elapsed-only check.
await jest.advanceTimersByTimeAsync(25_000);
const reason = await disconnectPromise;
expect(reason).toBe('heartbeat_timeout');
@ -202,6 +205,34 @@ describe('CDPRelayServer', () => {
jest.useRealTimers();
});
it('should allow extension to connect after waitForExtension times out', async () => {
jest.useFakeTimers();
relay.stop();
relay = new CDPRelayServer({ connectionTimeoutMs: 2_000 });
port = await relay.listen();
// Timeout the waitForExtension call — relay stays alive
const waitPromise = relay.waitForExtension().catch((e: unknown) => e);
await jest.advanceTimersByTimeAsync(2_100);
const error = await waitPromise;
expect(error).toBeInstanceOf(ExtensionNotConnectedError);
jest.useRealTimers();
// Extension connects after the timeout — must be registered
const ext = connectExtension();
await waitForOpen(ext);
createFakeExtension(ext);
expect(relay.isExtensionConnected()).toBe(true);
const tabs = await relay.listTabs();
expect(tabs.length).toBeGreaterThan(0);
ext.close();
});
it('should return targetInfos shape from Target.getTargets', async () => {
const ext = connectExtension();
await waitForOpen(ext);

View File

@ -57,6 +57,7 @@ export class AgentBrowserAdapter implements Adapter {
private resolvedConfig: ResolvedConfig;
private relay?: CDPRelayServer;
private relayPort?: number;
private cdpEndpoint?: string;
private urlCache = new Map<string, string>();
private tabCache: TabData[] = [];
@ -181,12 +182,16 @@ export class AgentBrowserAdapter implements Adapter {
if (!chromePath) throw new BrowserExecutableNotFoundError(config.browser);
log.debug('launch: browser executable:', chromePath);
if (!this.relay) {
this.relay = new CDPRelayServer();
log.debug('launch: starting relay');
const port = await this.relay.listen();
log.debug('launch: relay listening on port', port);
const extensionEndpoint = this.relay.extensionEndpoint(port);
this.relayPort = await this.relay.listen();
log.debug('launch: relay listening on port', this.relayPort);
} else {
log.debug('launch: reusing existing relay on port', this.relayPort);
}
if (!this.relay.isExtensionConnected()) {
const extensionEndpoint = this.relay.extensionEndpoint(this.relayPort!);
const connectUrl =
`chrome-extension://${BROWSER_USE_EXTENSION_ID}/connect.html` +
`?mcpRelayUrl=${encodeURIComponent(extensionEndpoint)}`;
@ -205,8 +210,11 @@ export class AgentBrowserAdapter implements Adapter {
log.debug('launch: waiting for extension to connect');
await this.relay.waitForExtension({ browserWasLaunched: true });
log.debug('launch: extension connected');
} else {
log.debug('launch: extension already connected to existing relay');
}
this.cdpEndpoint = this.relay.cdpEndpoint(port);
this.cdpEndpoint = this.relay.cdpEndpoint(this.relayPort!);
log.debug('launch: cdp endpoint:', this.cdpEndpoint);
this.relay.onExtensionDisconnect = (reason) => {
@ -224,6 +232,7 @@ export class AgentBrowserAdapter implements Adapter {
this.relay.stop();
this.relay = undefined;
}
this.relayPort = undefined;
this.cdpEndpoint = undefined;
try {
await this.killSession();

View File

@ -279,14 +279,7 @@ export function getInstallInstructions(
/** Get instructions for installing the n8n AI Browser Bridge extension. */
export function getExtensionInstallInstructions(): string {
// TODO: Replace with actual Chrome Web Store URL once published
return (
'Install the n8n AI Browser Bridge extension:\n' +
' 1. Open chrome://extensions in your browser\n' +
' 2. Enable "Developer mode" (toggle in top-right)\n' +
' 3. Click "Load unpacked" and select the mcp-browser-extension directory\n' +
'Once the extension is published to the Chrome Web Store, you can install it directly from there.'
);
return 'Install the n8n AI Browser Bridge extension from the Chrome Web Store.';
}
/** Singleton instance for convenience. */

View File

@ -52,7 +52,7 @@ function isRestrictedTarget(targetInfo: { type?: string; url?: string }): boolea
// ---------------------------------------------------------------------------
export interface CDPRelayServerOptions {
/** Timeout in ms waiting for extension to connect. Default 15_000 */
/** Timeout in ms waiting for extension to connect. Default 30_000 */
connectionTimeoutMs?: number;
}
@ -99,7 +99,7 @@ export class CDPRelayServer {
private readonly cdpEvents = new EventEmitter();
constructor(options?: CDPRelayServerOptions) {
this.connectionTimeoutMs = options?.connectionTimeoutMs ?? 15_000;
this.connectionTimeoutMs = options?.connectionTimeoutMs ?? 30_000;
const uuid = randomUUID();
this.cdpPath = `/cdp/${uuid}`;
@ -142,7 +142,9 @@ export class CDPRelayServer {
return `ws://127.0.0.1:${port}${this.extensionPath}`;
}
/** Wait for the extension to connect. Rejects after timeout with phase-specific guidance. */
/** Wait for the extension to connect. Rejects after timeout with phase-specific guidance.
* The underlying relay connection remains open so the extension can still connect later.
*/
async waitForExtension(options?: WaitForExtensionOptions): Promise<void> {
if (this.extensionConn) return;
@ -151,9 +153,11 @@ export class CDPRelayServer {
const phase: ExtensionNotConnectedPhase =
options?.browserWasLaunched === false ? 'browser_not_launched' : 'extension_missing';
const timer = setTimeout(() => {
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
log.error('extension connection timed out, phase:', phase);
this.extensionConnectedReject?.(
reject(
new ExtensionNotConnectedError(
this.connectionTimeoutMs,
phase,
@ -161,15 +165,21 @@ export class CDPRelayServer {
),
);
}, this.connectionTimeoutMs);
});
try {
await this.extensionConnectedPromise;
await Promise.race([this.extensionConnectedPromise, timeoutPromise]);
log.debug('extension connected');
} finally {
clearTimeout(timer);
clearTimeout(timeoutId!);
}
}
/** Whether the extension is currently connected. */
isExtensionConnected(): boolean {
return this.extensionConn !== null;
}
/** Shut down the relay, closing all connections. */
stop(): void {
log.debug('stopping relay server');
@ -868,6 +878,7 @@ class ExtensionConnection {
>();
private lastId = 0;
private lastPongAt = Date.now();
private lastPingSentAt = 0;
private heartbeatInterval: ReturnType<typeof setInterval> | undefined;
/** The reason string from the WebSocket close frame, if any. */
@ -962,7 +973,10 @@ class ExtensionConnection {
}
const elapsed = Date.now() - this.lastPongAt;
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
// Only terminate if we already sent a ping since the last pong with no response.
// This prevents false timeouts after system sleep, where the timer fires with a
// large elapsed value before any new ping has been sent (and thus answered).
if (elapsed > HEARTBEAT_TIMEOUT_MS && this.lastPingSentAt > this.lastPongAt) {
log.debug('heartbeat timeout: no pong for', elapsed, 'ms');
this.closeReason = 'heartbeat_timeout';
this.stopHeartbeat();
@ -970,6 +984,7 @@ class ExtensionConnection {
return;
}
this.lastPingSentAt = Date.now();
this.ws.ping();
}, HEARTBEAT_INTERVAL_MS);
}

View File

@ -3,6 +3,7 @@ import {
AlreadyConnectedError,
BrowserNotAvailableError,
ConnectionLostError,
ExtensionNotConnectedError,
NotConnectedError,
type ConnectionLostReason,
} from './errors';
@ -25,6 +26,8 @@ export class BrowserConnection {
private state: ConnectionState | null = null;
private disconnectReason: ConnectionLostReason | undefined;
private readonly config: ResolvedConfig;
/** Adapter kept alive after an extension-connect timeout so its relay URL remains valid. */
private pendingAdapter: Adapter | null = null;
constructor(userConfig?: Partial<Config>) {
const parsed = configSchema.parse(userConfig ?? {});
@ -79,7 +82,9 @@ export class BrowserConnection {
browser,
};
const adapter = await this.createAdapter();
// Reuse a pending adapter (relay kept alive from a prior timeout) if available.
const adapter = this.pendingAdapter ?? (await this.createAdapter());
this.pendingAdapter = null;
// Listen for unexpected disconnections so we can invalidate state immediately
adapter.onDisconnect = (reason) => {
@ -89,7 +94,17 @@ export class BrowserConnection {
this.state = null;
};
try {
await adapter.launch(connectConfig);
} catch (error) {
if (error instanceof ExtensionNotConnectedError) {
// Keep the adapter alive so its relay URL stays valid for the next retry.
this.pendingAdapter = adapter;
} else {
await adapter.close().catch(() => {});
}
throw error;
}
// Two-tier model: listTabs() returns metadata from the relay (no
// debugger attachment). Playwright page objects are created lazily
@ -107,6 +122,11 @@ export class BrowserConnection {
}
async disconnect(): Promise<void> {
const pending = this.pendingAdapter;
this.pendingAdapter = null;
if (pending) await pending.close().catch(() => {});
if (!this.state) return; // already disconnected — idempotent
const { adapter } = this.state;

View File

@ -113,14 +113,14 @@ export class ExtensionNotConnectedError extends McpBrowserError {
) {
const phaseHint =
phase === 'browser_not_launched'
? 'The browser process may not have started.'
? 'The browser process may not have started. Check that the browser is installed and accessible.'
: phase === 'extension_missing'
? 'The browser opened but the n8n AI Browser Bridge extension did not connect.'
? 'The browser opened but the user did not confirm the browser connection in time. Ask the user to look for the n8n AI Browser Bridge extension popup in their browser and click Connect. If the user does not see the popup, the extension may not be installed.'
: 'The extension did not connect within the timeout period.';
const install = extensionInstructions ? `\n${extensionInstructions}` : '';
super(
`Extension connection timed out after ${timeoutMs}ms`,
`${phaseHint}${install}\nThen retry browser_connect.`,
`${phaseHint}${install}\nThen call browser_connect again.`,
);
}
}