Skip to content

Commit bb6eb87

Browse files
[miniflare] 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. Add a `parseJsonResponse` helper to the binding worker that reads the body as text first, surfaces the HTTP status and (truncated) 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. Wire it into the three loopback JSON-parsing sites in the binding worker (`/browser/launch`, `/browser/sessionIds`, and per-DO session-info reads). This makes both local-dev failures and CI test flakes self-diagnosing without requiring a debugger.
1 parent 304d498 commit bb6eb87

2 files changed

Lines changed: 67 additions & 9 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"miniflare": patch
3+
---
4+
5+
Improve error diagnostics in the Browser Run binding worker
6+
7+
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.
8+
9+
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.

packages/miniflare/src/workers/browser-rendering/binding.worker.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,46 @@ function isRetryableFetchError(error: unknown): boolean {
7070
);
7171
}
7272

73+
const MAX_BODY_PREVIEW = 2000;
74+
75+
function truncateBody(text: string): string {
76+
if (text.length <= MAX_BODY_PREVIEW) {
77+
return text;
78+
}
79+
return `${text.slice(0, MAX_BODY_PREVIEW)}... (truncated, ${text.length} bytes total)`;
80+
}
81+
82+
/**
83+
* Read a JSON response with diagnostic error reporting.
84+
*
85+
* Standard `await resp.json()` produces opaque "Unexpected token X, ..."
86+
* errors when an upstream returns non-JSON (e.g. a 500 with a stack-trace
87+
* text body from the loopback `/browser/launch` handler when Chrome fails
88+
* to start). This helper reads the body as text first, includes the status
89+
* and (truncated) text in any thrown error, and chains the original
90+
* `SyntaxError` via `cause` so the underlying parse failure is still
91+
* inspectable.
92+
*/
93+
async function parseJsonResponse<T = unknown>(
94+
resp: Response,
95+
context: string
96+
): Promise<T> {
97+
const text = await resp.text();
98+
if (!resp.ok) {
99+
throw new Error(
100+
`${context}: upstream returned ${resp.status} ${resp.statusText}\n${truncateBody(text)}`
101+
);
102+
}
103+
try {
104+
return JSON.parse(text) as T;
105+
} catch (cause) {
106+
throw new Error(
107+
`${context}: expected JSON, got non-JSON response (${resp.status} ${resp.statusText})\n${truncateBody(text)}`,
108+
{ cause }
109+
);
110+
}
111+
}
112+
73113
/**
74114
* Wrapper around `fetch` that retries on transient connection failures.
75115
*
@@ -445,11 +485,13 @@ class BrowserRenderingRouter extends Router {
445485
}
446486

447487
async #acquireSession(): Promise<SessionInfo> {
448-
const sessionInfo: SessionInfo = await this.env[
449-
SharedBindings.MAYBE_SERVICE_LOOPBACK
450-
]
451-
.fetch("http://localhost/browser/launch")
452-
.then((r) => r.json());
488+
const resp = await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch(
489+
"http://localhost/browser/launch"
490+
);
491+
const sessionInfo = await parseJsonResponse<SessionInfo>(
492+
resp,
493+
"Failed to launch local browser via miniflare loopback (/browser/launch)"
494+
);
453495
await this.#fetchSession(sessionInfo.sessionId, "/session-info", {
454496
method: "POST",
455497
body: JSON.stringify(sessionInfo),
@@ -458,17 +500,24 @@ class BrowserRenderingRouter extends Router {
458500
}
459501

460502
async #getActiveSessions() {
461-
const sessionIds = (await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK]
462-
.fetch("http://localhost/browser/sessionIds")
463-
.then((r) => r.json())) as string[];
503+
const sessionIdsResp = await this.env[
504+
SharedBindings.MAYBE_SERVICE_LOOPBACK
505+
].fetch("http://localhost/browser/sessionIds");
506+
const sessionIds = await parseJsonResponse<string[]>(
507+
sessionIdsResp,
508+
"Failed to list active browser sessions via miniflare loopback (/browser/sessionIds)"
509+
);
464510

465511
const sessions = await Promise.all(
466512
sessionIds.map(async (sessionId) => {
467513
const resp = await this.#fetchSession(sessionId, "/session-info");
468514
if (resp.status === 204) {
469515
return null;
470516
}
471-
const sessionInfo: SessionInfo = await resp.json();
517+
const sessionInfo = await parseJsonResponse<SessionInfo>(
518+
resp,
519+
`Failed to read session-info for browser session ${sessionId}`
520+
);
472521
return {
473522
sessionId: sessionInfo.sessionId,
474523
startTime: sessionInfo.startTime,

0 commit comments

Comments
 (0)