Response.json: throw TypeError when JSON.stringify returns undefined
Problem
Response.json({ toJSON() {} })answers 200 with an empty body. The Fetch spec and node throwTypeError: Value is not JSON serializable.Response.json(type({ name: "string" }))(ArkType, a function withtoJSON) 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 behindResponse.json. It tests the type of the argument (#21258), not the result.
Fix
construct_jsonthrows the TypeError whenJSON.stringifyreturns 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). Alsofetch.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 whenJSON.stringifyreturns undefined. A defined result is never empty.- No user reported this. It closes no issue.
- Considered an
Optionreturn fromjson_stringify_fast: its 8 other callers each need a different reaction. Considered only an else arm: the ArkType value keeps throwing.
Downsides
- A
Bun.serveroute that returns such a value now answers 500. It answered 200 with an empty body, which a Bun client read asnull. Response.json(1n)with notoJSONnow throws JSC'sJSON.stringify cannot serialize BigInt.The second commit causes this: it makesBigInt.prototype.toJSONwork.- A throw for
undefined, a Symbol or a function now runsJSON.stringifyfirst: +508 to +625 instructions, +1 malloc.
Notes
History.
- 394ec8dcbc (2022) implemented
Response.jsonasJSON.stringifyplus 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
toJSONreturns. 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) callJSON.stringifyand 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 of | bun 1.3.0 | bun 1.3.1 to main | this PR | node |
|---|---|---|---|---|
{ toJSON() {} }, a toJSON that returns a function or a Symbol, new Proxy(function () {}, {}), Date, Map, Function.prototype | 200, empty body | 200, empty body | TypeError | TypeError |
undefined, a Symbol, a plain function | 200, empty body | TypeError | TypeError | TypeError |
a function, arrow function, bound function or class with toJSON, for example an ArkType type(...) (arktype 2.2.9) | 200 with the JSON | TypeError | 200 with the JSON | 200 with the JSON |
1n with BigInt.prototype.toJSON | 200 with the JSON | TypeError | 200 with the JSON | 200 with the JSON |
{ toJSON() {} } with { status: 204 } | 204, empty body | 204, empty body | TypeError | TypeError |
{ toJSON() {} } with { status: 99 } | RangeError | RangeError | TypeError | TypeError |
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()resolvesnullin bun and rejects withSyntaxError: Unexpected end of JSON inputin node. - This PR: the route throws.
Bun.serveanswers 500 and logsTypeError: Value is not JSON serializable. - #21258 made the same change for
undefinedin 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.stringifypropagates unchanged: atoJSONthat throws, a cycle.
The four type tests that are removed.
is_undefined()andis_symbol()were redundant.JSON.stringifyreturns undefined for both.js_type() == JSFunctionmissed a callableProxyand host constructors. It rejected a function that hastoJSON.is_big_int()rejected a BigInt thatBigInt.prototype.toJSONserializes. 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 whatJSON.stringify(1n)andResponse.json({ n: 1n })throw, and what bun 1.3.0 threw. Issue #21257 lists that text as compatible.fetch.test.ts:1352and21257.test.ts:31pinned 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.toJSONstays ignored.
Measurements. Release builds of main 71bc8185c8 with and without this diff, linux x64.
- Successful call, self instructions in
ResponseClass__constructJSON(gdbnexti, 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)..textis 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:
undefined2831 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.toJSONcalls perResponse.jsoncall: 1 (node 1).toJSONlookups on a plain function: 0 to 1 (node 1).bench/snippets/response-json.mjs, 10 interleaved rounds, p50 ofResponse.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%jtitles (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 testout.tag() == Tag::Deadat:573is 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
tsconfigandmacrooptions ofBun.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.TOMLandBun.XMLstringifyhave their own function tests. They do not callJSON.stringify.
Order of init.
- The spec converts
initbefore it serializes the data, and validatesinitafter. bun does both after. - Unserializable data plus an
initthat fails validation (status 99, status 204, a bad header name): bun and node both report the serialization TypeError. - Unserializable data plus an
initwhose conversion throws (a getter that throws, a Symbol status): node reports theiniterror, bun reports the serialization TypeError.
Tests.
- The new tests are in
response.test.ts. That file needs no server and no network. Itsprint sizesnapshot prints the size of the file itself, so the number changes with the added tests. fetch.test.tsand21257.test.tschange 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.jsandundici.test.ts(all pass). - Release builds of main and of this branch:
body.test.tspasses on both. The same 21 tests offetch.test.tsand 1 test ofserve.test.tsfail 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