Skip to content

Commit 7714128

Browse files
committed
fix: bound browser echo mcp forwarding
1 parent 445ee1b commit 7714128

9 files changed

Lines changed: 306 additions & 97 deletions

File tree

packages/core/build.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export default defineBuildConfig({
44
entries: [
55
'./src/index',
66
'./src/client',
7+
'./src/server',
78
'./src/types',
89
'./src/worker'
910
],

packages/core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"types": "./dist/client.d.ts",
2525
"default": "./dist/client.mjs"
2626
},
27+
"./server": {
28+
"types": "./dist/server.d.ts",
29+
"default": "./dist/server.mjs"
30+
},
2731
"./types": {
2832
"types": "./dist/types.d.ts",
2933
"default": "./dist/types.mjs"

packages/core/src/server.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { dirname, join } from 'node:path';
3+
4+
export type BrowserEchoMcpTarget = {
5+
url: string;
6+
routeLogs: `/${string}`;
7+
};
8+
9+
export const BROWSER_ECHO_FORWARD_TIMEOUT_MS = 300;
10+
11+
const LOCAL_MCP_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
12+
13+
export function hasExplicitMcpUrl(value = process.env.BROWSER_ECHO_MCP_URL): boolean {
14+
if (!value) return false;
15+
const normalized = String(value).trim().toLowerCase();
16+
return Boolean(normalized && !['undefined', 'null', 'false', '0'].includes(normalized));
17+
}
18+
19+
export function resolveBrowserEchoMcpTarget(cwd = process.cwd()): BrowserEchoMcpTarget | null {
20+
try {
21+
let dir = cwd;
22+
for (let depth = 0; depth < 10; depth++) {
23+
const discoveryPath = join(dir, '.browser-echo-mcp.json');
24+
if (existsSync(discoveryPath)) {
25+
const data = JSON.parse(readFileSync(discoveryPath, 'utf-8'));
26+
const url = normalizeLocalMcpUrl(data?.url);
27+
if (!url) break;
28+
29+
return {
30+
url,
31+
routeLogs: normalizeMcpRoute(data?.route ?? data?.routeLogs),
32+
};
33+
}
34+
35+
const parent = dirname(dir);
36+
if (parent === dir) break;
37+
dir = parent;
38+
}
39+
} catch {
40+
return null;
41+
}
42+
43+
return null;
44+
}
45+
46+
export async function forwardBrowserEchoPayload(
47+
payload: unknown,
48+
options: {
49+
cwd?: string;
50+
fetchImpl?: typeof fetch;
51+
timeoutMs?: number;
52+
} = {},
53+
): Promise<boolean> {
54+
const target = resolveBrowserEchoMcpTarget(options.cwd);
55+
if (!target) return false;
56+
57+
return forwardToBrowserEchoMcp(target, payload, options);
58+
}
59+
60+
export async function forwardToBrowserEchoMcp(
61+
target: BrowserEchoMcpTarget,
62+
payload: unknown,
63+
options: {
64+
fetchImpl?: typeof fetch;
65+
timeoutMs?: number;
66+
} = {},
67+
): Promise<boolean> {
68+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
69+
if (typeof fetchImpl !== 'function') return false;
70+
71+
const timeoutMs = options.timeoutMs ?? BROWSER_ECHO_FORWARD_TIMEOUT_MS;
72+
const url = `${target.url}${target.routeLogs}`;
73+
const requestInit = {
74+
method: 'POST',
75+
headers: { 'content-type': 'application/json' },
76+
body: JSON.stringify(payload),
77+
cache: 'no-store' as RequestCache,
78+
};
79+
const timeout = createTimeoutSignal(timeoutMs);
80+
81+
try {
82+
const response = await fetchImpl(url, { ...requestInit, signal: timeout.signal });
83+
return Boolean(response?.ok);
84+
} catch (error) {
85+
if (isAbortSignalCompatibilityError(error)) {
86+
return forwardWithTimeoutOnly(fetchImpl, url, requestInit, timeoutMs);
87+
}
88+
return false;
89+
} finally {
90+
timeout.clear();
91+
}
92+
}
93+
94+
function normalizeLocalMcpUrl(value: unknown): string {
95+
if (typeof value !== 'string') return '';
96+
const raw = value.trim();
97+
if (!raw) return '';
98+
99+
try {
100+
const parsed = new URL(raw);
101+
if (parsed.protocol !== 'http:') return '';
102+
if (!LOCAL_MCP_HOSTS.has(parsed.hostname)) return '';
103+
104+
const path = parsed.pathname.replace(/\/+$/g, '');
105+
if (path && path !== '/mcp') return '';
106+
107+
parsed.pathname = '';
108+
parsed.search = '';
109+
parsed.hash = '';
110+
return parsed.toString().replace(/\/$/g, '');
111+
} catch {
112+
return '';
113+
}
114+
}
115+
116+
function normalizeMcpRoute(value: unknown): `/${string}` {
117+
if (typeof value !== 'string') return '/__client-logs';
118+
const route = value.trim();
119+
if (!route.startsWith('/') || route.startsWith('//') || route.includes('://')) return '/__client-logs';
120+
return route as `/${string}`;
121+
}
122+
123+
function createTimeoutSignal(timeoutMs: number): { signal: AbortSignal; clear: () => void } {
124+
const controller = new AbortController();
125+
const timer = setTimeout(() => controller.abort(), timeoutMs);
126+
return {
127+
signal: controller.signal,
128+
clear: () => clearTimeout(timer),
129+
};
130+
}
131+
132+
function isAbortSignalCompatibilityError(error: unknown): boolean {
133+
return error instanceof TypeError && String(error.message).includes('AbortSignal');
134+
}
135+
136+
async function forwardWithTimeoutOnly(
137+
fetchImpl: typeof fetch,
138+
url: string,
139+
requestInit: RequestInit,
140+
timeoutMs: number,
141+
): Promise<boolean> {
142+
let timer: ReturnType<typeof setTimeout> | undefined;
143+
try {
144+
const request = fetchImpl(url, requestInit)
145+
.then((response) => Boolean(response?.ok))
146+
.catch(() => false);
147+
const timeout = new Promise<false>((resolve) => {
148+
timer = setTimeout(() => resolve(false), timeoutMs);
149+
});
150+
151+
return await Promise.race([request, timeout]);
152+
} finally {
153+
if (timer) clearTimeout(timer);
154+
}
155+
}

packages/core/test/server.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { describe, expect, it } from 'vitest';
5+
import { resolveBrowserEchoMcpTarget } from '../src/server';
6+
7+
describe('server MCP discovery', () => {
8+
it('normalizes local MCP URLs and legacy routeLogs', () => {
9+
const dir = mkdtempSync(join(tmpdir(), 'be-core-mcp-'));
10+
try {
11+
writeFileSync(join(dir, '.browser-echo-mcp.json'), JSON.stringify({
12+
url: 'http://localhost:5179/mcp',
13+
routeLogs: '/__client-logs',
14+
}));
15+
16+
expect(resolveBrowserEchoMcpTarget(dir)).toEqual({
17+
url: 'http://localhost:5179',
18+
routeLogs: '/__client-logs',
19+
});
20+
} finally {
21+
rmSync(dir, { recursive: true, force: true });
22+
}
23+
});
24+
25+
it('rejects non-local URLs', () => {
26+
const dir = mkdtempSync(join(tmpdir(), 'be-core-remote-'));
27+
try {
28+
writeFileSync(join(dir, '.browser-echo-mcp.json'), JSON.stringify({
29+
url: 'https://example.com/mcp',
30+
route: '/__client-logs',
31+
}));
32+
33+
expect(resolveBrowserEchoMcpTarget(dir)).toBeNull();
34+
} finally {
35+
rmSync(dir, { recursive: true, force: true });
36+
}
37+
});
38+
39+
it('defaults unsafe routes to the log endpoint', () => {
40+
const dir = mkdtempSync(join(tmpdir(), 'be-core-route-'));
41+
try {
42+
writeFileSync(join(dir, '.browser-echo-mcp.json'), JSON.stringify({
43+
url: 'http://127.0.0.1:5179',
44+
route: '//evil.test/path',
45+
}));
46+
47+
expect(resolveBrowserEchoMcpTarget(dir)?.routeLogs).toBe('/__client-logs');
48+
} finally {
49+
rmSync(dir, { recursive: true, force: true });
50+
}
51+
});
52+
});

packages/next/src/route.ts

Lines changed: 3 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { NextRequest } from 'next/server';
22
import { NextResponse } from 'next/server';
3-
4-
// Simplified: resolve MCP from project-local JSON once; no fallback
3+
import { forwardBrowserEchoPayload, hasExplicitMcpUrl } from '@browser-echo/core/server';
54

65
export type BrowserLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
76
type Entry = { level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; tag?: string };
@@ -16,33 +15,9 @@ export async function POST(req: NextRequest) {
1615
catch { return new NextResponse('invalid JSON', { status: 400 }); }
1716
if (!payload || !Array.isArray(payload.entries)) return new NextResponse('invalid payload', { status: 400 });
1817

19-
// Resolve MCP once: project JSON only (no fallback)
20-
const mcp = await __resolveMcpFromProject();
21-
22-
// Forward to MCP server if available (fire-and-forget)
23-
if (mcp.url) {
24-
try {
25-
const route = (mcp.routeLogs as `/${string}`) || '/__client-logs';
26-
const headers: Record<string,string> = { 'content-type': 'application/json' };
27-
await fetch(`${mcp.url}${route}`, {
28-
method: 'POST',
29-
headers,
30-
body: JSON.stringify(payload),
31-
cache: 'no-store',
32-
}).catch(() => undefined);
33-
} catch {}
34-
}
18+
await forwardBrowserEchoPayload(payload);
3519

36-
// Dynamically decide whether to print to terminal
37-
// Only suppress when MCP URL is explicitly configured via env var
38-
const envMcp = (() => {
39-
const raw = process.env.BROWSER_ECHO_MCP_URL;
40-
if (!raw) return '';
41-
const s = String(raw).trim().toLowerCase();
42-
if (!s || s === 'undefined' || s === 'null' || s === 'false' || s === '0') return '';
43-
return String(raw).trim();
44-
})();
45-
const shouldPrint = !envMcp;
20+
const shouldPrint = !hasExplicitMcpUrl();
4621

4722
const sid = (payload.sessionId ?? 'anon').slice(0, 8);
4823
for (const entry of payload.entries) {
@@ -81,27 +56,3 @@ function color(level: BrowserLogLevel, msg: string) {
8156
}
8257
}
8358
function dim(s: string) { return c.dim + s + c.reset; }
84-
85-
async function __resolveMcpFromProject(): Promise<{ url: string; routeLogs?: `/${string}` }> {
86-
try {
87-
const { readFileSync, existsSync } = await import('node:fs');
88-
const { join, dirname } = await import('node:path');
89-
let dir = process.cwd();
90-
for (let depth = 0; depth < 10; depth++) {
91-
const p = join(dir, '.browser-echo-mcp.json');
92-
if (existsSync(p)) {
93-
const raw = readFileSync(p, 'utf-8');
94-
const data = JSON.parse(raw);
95-
const rawUrl = (data?.url ? String(data.url) : '');
96-
const base = rawUrl.replace(/\/$/, '').replace(/\/mcp$/i, '');
97-
if (!/^(http:\/\/127\.0\.0\.1|http:\/\/localhost)/.test(base)) break;
98-
const routeLogs = (data?.route ? String(data.route) : '/__client-logs') as `/${string}`;
99-
if (base) return { url: base, routeLogs };
100-
}
101-
const up = dirname(dir);
102-
if (up === dir) break;
103-
dir = up;
104-
}
105-
} catch {}
106-
return { url: '' } as any;
107-
}

packages/next/test/route.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,43 @@ it('walks up directories to find discovery file and forwards', async () => {
140140
}
141141
});
142142

143+
it('times out stale MCP discovery and still returns 204', async () => {
144+
const REAL_FETCH = globalThis.fetch as any;
145+
let aborted = false;
146+
globalThis.fetch = vi.fn((_url: any, init: any) => new Promise((_resolve, reject) => {
147+
const signal = init?.signal as AbortSignal | undefined;
148+
if (!signal) {
149+
reject(new Error('missing signal'));
150+
return;
151+
}
152+
signal.addEventListener('abort', () => {
153+
aborted = true;
154+
reject(new Error('aborted'));
155+
}, { once: true });
156+
})) as any;
157+
const oldCwd = process.cwd();
158+
const base = mkdtempSync(join(tmpdir(), 'be-next-stale-'));
159+
try {
160+
writeFileSync(join(base, '.browser-echo-mcp.json'), JSON.stringify({ url: 'http://127.0.0.1:59997', route: '/__client-logs', timestamp: Date.now() }));
161+
process.chdir(base);
162+
vi.resetModules();
163+
const mod = await import('../src/route');
164+
const w = vi.spyOn(console, 'warn').mockImplementation(() => {});
165+
const req: any = { json: async () => ({ sessionId: 'stale123', entries: [{ level: 'warn', text: 'still prints' }] }) };
166+
const started = Date.now();
167+
const res: any = await mod.POST(req);
168+
expect(Date.now() - started).toBeLessThan(1000);
169+
expect((res as any).status).toBe(204);
170+
expect(aborted).toBe(true);
171+
expect(w).toHaveBeenCalled();
172+
w.mockRestore();
173+
} finally {
174+
process.chdir(oldCwd);
175+
try { rmSync(base, { recursive: true, force: true }); } catch {}
176+
globalThis.fetch = REAL_FETCH;
177+
}
178+
});
179+
143180
it('ignores malformed discovery file gracefully and keeps printing', async () => {
144181
const oldCwd = process.cwd();
145182
const base = mkdtempSync(join(tmpdir(), 'be-next-bad-'));

0 commit comments

Comments
 (0)