diff --git a/.changeset/improve-browser-run-binding-error-diagnostics.md b/.changeset/improve-browser-run-binding-error-diagnostics.md new file mode 100644 index 0000000000..35a1902f6c --- /dev/null +++ b/.changeset/improve-browser-run-binding-error-diagnostics.md @@ -0,0 +1,9 @@ +--- +"miniflare": patch +--- + +Improve error diagnostics in the Browser Run binding worker + +When the local Browser Run binding failed to reach an upstream — for example when Chrome failed to launch and miniflare's loopback `/browser/launch` endpoint returned a 500 with a stack-trace text body — the binding worker would call `response.json()` on the non-JSON body and throw an opaque `SyntaxError: Unexpected token X, "..." is not valid JSON`. The actual upstream error message (e.g. `Chrome readiness probe at ... timed out after 5000ms`) was discarded. + +The binding worker now reads the response body as text first, surfaces the HTTP status and body content in the thrown error, and chains the original `SyntaxError` via `cause` when the body was a 2xx response that didn't parse as JSON. This makes both local-dev failures and CI test flakes self-diagnosing. diff --git a/packages/miniflare/src/workers/browser-rendering/binding.worker.ts b/packages/miniflare/src/workers/browser-rendering/binding.worker.ts index e5946656e1..de3c624e0c 100644 --- a/packages/miniflare/src/workers/browser-rendering/binding.worker.ts +++ b/packages/miniflare/src/workers/browser-rendering/binding.worker.ts @@ -70,6 +70,46 @@ function isRetryableFetchError(error: unknown): boolean { ); } +const MAX_BODY_PREVIEW = 2000; + +function truncateBody(text: string): string { + if (text.length <= MAX_BODY_PREVIEW) { + return text; + } + return `${text.slice(0, MAX_BODY_PREVIEW)}... (truncated, ${text.length} bytes total)`; +} + +/** + * Read a JSON response with diagnostic error reporting. + * + * Standard `await resp.json()` produces opaque "Unexpected token X, ..." + * errors when an upstream returns non-JSON (e.g. a 500 with a stack-trace + * text body from the loopback `/browser/launch` handler when Chrome fails + * to start). This helper reads the body as text first, includes the status + * and (truncated) text in any thrown error, and chains the original + * `SyntaxError` via `cause` so the underlying parse failure is still + * inspectable. + */ +async function parseJsonResponse( + resp: Response, + context: string +): Promise { + const text = await resp.text(); + if (!resp.ok) { + throw new Error( + `${context}: upstream returned ${resp.status} ${resp.statusText}\n${truncateBody(text)}` + ); + } + try { + return JSON.parse(text) as T; + } catch (cause) { + throw new Error( + `${context}: expected JSON, got non-JSON response (${resp.status} ${resp.statusText})\n${truncateBody(text)}`, + { cause } + ); + } +} + /** * Wrapper around `fetch` that retries on transient connection failures. * @@ -445,11 +485,13 @@ class BrowserRenderingRouter extends Router { } async #acquireSession(): Promise { - const sessionInfo: SessionInfo = await this.env[ - SharedBindings.MAYBE_SERVICE_LOOPBACK - ] - .fetch("http://localhost/browser/launch") - .then((r) => r.json()); + const resp = await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch( + "http://localhost/browser/launch" + ); + const sessionInfo = await parseJsonResponse( + resp, + "Failed to launch local browser via miniflare loopback (/browser/launch)" + ); await this.#fetchSession(sessionInfo.sessionId, "/session-info", { method: "POST", body: JSON.stringify(sessionInfo), @@ -458,9 +500,13 @@ class BrowserRenderingRouter extends Router { } async #getActiveSessions() { - const sessionIds = (await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK] - .fetch("http://localhost/browser/sessionIds") - .then((r) => r.json())) as string[]; + const sessionIdsResp = await this.env[ + SharedBindings.MAYBE_SERVICE_LOOPBACK + ].fetch("http://localhost/browser/sessionIds"); + const sessionIds = await parseJsonResponse( + sessionIdsResp, + "Failed to list active browser sessions via miniflare loopback (/browser/sessionIds)" + ); const sessions = await Promise.all( sessionIds.map(async (sessionId) => { @@ -468,7 +514,10 @@ class BrowserRenderingRouter extends Router { if (resp.status === 204) { return null; } - const sessionInfo: SessionInfo = await resp.json(); + const sessionInfo = await parseJsonResponse( + resp, + `Failed to read session-info for browser session ${sessionId}` + ); return { sessionId: sessionInfo.sessionId, startTime: sessionInfo.startTime,