util.inspect: print the state of Response, Request, Headers, Blob and six more classes
Problem
util.inspect(new Response("x"))returnsResponse {}.Request,Headers,FormData,Blob,File,AbortSignal,AbortController,Event,TextDecoderandTimeoutprint empty too. So doesconsole.login anode:worker_threadsWorker.- Their state is in native fields.
formatValue(src/js/internal/util/inspect.js:1242) prints own properties or callsvalue[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_hookshas 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.inspectcallsvalue[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
formatValuebranch to Bun's printer: it gives Bun's console text and adds work per value.
Downsides
util.inspectdrops properties a program added (req.user), as node does.- Loggers on
node:utilnow print header values, URLs and form fields. So does a method that passes one toinspect. console.logof one of the ten costs 216 to 1,766 more instructions (1% to 11%). Another value with a callable method: about 50..textgrows 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 fromthis, the depth rule, the option changes, and the name of the constructor. It readsconstructor.nameas node does, so a getter that a program put there runs. - The older methods (streams,
URL,CryptoKeyand others) build their text in C++, incustomInspect. The ten new ones need more than it does (undici's option changes, names that differ by class, the names read fromthis), 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
ReadableStreamitself is not changed. - Follow-up, if the assignee agrees: move the 19 callers of
customInspect, and the copies inBroadcastChannel,URLSearchParamsandPerformanceEntry, to the JS tail, and deletecustomInspect. That replaces the method hunks of #40008, #39382 and #39207.
What each class prints
| Class | Text | Past the depth limit | A receiver that holds no instance |
|---|---|---|---|
Headers | Headers { 'x-request-id': 'abc123' } | the same text | throws ERR_INVALID_THIS |
Response | Response { status, statusText, headers, body, bodyUsed, ok, redirected, type, url } | the same text | returned as it is |
Request | Request { method, url, headers, destination, referrer, referrerPolicy, mode, credentials, cache, redirect, integrity, signal } | the same text, with signal: [AbortSignal] | returned as it is |
FormData | FormData { user: 'ada' } | the same text | throws ERR_INVALID_THIS |
Blob, File | Blob { size, type }, File { size, type, name, lastModified } | [Blob], [File] | returned as it is |
AbortSignal | AbortSignal { aborted: false } | [AbortSignal] | returned as it is |
AbortController | AbortController { signal: AbortSignal { aborted: false } } | AbortController {} | returned as it is |
Event and its subclasses | CustomEvent { type, defaultPrevented, cancelable, timeStamp } | CustomEvent | throws ERR_INVALID_THIS |
TextDecoder | TextDecoder { encoding, fatal, ignoreBOM } | [TextDecoder] | throws ERR_INVALID_THIS |
Timeout | Timeout { _idleTimeout, _idleStart, _onTimeout, _repeat, _destroyed, ...own properties } | the same text | printed as a plain value |
- A
nullorundefinedreceiver throwsERR_INVALID_THISfor the nine web classes. - The error is the same one inside another value.
util.inspectwrapped every error from a nested method in anAssertionError. Node rethrows it (nodejs/node#55544), and this change does the same informatRaw, for the methods of a program too. #45261 makes the same change to that line. Withgetters: true, the catch of a getter now meets such a value: it printsnullandundefinedas node does, and a thrown primitive asundefined, as on main (node v26 prints the value). - Where node's depth does not count down (a
Response, aRequest, aTextDecoder, 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]], whichinspectpasses 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:referreris''andcredentialsis'include'. Bun has nokeepalive,isReloadNavigationorisHistoryNavigation.bodyshows asnull, or as the stream when the Response has one. Bun makes the stream at the first read ofresponse.body, so the line can appear later. For a string body,headershas nocontent-type: that is the state ofresponse.headersin 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
Filewith a text type has;charset=utf-8intype, so oneFormDataline wraps. Timeout: Bun stores five of node's fields. Node also has_idlePrev,_idleNext,_timerArgsand five symbol keys. WithshowHidden, Bun lists the tag ofTimeout.prototype, as on main.TextDecoderwithshowHidden: node addsSymbol(flags)andSymbol(handle).
- Receivers, by decision.
- A
Proxyaround an instance, and an object that inherits from one, print the state. Node throws aTypeErrorfor 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 aTypeErrorwith the text of the engine.
- A
- Direct calls.
method.call(x)with no options throws aTypeErroron both, with other text. TheTextDecodermethod prints in Bun, because it reads no option.method.call(blob, -1, {})returns[Blob]. Node returns the object, and itsutil.inspectmakes[Blob]from it. - The name of a subclass past the depth limit. Bun's
getPrefixprints[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
Proxyaround an instance runs. So a mock that answersurlorstatusfrom agettrap (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,TextDecoderandAbortController. - Past the depth limit, the search for the name of the constructor stops at a
Proxyin 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
_idleTimeoutand_onTimeout. Node sets them to -1 andnull. - The body of a Response past the depth limit is
ReadableStream {}, as a stream prints there in Bun. Node printsundefined. Event.timeStampis 0 in Bun. A Response fromfetch()hastype: 'default'(node:'basic'). #33499 changes the second.Bun.file()and an S3 file printBlob { type }. A print does nostatand no request. A file shows its size once the Blob has it: after a read ofsizeorlastModified, or afterexists(). On main an S3 file wasBlob [S3File] {}.util.inspect(x, { customInspect: false })gives the text of main. AFileisBlob {}there, becauseFilehas no prototype of its own (#30328, #14102, #25422). A value is aFilewhen Bun'sinstanceof Filesays so (#35439, #14725, #43691).- A subclass prints the name of the class for
Response,Request,Headers,FormData,BlobandFile, and its own name for the other four, as in node. The Request of arouteshandler is such a subclass. So is the Response ofbun:app: its React element properties do not show, andtypeis the element's.
Console
console.logandBun.inspectdo 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.prototypefor anAbortSignal), 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 aProxythat a program made the prototype of one of the ten prototypes is not found. Thebun:testprinter 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.prototypeand 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:254andperf_hooks.test.ts:311pin that). Here the console keeps its own text: it has a printer forResponse,Request,Headers,FormData,BlobandTimeout, and it lists the properties ofAbortSignal,AbortController,EventandTextDecoder, which showsreason,detailanddata. - 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 printedheaders: Headers {}and prints the header values now) and for an older built-in method (aPerformanceMarkwhosedetailis a Blob, a stream reader whoseclosedpromise 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. Thenconsole.logprints Bun's text where it printed the program's. - In a
node:worker_threadsWorker,console.logis thenode:consoleConsole. Soconsole.log(new Response(null))there printedResponse {}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, +1NativeExecutableand +1 own key per prototype at first touch (ten prototypes: 87 to 97 functions).internal/util/inspect_hooksloads 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.inspectof 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, aBuffer+56, aReadableStream+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 andutil.inspect(new A())+25: inside the noise.internal/util/inspect.jschanges in three places: the rethrow informatRaw, a guard in the catch of a getter, and one more export.util.inspectof one of the ten, instructions per call:Headers106,635,Response381,748,Request650,922,Blob135,297,File173,961,FormData187,870,Event136,996,AbortSignal108,244,AbortController211,517,TextDecoder126,536,Timeout177,268. The existingURLmethod takes 332,116. On main, where the text is the shell: about 21,800 (43,100 forEvent).- 1,000
util.inspect(Bun.file(p))calls: 0stat,lstat,newfstatatorstatxon both builds, and nofstatof the file. - 100
util.inspect(request)calls on Requests that have no signal allocate 100AbortSignalobjects. After 1,200 more prints and a collection the count is what it was at the start. 100util.inspect(response)calls allocate 0ReadableStreamobjects. - Console text: 330 prints (instances, subclass instances, prototypes and nested values of the classes, and older classes with a method, with five
Bun.inspectoption sets andconsole.log) give 3,601 lines, and 0 differ between the two builds. - Binary:
.text80,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 (theEventmethod). #42015 1 (Response.rs). #40483 2 (Request.rs,webcore.rs). #41281 2 and #44833 2 (ConsoleObject.rs). #45261 2 (the rethrow line ininspect.js, the test file).
Tests
test/js/node/util/inspect-web-classes.test.tsusesnode:testandnode: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 8todo. A release build of main fails 59. The last test runs the file under Node.js when thenodeon 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:
MessagePortgets it whennode:worker_threadsloads. The 8todorows areBlockList,EventTarget,Performance,PerformanceObserver,PerformanceObserverEntryList,SocketAddress,TextEncoderandX509Certificate. Node has the method on more prototypes that only an instance gives:Histogram,PerformanceNodeTiming, theURLSearchParamsiterator, the timers list, the HTTP/2 session and stream, andvm.Module. test/js/node/util/custom-inspect.test.jshas 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.servesends 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 anode:vmtimeout ends. 72 pass, also withBUN_JSC_validateExceptionChecks=1, with a random order, and in one process with the other file. A release build of main fails 20.test-eventtarget.jsandtest-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 file | main | this change | What is left |
|---|---|---|---|
test-abortcontroller.js | 17 of 19 | 18 of 19 | isTrusted is an own accessor of an event in Bun (#19492 has the file) |
test-blob.js | stops at line 46 | the same | new Blob([], { type: 1 }).type, before the inspect assertions |
test-file.js | stops at line 10 | the same | File has no prototype of its own (#30328) |
test-whatwg-encoding-custom-textdecoder.js | stops at line 113 | line 113, with node's text less two fields | Symbol(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 forResponse,Request,HeadersandEvent. Alsotscforsrc/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
Eventmethod follows 3cf72ef9b4 by Ciro Spaciari (#35408, #35483). 80995e6f4d narrowed that method there, because it changed the text ofBun.inspect. Here the console does not call the method, so the text stays. The second of the two to merge drops its install inJSEvent.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) changesRequest.urlandRequest.headersafter 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
inspectCustomoption of the class generator as dead code. This change uses it, and removespublicSymbol, 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
formatRawnode's arm for aURLand has the same rethrow in its catch. - #30328 gives
Fileits own prototype. The two tests that pin aFileonBlob.prototypechange with it. - #33499 (fetch
type), #41194 (_onTimeoutgetter), #44827 (206 for a slice) own the values named above.
Left for later
- The eight
todoprototypes, and the ones that only an instance gives. util.isDeepStrictEqualsays two instances with other state are equal forBlob,File,AbortSignal,Event,TextDecoderandTimeout. Node saysfalse.- Bun's own classes (
Subprocess, the servers, sockets and 35 more) still printName {}orObject [Name] {}undernode:util.Immediate,MessageChannel,vm.ScriptandStringDecoderprint empty too: node has no method there, it has own properties (#34609, #31802). - The
URLandURLSearchParamsmethods do not return on a prototype cycle that a Proxy closes (node does not either). Handed off. Bun.serveanswers 200, not 206, for a slice of aBun.fileafter something readresponse.headers, andconsole.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).