diff --git a/packages/opencode/test/sdk-json-guard.test.ts b/packages/opencode/test/sdk-json-guard.test.ts new file mode 100644 index 0000000000..55e2927792 --- /dev/null +++ b/packages/opencode/test/sdk-json-guard.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test" +import { createClient } from "../../sdk/js/src/v2/gen/client/client.gen" +import { createOpencodeClient } from "../../sdk/js/src/v2/client" + +// The JSON-parse guard lives in GENERATED code that `script/build.ts` wipes +// (clean: true) and re-applies on every release build. These tests pin both +// halves: the drift canaries fail if either copy of the patch disappears, and +// the live-server tests exercise the actual failure shapes (a proxy serving +// an HTML error page as application/json, and one labeling it honestly). + +describe("sdk json guard — drift canaries", () => { + const read = (p: string) => Bun.file(new URL(p, import.meta.url).pathname).text() + + it("both generated clients carry the guard", async () => { + for (const p of [ + "../../sdk/js/src/gen/client/client.gen.ts", + "../../sdk/js/src/v2/gen/client/client.gen.ts", + ]) { + const src = await read(p) + expect(src).toContain("guard JSON parse against non-JSON") + expect(src).toContain("but the body was not JSON") + } + }) + + it("build.ts re-applies the v2 guard after codegen with a matching needle", async () => { + const build = await read("../../sdk/js/script/build.ts") + expect(build).toContain("json-guard patch did not apply") + expect(build).toContain('const jsonGuardNeedle = " data = text ? JSON.parse(text) : {};"') + expect(build).toContain("but the body was not JSON") + }) +}) + +describe("sdk json guard — live failure shapes", () => { + let server: ReturnType + let base: string + const html = "502 Bad Gateway" + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const path = new URL(req.url).pathname + if (path.endsWith("/lying-proxy")) + return new Response(html, { status: 200, headers: { "content-type": "application/json" } }) + // every other route: an honest proxy error page with charset + return new Response(html, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }) + }, + }) + base = `http://localhost:${server.port}` + }) + afterAll(() => server.stop(true)) + + it("HTML mislabeled as application/json rejects with an actionable error", async () => { + const client = createClient({ baseUrl: base }) + const err = await client + .get({ url: "/lying-proxy" }) + .then(() => null) + .catch((e: unknown) => e as Error & { cause?: { body?: string } }) + expect(err).not.toBeNull() + expect(err!.message).toContain("but the body was not JSON") + expect(err!.message).toContain("/lying-proxy") + expect(err!.message).toContain("content-type application/json") + expect(err!.cause?.body).toContain("502 Bad Gateway") + }) + + it("honestly-labeled text/html (with charset) rejects at the interceptor", async () => { + const oc = createOpencodeClient({ baseUrl: base }) + const err = await oc.app + .log({ service: "t", level: "info", message: "x" }) + .then(() => null) + .catch((e: unknown) => e as Error) + expect(err).not.toBeNull() + expect(String(err)).toContain("text/html") + }) +}) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 72f4e3f3e9..f1ef437e40 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -58,6 +58,36 @@ if (sseTypesPatched === sseTypesSource) { } await Bun.write(sseTypesPath, sseTypesPatched) +// Re-apply the JSON-parse guard: `clean: true` above wipes src/v2/gen, so an +// edit inside client.gen.ts alone would be deleted on every release build +// (script/publish.ts runs this file in prepareReleaseFiles). A 200 whose body +// is an HTML error page from a proxy/gateway/CDN otherwise crashes with a raw +// "JSON Parse error: Unrecognized token '<'". +const jsonGuardPath = "./src/v2/gen/client/client.gen.ts" +const jsonGuardFile = Bun.file(jsonGuardPath) +const jsonGuardSource = await jsonGuardFile.text() +const jsonGuardNeedle = " data = text ? JSON.parse(text) : {};" +const jsonGuardBlock = [ + " // altimate_change start — upstream_fix: guard JSON parse against non-JSON (HTML) response bodies", + " // Re-applied by script/build.ts after codegen; edit it THERE, not here.", + " try {", + " data = text ? JSON.parse(text) : {}", + " } catch (cause) {", + " throw new Error(", + " \`Expected a JSON response from \${request.method} \${request.url} but the body was not JSON \` +", + " \`(HTTP \${response.status}, content-type \${response.headers.get(\"content-type\") ?? \"unset\"}). \` +", + " \`This is usually a proxy or gateway error page, not the API.\`,", + " { cause: { parseError: cause, status: response.status, body: text.slice(0, 200) } },", + " )", + " }", + " // altimate_change end", +].join("\n") +const jsonGuardPatched = jsonGuardSource.replace(jsonGuardNeedle, jsonGuardBlock) +if (jsonGuardPatched === jsonGuardSource) { + throw new Error(`json-guard patch did not apply; @hey-api/client-fetch output may have changed (${jsonGuardPath})`) +} +await Bun.write(jsonGuardPath, jsonGuardPatched) + await $`bun prettier --write src/gen` await $`bun prettier --write src/v2` await $`rm -rf dist` diff --git a/packages/sdk/js/src/gen/client/client.gen.ts b/packages/sdk/js/src/gen/client/client.gen.ts index 34a8d0bece..1b8af7f24c 100644 --- a/packages/sdk/js/src/gen/client/client.gen.ts +++ b/packages/sdk/js/src/gen/client/client.gen.ts @@ -114,10 +114,30 @@ export const createClient = (config: Config = {}): Client => { case "arrayBuffer": case "blob": case "formData": - case "json": case "text": data = await response[parseAs]() break + // altimate_change start — upstream_fix: guard JSON parse against non-JSON (HTML) response bodies + // "json" is split out of the fall-through group above so its parse can be guarded: a 200 + // whose body is an HTML error page from a proxy/gateway/CDN otherwise crashes with a raw + // "JSON Parse error: Unrecognized token '<'". The body is read OUTSIDE the guard so a + // network/body-read failure (socket reset, abort) keeps its own error; only an actual + // JSON syntax failure gets the actionable message (mirrors the v2 client). + case "json": { + const text = await response.text() + try { + data = text ? JSON.parse(text) : {} + } catch (cause) { + throw new Error( + `Expected a JSON response from ${request.method} ${request.url} but the body was not JSON ` + + `(HTTP ${response.status}, content-type ${response.headers.get("content-type") ?? "unset"}). ` + + `This is usually a proxy or gateway error page, not the API.`, + { cause: { parseError: cause, status: response.status, body: text.slice(0, 200) } }, + ) + } + break + } + // altimate_change end case "stream": return opts.responseStyle === "data" ? response.body diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index c1956cffe0..798bd17453 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -83,8 +83,11 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp ) client.interceptors.response.use((response) => { const contentType = response.headers.get("content-type") - if (contentType === "text/html") + // altimate_change start — upstream_fix: normalize before comparing; proxies and CDNs + // send "text/html; charset=utf-8", which exact equality silently let through + if (contentType?.split(";")[0]?.trim().toLowerCase() === "text/html") throw new Error("Request is not supported by this version of OpenCode Server (Server responded with text/html)") + // altimate_change end return response }) diff --git a/packages/sdk/js/src/v2/gen/client/client.gen.ts b/packages/sdk/js/src/v2/gen/client/client.gen.ts index 627e98ec42..5436e3874c 100644 --- a/packages/sdk/js/src/v2/gen/client/client.gen.ts +++ b/packages/sdk/js/src/v2/gen/client/client.gen.ts @@ -169,7 +169,21 @@ export const createClient = (config: Config = {}): Client => { // Some servers return 200 with no Content-Length and empty body. // response.json() would throw; read as text and parse if non-empty. const text = await response.text() - data = text ? JSON.parse(text) : {} + // altimate_change start — upstream_fix: guard JSON parse against non-JSON (HTML) response bodies + // A 200 whose body is an HTML error page from a proxy/gateway/CDN otherwise crashes with a + // raw "JSON Parse error: Unrecognized token '<'". Surface an actionable error instead. + // Re-applied by script/build.ts after codegen (clean: true wipes this tree); edit it THERE. + try { + data = text ? JSON.parse(text) : {} + } catch (cause) { + throw new Error( + `Expected a JSON response from ${request.method} ${request.url} but the body was not JSON ` + + `(HTTP ${response.status}, content-type ${response.headers.get("content-type") ?? "unset"}). ` + + `This is usually a proxy or gateway error page, not the API.`, + { cause: { parseError: cause, status: response.status, body: text.slice(0, 200) } }, + ) + } + // altimate_change end break } case "stream":