Repository navigation
undici request() and ws server both throw 'Cannot read properties of undefined' at the transport layer despite compiling cleanly #10797
Description
Activity
- addedpackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 20, 2026 Re-examine these two before investing in them — two of the four blockers from the same probe wave have now turned out not to be what they were reported as.
- cheerio.load() returns a callable object that reports typeof 'function' but throws 'string "h2" is not a function' when invoked #10796 (cheerio) — reported as a callable-object dispatch defect. Could not be reproduced. Real cheerio from
dist/esmworks byte-identically to Node; the reported symptom came from routing around Cross-module inlining copiesEnum.Memberreferences into the importer: "enum member X.Y not found in enums table" #10417 (cross-module enum inlining) withPERRY_ALLOW_PARTIAL_CODEGEN=1, which produces a different failure. Cheerio's real blocker is Cross-module inlining copiesEnum.Memberreferences into the importer: "enum member X.Y not found in enums table" #10417. - class X extends Stream (the bare node:stream base) throws 'is not a constructor' — #10649 covers only Readable/Writable/Duplex/Transform; blocks nodemailer #10798 (nodemailer / bare
Stream) — theextends Streamgap was real and is fixed (PR fix(runtime): dispatch node:stream super() through the legacy Stream base #10805), but theTypeError: is not a constructorreported in the issue did not reproduce for any genuineextends Streamshape, including nodemailer's ownXOAuth2path, which already worked on pristinemain. That exact error came from something else entirely: requiring a compiled package's internal file from outside the package yields a class export withtypeof "object", which reproduces for a plain class with no heritage at all. Filed as perry.compilePackages: a compiled package's internal file, required from OUTSIDE the package, exports a class as typeof object (not function) — new X() throws 'is not a constructor' #10806.
What that implies for this issue
Both false readings share a cause: the probe exercised these packages in ways that differ from how a program actually consumes them — routing around a hard failure with a partial-codegen flag in one case, reaching into package internals in the other. Neither symptom was a lie; both were real observations of something other than the stated defect.
So before anyone spends a day on undici or ws:
- Reproduce each from a plain, ordinary consumer — a normal
import/requireof the package's public entry point, no partial-codegen flags, no reaching into internals, pinned to the version in[bindings.<pkg>.upstream]. - Check perry.compilePackages: a compiled package's internal file, required from OUTSIDE the package, exports a class as typeof object (not function) — new X() throws 'is not a constructor' #10806 first for
ws. Its reported failure isCannot read properties of undefined (reading 'destroy')immediately after a byte-correct handshake — andwsis exactly the kind of package where a server implementation reaches across internal module boundaries. If the probe's fixture touched internals, perry.compilePackages: a compiled package's internal file, required from OUTSIDE the package, exports a class as typeof object (not function) — new X() throws 'is not a constructor' #10806 could produce a symptom like this. - Check Cross-module inlining copies
Enum.Memberreferences into the importer: "enum member X.Y not found in enums table" #10417 for both, as cheerio's case showed a hard compile failure can be routed around into a misleading runtime error.
The underlying defects may well be real —
undici's failure before any TCP connect, andws's correct handshake followed by an undefined socket, are both specific and plausible. This is not a claim that they are artifacts. It is that two of four from this batch were, so the reproduction should be re-established from a clean consumer before the investigation is scoped.Method note worth keeping
A probe that reports "package X fails with error E" is reporting what it observed, not necessarily what a user would hit. The observation is still valuable — it is what surfaced #10806 and #10417's reach — but the framing "this package is blocked by E" carries an inference the probe did not test. Recording the fixture alongside the error, not just the error, would have made both of these visible immediately.
- cheerio.load() returns a callable object that reports typeof 'function' but throws 'string "h2" is not a function' when invoked #10796 (cheerio) — reported as a callable-object dispatch defect. Could not be reproduced. Real cheerio from
Re-established both probes from clean public-entry consumers on current
main(47ade47327), with the package versions from this issue and no partial-codegen flags or internal-file imports. Neither reported exception reproduces.ws@8.21.1Fixture imports
{ WebSocketServer, WebSocket }fromws, starts a local server, and connects a public client. It compiles and runs, but now stops at:Error: Unexpected server response: 426The original post-handshake
undefined.destroycrash does not occur.undici@8.9.0Fixture imports
{ request }fromundiciand requests a localnode:httpserver. The clean 111-module source graph reaches link, then is blocked by the llhttp WebAssembly dependency:undefined reference to js_webassembly_module_newThe original pre-connect
undefined.lengthruntime failure therefore is not currently reachable from the supported public consumer path.These are distinct current blockers, not evidence for the transport-object hypothesis in the issue, and they should be split/reframed before code changes are proposed. I am not opening a speculative/no-op PR for #10797.
Two HTTP/WebSocket packages compile from real source but fail at runtime, both before any network I/O completes. Filing together because both are transport-layer failures found in the same probe and may share a cause; split if they diverge.
Found on
origin/main(v0.5.1617),--profile perry-dev, Linux x64, versions pinned to each binding's[bindings.<pkg>.upstream]block.undici 8.9.0 — 112 modules, compiles, then throws before connecting
Thrown from
request()before any TCP connect is attempted, so it is not a socket or protocol problem — something is undefined in the request-construction path.ws 8.21.1 — handshake provably correct, crash immediately after
This one is unusually well-localised by the probe: the WebSocket handshake is byte-exact, with a correct RFC 6455
Sec-WebSocket-Acceptvalue computed and sent. The server then crashes before the'connection'event fires. So the protocol logic works and the failure is in what happens to the socket object immediately afterwards — something that should be a socket isundefinedat the point.destroy()is called on it.Why these are worth taking together
Both are
Cannot read properties of undefinedon an object that the package expects the runtime to have provided — a socket inws's case, something in the request path forundici. Both packages sit directly onnode:net/node:httpinternals rather than on the public surface. If Perry is under-populating an object that Node provides, one fix may resolve both. If they turn out to be unrelated, this should be split into two issues.Reproduction
Both: install the pinned version, add a
perry.compilePackagesentry, and exercise the primary documented use —request()against a local server for undici; aWebSocketServerplus a client connection for ws. Compare againstnode --experimental-strip-typeson Node 26.5.1.Note on how these were found
Both packages passed a static pre-screen for every known blocker shape (
export =+ namespace, conditionalrequire,new Functionwith class expressions, bound-export native heritage,require.main). They failed anyway. That is the screen working as designed — it narrows where to look and predicts nothing about what it has not seen before — and it is why compiling is not optional before declaring a binding removable.