Response.json: throw TypeError when JSON.stringify returns undefined

RO
17d ago
+150-32

Problem

  • Response.json({ toJSON() {} }) answers 200 with an empty body. The Fetch spec and node throw TypeError: Value is not JSON serializable.
  • Response.json(type({ name: "string" })) (ArkType, a function with toJSON) throws that TypeError since bun 1.3.1. bun 1.3.0 and node answer 200.
  • The cause is Response::construct_json (src/runtime/webcore/Response.rs:873), the native function behind Response.json. It tests the type of the argument (#21258), not the result.

Fix

  • construct_json throws the TypeError when JSON.stringify returns undefined. The four type tests are removed.
  • This is the spec algorithm serialize a JavaScript value to a JSON string: only the result decides.
  • Verified: test/js/web/fetch/response.test.ts (25 new tests, 21 fail on main). 77 of 77 probe rows match node v26.3.0 (46 on main). Also fetch.test.ts, 21257.test.ts, bun-types.test.ts.
  • Self-reviewed: 6 concerns raised, 6 addressed in text. Two requests rejected, see Notes.

Background

  • json_stringify_fast (src/jsc/JSValue.rs:1567) returns an empty string when JSON.stringify returns undefined. A defined result is never empty.
  • No user reported this. It closes no issue.
  • Considered an Option return from json_stringify_fast: its 8 other callers each need a different reaction. Considered only an else arm: the ArkType value keeps throwing.

Downsides

  • A Bun.serve route that returns such a value now answers 500. It answered 200 with an empty body, which a Bun client read as null.
  • Response.json(1n) with no toJSON now throws JSC's JSON.stringify cannot serialize BigInt. The second commit causes this: it makes BigInt.prototype.toJSON work.
  • A throw for undefined, a Symbol or a function now runs JSON.stringify first: +508 to +625 instructions, +1 malloc.
Notes

History.

  • 394ec8dcbc (2022) implemented Response.json as JSON.stringify plus a new Response. An undefined result gave an empty body.
  • #21235 (labelled confirmed bug) and #21257 asked for a throw for undefined, a Symbol and a function. #21258 added it in bun 1.3.1 with tests on the type of the argument.
  • A type test cannot see what toJSON returns. So main is wrong in both directions: some unserializable values still get the empty 200, and some serializable values throw.

References.

  • undici (serializeJavascriptValueToJSONString) and WebKit (FetchResponse::jsonForBindings) call JSON.stringify and test only the result.
  • On main the result check is if !str.is_empty() { ... } with no else arm. An undefined result falls through to a 200 Response.

What changes. node v26.3.0 is the reference. All rows were run on bun 1.3.0, bun 1.3.1, main 71bc8185c8, this branch and node.

Response.json ofbun 1.3.0bun 1.3.1 to mainthis PRnode
{ toJSON() {} }, a toJSON that returns a function or a Symbol, new Proxy(function () {}, {}), Date, Map, Function.prototype200, empty body200, empty bodyTypeErrorTypeError
undefined, a Symbol, a plain function200, empty bodyTypeErrorTypeErrorTypeError
a function, arrow function, bound function or class with toJSON, for example an ArkType type(...) (arktype 2.2.9)200 with the JSONTypeError200 with the JSON200 with the JSON
1n with BigInt.prototype.toJSON200 with the JSONTypeError200 with the JSON200 with the JSON
{ toJSON() {} } with { status: 204 }204, empty body204, empty bodyTypeErrorTypeError
{ toJSON() {} } with { status: 99 }RangeErrorRangeErrorTypeErrorTypeError

What a client sees for a Bun.serve route that returns Response.json({ toJSON() {} }).

  • main: 200, Content-Length: 0, application/json;charset=utf-8. await (await fetch(url)).json() resolves null in bun and rejects with SyntaxError: Unexpected end of JSON input in node.
  • This PR: the route throws. Bun.serve answers 500 and logs TypeError: Value is not JSON serializable.
  • #21258 made the same change for undefined in 1.3.1.

What stays.

  • Response.json() with no argument answers 200 with an empty body (fetch.test.ts:1301). node throws for the missing argument.
  • Response.json({ a: 1 }, { status: 204 }) answers 204. This PR does not touch null body statuses.
  • An error from JSON.stringify propagates unchanged: a toJSON that throws, a cycle.

The four type tests that are removed.

  • is_undefined() and is_symbol() were redundant. JSON.stringify returns undefined for both.
  • js_type() == JSFunction missed a callable Proxy and host constructors. It rejected a function that has toJSON.
  • is_big_int() rejected a BigInt that BigInt.prototype.toJSON serializes. It also gave V8's message text, Do not know how to serialize a BigInt. The two effects come from the same 8 lines, so the second commit removes both.
  • After the second commit, Response.json(1n) throws what JSON.stringify(1n) and Response.json({ n: 1n }) throw, and what bun 1.3.0 threw. Issue #21257 lists that text as compatible. fetch.test.ts:1352 and 21257.test.ts:31 pinned V8's text and now pin JSC's.
  • With only the first commit: 73 of 77 rows match node, a successful call is 170 instructions (object) or 168 (number), and BigInt.prototype.toJSON stays ignored.

Measurements. Release builds of main 71bc8185c8 with and without this diff, linux x64.

  • Successful call, self instructions in ResponseClass__constructJSON (gdb nexti, 3 calls each, all equal): 179 to 165 for an object or a string, 172 to 165 for a number or null. Conditional branches: 22 to 17 and 19 to 17.
  • Successful call with callees (gdb stepi): 1758 to 1744 for { a: 1 }.
  • ResponseClass__constructJSON: 2517 to 2376 bytes (llvm-nm). .text is 81,904,741 bytes in both builds (size -A).
  • Allocations per successful call: 4 mallocs and 1 GC cell, before and after (gdb hit counters on the mimalloc entry points, heapStats()).
  • Throw with callees: undefined 2831 to 3339 instructions, a Symbol 2841 to 3355, a function 2839 to 3464. Each gains 1 malloc and 1 free.
  • Response.json({ toJSON() {} }): main builds the Response in 2446 instructions. This branch throws in 4200 to 4650.
  • toJSON calls per Response.json call: 1 (node 1). toJSON lookups on a plain function: 0 to 1 (node 1).
  • bench/snippets/response-json.mjs, 10 interleaved rounds, p50 of Response.json(smallObj): 249.7 ns on main (239.2 to 277.6), 252.4 ns on this branch (241.2 to 263.2). The difference is inside the noise.

Other consumers of an undefined JSON.stringify result. This PR changes none of them. Each needs its own reaction, and only Response.json has a spec that names one. "8 other callers" above counts json_stringify_fast only.

  • console %j (src/jsc/ConsoleObject.rs:2589) prints nothing. #34239 is open for it.
  • test.each %j titles (src/runtime/test_runner/jest.rs:791) print nothing. #44833 is open and handles it.
  • IPC send() (src/runtime/ipc.rs:571) writes an empty line. The test out.tag() == Tag::Dead at :573 is never true, because this call never returns that tag. #43005 rejects a function before the call. It leaves { toJSON() {} }. No open PR covers that.
  • Postgres parameters (src/sql_jsc/postgres/PostgresRequest.rs:121, :152) do not test for the empty result. No open PR covers it.
  • MySQL parameters (src/sql_jsc/mysql/MySQLValue.rs:148) already throw on the empty result (#45304). This PR uses the same check.
  • The tsconfig and macro options of Bun.Transpiler (src/runtime/api/JSTranspiler.rs:321, :366) ignore the option.
  • subtle.wrapKey("jwk") (src/jsc/bindings/webcrypto/SubtleCrypto.cpp:1430) does not test for a null string.
  • Bun.JSON5, Bun.YAML, Bun.TOML and Bun.XML stringify have their own function tests. They do not call JSON.stringify.

Order of init.

  • The spec converts init before it serializes the data, and validates init after. bun does both after.
  • Unserializable data plus an init that fails validation (status 99, status 204, a bad header name): bun and node both report the serialization TypeError.
  • Unserializable data plus an init whose conversion throws (a getter that throws, a Symbol status): node reports the init error, bun reports the serialization TypeError.

Tests.

  • The new tests are in response.test.ts. That file needs no server and no network. Its print size snapshot prints the size of the file itself, so the number changes with the added tests.
  • fetch.test.ts and 21257.test.ts change one assertion each: the BigInt message.
  • Debug build with BUN_JSC_validateExceptionChecks=1: response.test.ts (48 pass), 21257.test.ts (3 pass), bun-serve-static.test.ts, bun-serve-routes.test.ts, node-fetch.test.js and undici.test.ts (all pass).
  • Release builds of main and of this branch: body.test.ts passes on both. The same 21 tests of fetch.test.ts and 1 test of serve.test.ts fail on both. They need the public network or a user that is not root.

Self-review.

  • The review asked for 6 changes, all to this text and to the JSDoc of Response.json. All 6 are made: the history, the ArkType repro, the client view, the second commit, the JSDoc sentence, the list of other consumers.
  • Rejected: new issues for the consumers that have no open PR. They are not part of this change. The list above names them.
  • Rejected: a count of WPT subtests. I did not run WPT here, so this text gives no number.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch.test.ts