From e6a6a1be9cbf8acc3c0f76f07c60eead1f08456e Mon Sep 17 00:00:00 2001 From: ping-maxwell Date: Thu, 23 Apr 2026 03:21:23 +1000 Subject: [PATCH] fix: return 400 for empty JSON body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `getBody()` function calls `request.json()` unconditionally when the Content-Type header matches `application/json`: ```ts if (jsonContentTypeRegex.test(normalizedContentType)) return await request.json(); ``` When the body is empty or not valid JSON, request.json() throws a native SyntaxError. Since SyntaxError is not an instance of APIError, the router's catch block at router.mjs:84-99 falls through to the generic 500 handler: console.error(`# SERVER_ERROR: `, error); return new Response(null, { status: 500, statusText: "Internal Server Error" }); This affects all POST endpoints that don't define a body schema — because without body or disableBody: true, better-call still attempts JSON parsing. --- packages/better-call/src/router.test.ts | 67 +++++++++++++++++++++++++ packages/better-call/src/utils.ts | 12 ++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/better-call/src/router.test.ts b/packages/better-call/src/router.test.ts index 599b8f4..4ba4cd9 100644 --- a/packages/better-call/src/router.test.ts +++ b/packages/better-call/src/router.test.ts @@ -620,6 +620,73 @@ describe("error handling", () => { expect(body.message).toBe("Resource not found"); }); + describe("invalid JSON body", () => { + it("should return 400 for empty JSON body", async () => { + const endpoint = createEndpoint( + "/post", + { + method: "POST", + }, + async (c) => { + return c.body; + }, + ); + const router = createRouter({ endpoint }); + const request = new Request("http://localhost/post", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "", + }); + const response = await router.handler(request); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.code).toBe("BAD_REQUEST"); + expect(body.message).toBe("Invalid JSON in request body"); + }); + + it("should return 400 for malformed JSON body", async () => { + const endpoint = createEndpoint( + "/post", + { + method: "POST", + }, + async (c) => { + return c.body; + }, + ); + const router = createRouter({ endpoint }); + const request = new Request("http://localhost/post", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + const response = await router.handler(request); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.code).toBe("BAD_REQUEST"); + }); + + it("should return 200 for valid JSON body", async () => { + const endpoint = createEndpoint( + "/post", + { + method: "POST", + }, + async (c) => { + return c.body; + }, + ); + const router = createRouter({ endpoint }); + const request = new Request("http://localhost/post", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const response = await router.handler(request); + expect(response.status).toBe(200); + }); + }); + describe("allowedMediaTypes", () => { it("should allow requests with allowed media type at router level", async () => { const endpoint = createEndpoint( diff --git a/packages/better-call/src/utils.ts b/packages/better-call/src/utils.ts index 8506d40..383eb8d 100644 --- a/packages/better-call/src/utils.ts +++ b/packages/better-call/src/utils.ts @@ -39,7 +39,17 @@ export async function getBody(request: Request, allowedMediaTypes?: string[]) { } if (jsonContentTypeRegex.test(normalizedContentType)) { - return await request.json(); + try { + return await request.json(); + } catch (e) { + if (e instanceof SyntaxError) { + throw new APIError(400, { + message: "Invalid JSON in request body", + code: "BAD_REQUEST", + }); + } + throw e; + } } if (normalizedContentType.includes("application/x-www-form-urlencoded")) {