util.inspect: print the state of Response, Request, Headers, Blob and six more classes

RO
24d ago
+2749-84

Problem

  • util.inspect(new Response("x")) returns Response {}. Request, Headers, FormData, Blob, File, AbortSignal, AbortController, Event, TextDecoder and Timeout print empty too. So does console.log in a node:worker_threads Worker.
  • Their state is in native fields. formatValue (src/js/internal/util/inspect.js:1242) prints own properties or calls value[Symbol.for("nodejs.util.inspect.custom")], which node has and Bun did not.
  • This revives #35292.

Fix

  • Each prototype gets node's method. Its native half copies the state into a plain object and runs no program code. internal/util/inspect_hooks has node's code for the rest.
  • A print keeps nothing on the value and does no stat. Bun's console (console.log, Bun.inspect, bun:test) neither calls nor lists the methods: its text stays byte-identical to main.
  • Verified: inspect-web-classes.test.ts (Node.js passes it too, main fails 59), custom-inspect.test.js (main fails 20).
  • Self-reviewed: 186 concerns raised, 185 addressed. Not added: upstream test-abortcontroller.js (#19492 has it).

Background

  • util.inspect calls value[nodejs.util.inspect.custom](depth, options, inspect) and prints the string it returns.
  • Bun's console, a separate native printer, also calls such a method. It skips these ten by their native function's address.
  • Considered a formatValue branch to Bun's printer: it gives Bun's console text and adds work per value.

Downsides

  • util.inspect drops properties a program added (req.user), as node does.
  • Loggers on node:util now print header values, URLs and form fields. So does a method that passes one to inspect.
  • console.log of one of the ten costs 216 to 1,766 more instructions (1% to 11%). Another value with a callable method: about 50. .text grows 23,296 B.
Notes

Repro

The script from the report prints one row per class. It exits 1 when a row has no state in it.

main, exit 1                                   this branch, exit 0
Headers      Headers {}                        Headers { 'x-request-id': 'abc123' }
Response     Response {}                       Response { status: 404, statusText: 'Not Found', headers: Headers { ... }, ... }
Request      Request {}                        Request { method: 'POST', url: 'http://127.0.0.1:1/users?id=7', ... }
Blob         Blob {}                           Blob { size: 5, type: 'text/csv' }
File         Blob {}                           File { size: 5, type: 'text/csv', name: 'report.csv', lastModified: 1791937485247 }
FormData     FormData {}                       FormData { user: 'ada' }
AbortSignal  AbortSignal {}                    AbortSignal { aborted: true }
Event        Event { isTrusted: [Getter] }     Event { type: 'ping', defaultPrevented: false, cancelable: false, timeStamp: 0 }
TextDecoder  TextDecoder {}                    TextDecoder { encoding: 'utf-16le', fatal: false, ignoreBOM: false }

AbortController {} and Timeout {} are not in the script. They had the same cause.

The two halves

  • The native half of a method finds the instance behind the receiver (a Proxy is unwrapped, and an object that inherits from an instance counts). It copies the state into a plain object. It runs no code of the program.
  • The JS half, internal/util/inspect_hooks, is node's method for the class: the names that node reads from this, the depth rule, the option changes, and the name of the constructor. It reads constructor.name as node does, so a getter that a program put there runs.
  • The older methods (streams, URL, CryptoKey and others) build their text in C++, in customInspect. The ten new ones need more than it does (undici's option changes, names that differ by class, the names read from this), so their tail is JS.
  • A Response prints the state of its body stream with node's method for a stream, in the JS half. The method of ReadableStream itself is not changed.
  • Follow-up, if the assignee agrees: move the 19 callers of customInspect, and the copies in BroadcastChannel, URLSearchParams and PerformanceEntry, to the JS tail, and delete customInspect. That replaces the method hunks of #40008, #39382 and #39207.

What each class prints

ClassTextPast the depth limitA receiver that holds no instance
HeadersHeaders { 'x-request-id': 'abc123' }the same textthrows ERR_INVALID_THIS
ResponseResponse { status, statusText, headers, body, bodyUsed, ok, redirected, type, url }the same textreturned as it is
RequestRequest { method, url, headers, destination, referrer, referrerPolicy, mode, credentials, cache, redirect, integrity, signal }the same text, with signal: [AbortSignal]returned as it is
FormDataFormData { user: 'ada' }the same textthrows ERR_INVALID_THIS
Blob, FileBlob { size, type }, File { size, type, name, lastModified }[Blob], [File]returned as it is
AbortSignalAbortSignal { aborted: false }[AbortSignal]returned as it is
AbortControllerAbortController { signal: AbortSignal { aborted: false } }AbortController {}returned as it is
Event and its subclassesCustomEvent { type, defaultPrevented, cancelable, timeStamp }CustomEventthrows ERR_INVALID_THIS
TextDecoderTextDecoder { encoding, fatal, ignoreBOM }[TextDecoder]throws ERR_INVALID_THIS
TimeoutTimeout { _idleTimeout, _idleStart, _onTimeout, _repeat, _destroyed, ...own properties }the same textprinted as a plain value
  • A null or undefined receiver throws ERR_INVALID_THIS for the nine web classes.
  • The error is the same one inside another value. util.inspect wrapped every error from a nested method in an AssertionError. Node rethrows it (nodejs/node#55544), and this change does the same in formatRaw, for the methods of a program too. #45261 makes the same change to that line. With getters: true, the catch of a getter now meets such a value: it prints null and undefined as node does, and a thrown primitive as undefined, as on main (node v26 prints the value).
  • Where node's depth does not count down (a Response, a Request, a TextDecoder, or a depth with no limit), a value that its own print reaches again shows as [Circular]. Node prints such a cycle until another method on it counts the depth down to the limit, and runs out of stack when none does. A cycle of classes that all count the depth down prints as in node.
  • The methods know which values they print from one option, [[inspect_hooks.printing]], which inspect passes on as it passes any option that it does not know. A method of a program that runs inside such a print gets that key in its options.

Where the text differs from node v26.3.0

The shared test file holds node's text, and Node.js v26.3.0 passes it. Where Bun prints other text, the row is a differs(nodeText, bunText, why) call: 41 calls in 95 tests. For the nine classes other than Request, every view of an ordinary instance is equal: the text, depth 0, three levels deep, %s, colors, breakLength: Infinity, compact: false, depth: null. The differences, by cause:

  • Values that Bun holds. The method prints what the getters of the class give.
    • Request: referrer is '' and credentials is 'include'. Bun has no keepalive, isReloadNavigation or isHistoryNavigation.
    • body shows as null, or as the stream when the Response has one. Bun makes the stream at the first read of response.body, so the line can appear later. For a string body, headers has no content-type: that is the state of response.headers in Bun.
    • Headers: the names are lower-case and in Bun's storage order. A header with the name __proto__ prints. undici leaves it out.
    • A File with a text type has ;charset=utf-8 in type, so one FormData line wraps.
    • Timeout: Bun stores five of node's fields. Node also has _idlePrev, _idleNext, _timerArgs and five symbol keys. With showHidden, Bun lists the tag of Timeout.prototype, as on main.
    • TextDecoder with showHidden: node adds Symbol(flags) and Symbol(handle).
  • Receivers, by decision.
    • A Proxy around an instance, and an object that inherits from one, print the state. Node throws a TypeError for six classes, because its state is in private fields.
    • A receiver with no instance gets one answer per class (the table). Node's answers are not uniform: Response { status: undefined, ... }, Object { aborted: undefined }, or a TypeError with the text of the engine.
  • Direct calls. method.call(x) with no options throws a TypeError on both, with other text. The TextDecoder method prints in Bun, because it reads no option. method.call(blob, -1, {}) returns [Blob]. Node returns the object, and its util.inspect makes [Blob] from it.
  • The name of a subclass past the depth limit. Bun's getPrefix prints [MyBlob [Blob]]. Node v26.3.0 prints [MyBlob]. Node removed that rule in v26.9.0 (nodejs/node#63257), and v26.11.1 prints [MyBlob [Blob]] too.

Also:

  • No trap of a Proxy around an instance runs. So a mock that answers url or status from a get trap (fetch-mock does) prints the state of the Response behind it. Node reads each field through the Proxy and prints the mocked value.
  • A data property with the name of a field is the field, as in node: on the value, on a receiver that inherits from it, or on the prototype of a subclass. An accessor is not called. Node calls it, so it prints the value of a getter that a subclass overrides, for Response, Request, Blob, TextDecoder and AbortController.
  • Past the depth limit, the search for the name of the constructor stops at a Proxy in the prototype chain. Node looks behind the Proxy, so it can print another name for a value with such a prototype.
  • A timer that was cleared keeps _idleTimeout and _onTimeout. Node sets them to -1 and null.
  • The body of a Response past the depth limit is ReadableStream {}, as a stream prints there in Bun. Node prints undefined.
  • Event.timeStamp is 0 in Bun. A Response from fetch() has type: 'default' (node: 'basic'). #33499 changes the second.
  • Bun.file() and an S3 file print Blob { type }. A print does no stat and no request. A file shows its size once the Blob has it: after a read of size or lastModified, or after exists(). On main an S3 file was Blob [S3File] {}.
  • util.inspect(x, { customInspect: false }) gives the text of main. A File is Blob {} there, because File has no prototype of its own (#30328, #14102, #25422). A value is a File when Bun's instanceof File says so (#35439, #14725, #43691).
  • A subclass prints the name of the class for Response, Request, Headers, FormData, Blob and File, and its own name for the other four, as in node. The Request of a routes handler is such a subclass. So is the Response of bun:app: its React element properties do not show, and type is the element's.

Console

  • console.log and Bun.inspect do not call the ten methods and do not list them. They look for a method as if the ten were not there, so a method that a program put on the value, or on a prototype above (EventTarget.prototype for an AbortSignal), is called as on main. Three states differ: a property of a program under the key on a prototype above is not in a listing of the value, an accessor for the key on a prototype above is not read, and a method behind a Proxy that a program made the prototype of one of the ten prototypes is not found. The bun:test printer calls no such method on main either. It does not list the ten.
  • For a value that the console prints itself, the text is byte-identical to main: 330 prints give 3,601 lines, and 0 differ (Measurements). That includes each X.prototype and the older methods.
  • This is a departure for these ten classes. A built-in method drives the console for the older classes (url.test.ts:254 and perf_hooks.test.ts:311 pin that). Here the console keeps its own text: it has a printer for Response, Request, Headers, FormData, Blob and Timeout, and it lists the properties of AbortSignal, AbortController, Event and TextDecoder, which shows reason, detail and data.
  • The console recognises the ten methods by the address of the host function. It leaves one out of a listing when the key is a symbol with the name of the method's key.
  • A method that the console does call can pass one of the ten to util.inspect. Then the text there has the state. That holds for a method of a program (@hono/node-server's Response printed headers: Headers {} and prints the header values now) and for an older built-in method (a PerformanceMark whose detail is a Blob, a stream reader whose closed promise rejected with a Response).
  • A program that adds its own method only when the key is missing (if (!(custom in Response.prototype))) no longer adds it. Then console.log prints Bun's text where it printed the program's.
  • In a node:worker_threads Worker, console.log is the node:console Console. So console.log(new Response(null)) there printed Response {} on main, and prints node's text now. #39592 changes that path.

Measurements

Release builds of main 5f5799ed16 and of this change on it, linux x64. perf, valgrind and strace are not in the build container. Instruction counts come from a PTRACE_SINGLESTEP counter between two calls of a marker host function, with BUN_JSC_useJIT=0, so JS code counts at the cost of the interpreter. Two runs of the build of main differ by up to 164 instructions per call on a row. Syscall counts come from a PTRACE_SYSCALL counter over all threads.

  • Per realm: +1 JSFunction, +1 NativeExecutable and +1 own key per prototype at first touch (ten prototypes: 87 to 97 functions). internal/util/inspect_hooks loads at the first call of one of the methods.
  • Per instance: +0 objects (100,000 held instances of Timeout, Event, Response, Headers: 2, 1, 1, 1 objects each on both builds). Untouched process: 1,939 objects on both builds.
  • Bun.inspect of one of the ten, instructions per call, with the count on main: Headers +216 (8,398), Response +337 (16,174), Request +373 (9,946), Blob +323 (2,875), Timeout +334 (3,513), FormData +335 (15,256), Event +985 (59,046), AbortSignal +1,017 (110,882), TextDecoder +828 (14,226), AbortController +1,766 (34,413). The console finds the method, sees that it is one of the ten, and looks for another one above it. The last four have no printer in the console: it lists their prototype chain, so it meets the new key there too.
  • Other values under Bun.inspect: a value with a method of a program +48, a Buffer +56, a ReadableStream +104 (the address check of the method that they have). A plain object +23 and an object with a symbol-keyed function -5: inside the noise.
  • util.inspect({a:1,b:[1,2,3]}) -52 and util.inspect(new A()) +25: inside the noise. internal/util/inspect.js changes in three places: the rethrow in formatRaw, a guard in the catch of a getter, and one more export.
  • util.inspect of one of the ten, instructions per call: Headers 106,635, Response 381,748, Request 650,922, Blob 135,297, File 173,961, FormData 187,870, Event 136,996, AbortSignal 108,244, AbortController 211,517, TextDecoder 126,536, Timeout 177,268. The existing URL method takes 332,116. On main, where the text is the shell: about 21,800 (43,100 for Event).
  • 1,000 util.inspect(Bun.file(p)) calls: 0 stat, lstat, newfstatat or statx on both builds, and no fstat of the file.
  • 100 util.inspect(request) calls on Requests that have no signal allocate 100 AbortSignal objects. After 1,200 more prints and a collection the count is what it was at the start. 100 util.inspect(response) calls allocate 0 ReadableStream objects.
  • Console text: 330 prints (instances, subclass instances, prototypes and nested values of the classes, and older classes with a method, with five Bun.inspect option sets and console.log) give 3,601 lines, and 0 differ between the two builds.
  • Binary: .text 80,758,501 to 80,781,797 B (+23,296). The section with the bundled JS grows 10,433 B. File 106,178,312 to 106,211,080 B (+32,768). 24 new functions have 25,031 B, and 26 existing ones change by -1,823 B in sum.
  • Overlap with open PRs (git merge-tree, the files that this branch adds as conflicts, measured on an earlier base): #44268 0, #39382 0, #30328 0, #39592 0, #33499 0, #41194 0, #44827 0. #40008 1 (the test file). #39207 1 (JSReadableStream.cpp). #41169 2 (the class generator). #35408 1 and #35483 3 (the Event method). #42015 1 (Response.rs). #40483 2 (Request.rs, webcore.rs). #41281 2 and #44833 2 (ConsoleObject.rs). #45261 2 (the rethrow line in inspect.js, the test file).

Tests

  • test/js/node/util/inspect-web-classes.test.ts uses node:test and node:assert, and holds node's text. Node v26.3.0 passes 95 of its 96 tests and skips the last one, which runs the file under Node.js. Bun passes 88 and has 8 todo. A release build of main fails 59. The last test runs the file under Node.js when the node on the path is the version that Bun reports (26.3.0): another version prints other text for some rows.
  • Its inventory lists 44 prototypes that own the method in node and that a program can reach from a global or a module export. 36 own it in Bun in that file: MessagePort gets it when node:worker_threads loads. The 8 todo rows are BlockList, EventTarget, Performance, PerformanceObserver, PerformanceObserverEntryList, SocketAddress, TextEncoder and X509Certificate. Node has the method on more prototypes that only an instance gives: Histogram, PerformanceNodeTiming, the URLSearchParams iterator, the timers list, the HTTP/2 session and stream, and vm.Module.
  • test/js/node/util/custom-inspect.test.js has the guarantees that only Bun makes: the console text, no code of the program in the native half, no header list or signal kept, Bun.serve sends the same response after a print (11 body kinds, GET and HEAD), a file shows a size only once it has one, no S3 request, a print that a node:vm timeout ends. 72 pass, also with BUN_JSC_validateExceptionChecks=1, with a random order, and in one process with the other file. A release build of main fails 20.
  • test-eventtarget.js and test-events-customevent.js (vendored) get their upstream assertions back. Both fail on main.
  • The other upstream files for these methods are not in the tree, and none passes in full yet:
Upstream filemainthis changeWhat is left
test-abortcontroller.js17 of 1918 of 19isTrusted is an own accessor of an event in Bun (#19492 has the file)
test-blob.jsstops at line 46the samenew Blob([], { type: 1 }).type, before the inspect assertions
test-file.jsstops at line 10the sameFile has no prototype of its own (#30328)
test-whatwg-encoding-custom-textdecoder.jsstops at line 113line 113, with node's text less two fieldsSymbol(flags) and Symbol(handle) under showHidden
  • Also ran on the debug build: response.test.ts, request.test.ts, headers.test.ts, blob.test.ts, console-log.test.ts, test/js/bun/util/inspect.test.js, bun-inspect.test.ts, util.test.js, node-timers.test.ts, text-decoder.test.js, abort.test.ts, url.test.ts, perf_hooks.test.ts, bun-serve-file.test.ts, and the Deno-derived files for Response, Request, Headers and Event. Also tsc for src/js.

Self-review

Four rounds: two full reviews of the diff and two targeted checks of the reworks. They raised 48, 44, 69 and 25 concerns, with duplicates. Each is fixed or is in the lists above, but one: the upstream test-abortcontroller.js is not added. It needs a gate on one subtest (isTrusted), the vendored files are kept as upstream has them, and #19492 has that file.

Related work

  • #35292 is the earlier version of this fix. A stale sweep closed it with no review of the design.
  • The Event method follows 3cf72ef9b4 by Ciro Spaciari (#35408, #35483). 80995e6f4d narrowed that method there, because it changed the text of Bun.inspect. Here the console does not call the method, so the text stays. The second of the two to merge drops its install in JSEvent.cpp.
  • #42015 rewrites Response::get_or_create_headers, which this change splits. The test row "the headers line is what response.headers gives" fails on a wrong merge of the two. #40483 (draft) changes Request.url and Request.headers after a synchronous response.
  • #41281 and #44833 (draft) change the console arm where the console skips the ten methods. #44268 rewrites the console formatter.
  • #41169 removes the inspectCustom option of the class generator as dead code. This change uses it, and removes publicSymbol, which it does not use.
  • #40008, #39382 and #39207 fix the older methods' tail (customInspect, utilInspectFunction). The ten new methods do not go through it.
  • #45261 gives formatRaw node's arm for a URL and has the same rethrow in its catch.
  • #30328 gives File its own prototype. The two tests that pin a File on Blob.prototype change with it.
  • #33499 (fetch type), #41194 (_onTimeout getter), #44827 (206 for a slice) own the values named above.

Left for later

  • The eight todo prototypes, and the ones that only an instance gives.
  • util.isDeepStrictEqual says two instances with other state are equal for Blob, File, AbortSignal, Event, TextDecoder and Timeout. Node says false.
  • Bun's own classes (Subprocess, the servers, sockets and 35 more) still print Name {} or Object [Name] {} under node:util. Immediate, MessageChannel, vm.Script and StringDecoder print empty too: node has no method there, it has own properties (#34609, #31802).
  • The URL and URLSearchParams methods do not return on a prototype cycle that a Proxy closes (node does not either). Handed off.
  • Bun.serve answers 200, not 206, for a slice of a Bun.file after something read response.headers, and console.log(response) reads it. The method here does not. Handed off.
  • The 21 older methods have the function name anonymous. Node's name is [nodejs.util.inspect.custom], as the ten new ones have (#35483 has that change).