← vibesdk
worker/services/browser-capture/sidecar-client.ts
4.4 KB
Download file
/**
* Dev capture client. Talks to a local Node sidecar (`npm run dev:browser`)
* over HTTP. The sidecar drives Chromium locally with `puppeteer` and
* shares the `runCapture` core with the prod path.
*
* Contract: this client NEVER throws. When the sidecar is unreachable
* or misbehaves we log a warning and return an empty result with a
* `warning` field so the tool surfaces the situation to the LLM
* without an error that would trigger retries.
*/
import type { StructuredLogger } from '../../logger';
import type {
BrowserCaptureClient,
BrowserConsoleCaptureResult,
CapturePayload,
} from './types';
const DEFAULT_SIDECAR_URL = 'http://127.0.0.1:9223';
const DEFAULT_DEV_PREVIEW_ORIGIN = 'http://localhost:5173';
const HEALTH_PROBE_TIMEOUT_MS = 500;
const CAPTURE_TIMEOUT_MS = 90_000;
/**
* Rewrite the host portion of an incoming preview URL to point at the
* actual reachable dev origin. The agent's `getBrowserPreviewURL` can
* produce internal-only hosts (e.g. `space-internal` when the
* agent state's `wsOrigin` is contaminated by an internal RPC URL),
* which the local Chromium can't resolve. Since the sidecar only runs
* in dev, we know the user's dev server is at localhost:5173 (or a
* configured override).
*/
function rewriteToLocalHost(rawUrl: string, devOrigin: string): string {
try {
const incoming = new URL(rawUrl);
const target = new URL(devOrigin);
incoming.protocol = target.protocol;
incoming.host = target.host;
return incoming.toString();
} catch {
// If the input isn't a valid URL, append it as a path under the dev origin.
return `${devOrigin.replace(/\/$/, '')}/${rawUrl.replace(/^\//, '')}`;
}
}
export class SidecarCaptureClient implements BrowserCaptureClient {
constructor(
private readonly env: Env,
private readonly logger: StructuredLogger,
) {}
async captureConsoleLogs(
payload: CapturePayload,
): Promise<BrowserConsoleCaptureResult> {
const base = this.env.DEV_BROWSER_SIDECAR_URL || DEFAULT_SIDECAR_URL;
const devOrigin =
this.env.DEV_BROWSER_PREVIEW_ORIGIN || DEFAULT_DEV_PREVIEW_ORIGIN;
// Always navigate against the local dev server. The path part of
// the agent-supplied URL is preserved, only the scheme + host
// are rewritten.
const rewrittenUrl = rewriteToLocalHost(payload.url, devOrigin);
if (rewrittenUrl !== payload.url) {
this.logger.info('Rewriting preview URL host for local dev capture', {
original: payload.url,
rewritten: rewrittenUrl,
});
}
const localPayload: CapturePayload = { ...payload, url: rewrittenUrl };
// Phase 1 — health probe with tight timeout so unavailability fails fast.
try {
const probe = await fetch(`${base}/health`, {
signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS),
});
if (!probe.ok) {
this.logger.warn('Dev browser sidecar health check failed', {
base,
status: probe.status,
});
return this.unavailable(localPayload, `health returned ${probe.status}`);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn('Dev browser sidecar not reachable', { base, error: msg });
return this.unavailable(localPayload, msg);
}
// Phase 2 — actual capture.
try {
const resp = await fetch(`${base}/capture-console-logs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(localPayload),
signal: AbortSignal.timeout(CAPTURE_TIMEOUT_MS),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
this.logger.warn('Dev browser sidecar capture failed', {
base,
status: resp.status,
text: text.slice(0, 500),
});
return this.unavailable(
localPayload,
`capture returned ${resp.status}: ${text.slice(0, 200)}`,
);
}
return (await resp.json()) as BrowserConsoleCaptureResult;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn('Dev browser sidecar capture errored', { base, error: msg });
return this.unavailable(localPayload, msg);
}
}
private unavailable(
payload: CapturePayload,
reason: string,
): BrowserConsoleCaptureResult {
return {
url: payload.url,
capturedAt: new Date().toISOString(),
logs: [],
pageErrors: [],
requestFailures: [],
warning: `Dev browser sidecar unavailable: ${reason}. Start it with 'npm run dev:browser'.`,
};
}
}