diff --git a/.changeset/adapter-non-json-response.md b/.changeset/adapter-non-json-response.md new file mode 100644 index 0000000..17c6d0b --- /dev/null +++ b/.changeset/adapter-non-json-response.md @@ -0,0 +1,11 @@ +--- +"@seamless-auth/core": patch +"@seamless-auth/express": patch +--- + +Don't crash on non-JSON upstream responses. `authFetch` now parses response bodies +defensively, so a plain-text error (e.g. a rate-limited `429 Too many requests`) or an +empty body (`204`) no longer throws in handlers that read the body before checking the +status — which previously surfaced as an unhandled rejection that took down the adapter +process. Non-JSON bodies are returned as `{ message: }`; empty bodies as +`undefined`. Fixes #41. diff --git a/packages/core/src/authFetch.ts b/packages/core/src/authFetch.ts index 4421c72..1407ffb 100644 --- a/packages/core/src/authFetch.ts +++ b/packages/core/src/authFetch.ts @@ -30,9 +30,37 @@ export async function authFetch( : {}), }; - return fetch(url, { + const response = await fetch(url, { method: options.method, headers, body: options.body ? JSON.stringify(options.body) : undefined, }); + + return makeJsonTolerant(response); +} + +// Upstream responses aren't always JSON: a rate-limited request comes back as plain +// text ("Too many requests…") and a 204 has no body. Native Response.json() throws on +// both, which would crash callers that parse the body before checking the status. Make +// json() tolerant so callers always get a value — parsed JSON, { message: text } for a +// non-JSON body, or undefined for an empty one. +function makeJsonTolerant(response: Response): Response { + if (typeof response.text !== "function") { + return response; + } + + const readText = response.text.bind(response); + response.json = async () => { + const text = await readText(); + if (!text) { + return undefined; + } + try { + return JSON.parse(text); + } catch { + return { message: text }; + } + }; + + return response; } diff --git a/packages/core/tests/authFetch.test.js b/packages/core/tests/authFetch.test.js index d1f95c9..abc503b 100644 --- a/packages/core/tests/authFetch.test.js +++ b/packages/core/tests/authFetch.test.js @@ -55,4 +55,49 @@ describe("authFetch", () => { }), ); }); + + it("does not throw when the response body is non-JSON (e.g. a 429)", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => "Too many requests, please try again later.", + }); + + const { authFetch } = await import("../dist/authFetch.js"); + const res = await authFetch("https://auth.example.com/otp/verify-email-otp", { + method: "POST", + }); + + await expect(res.json()).resolves.toEqual({ + message: "Too many requests, please try again later.", + }); + }); + + it("parses a JSON body normally", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ token: "abc", sub: "user-1" }), + }); + + const { authFetch } = await import("../dist/authFetch.js"); + const res = await authFetch("https://auth.example.com/login", { method: "POST" }); + + await expect(res.json()).resolves.toEqual({ token: "abc", sub: "user-1" }); + }); + + it("returns undefined for an empty body (e.g. a 204)", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 204, + text: async () => "", + }); + + const { authFetch } = await import("../dist/authFetch.js"); + const res = await authFetch("https://auth.example.com/magic-link/check", { + method: "GET", + }); + + await expect(res.json()).resolves.toBeUndefined(); + }); });