mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat: Allow late browser connection after timeout (no-changelog) (#30092)
This commit is contained in:
parent
f709e53824
commit
1e8f89bd5a
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user