Skip to content

fix(runtime): subclassing native Request/Response exposes working body methods#4756

Merged
proggeramlug merged 1 commit into
mainfrom
fix/request-subclass-body
Jun 7, 2026
Merged

fix(runtime): subclassing native Request/Response exposes working body methods#4756
proggeramlug merged 1 commit into
mainfrom
fix/request-subclass-body

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Problem

Subclassing the native global Request (or Response) produced instances that were missing the body-reading methods (text/json/arrayBuffer/blob/formData/bytes), missing the inherited property getters, and sub instanceof Request was false:

const Sub = class extends Request {};
const sub = new Sub("http://x/y", { method: "POST", body: "hello" });
typeof sub.text            // was "undefined"  → now "function"
sub instanceof Request     // was false        → now true
await sub.text()           // threw            → now "hello"

This breaks @hono/node-server, which does class Request extends GlobalRequest { … }, so every c.req.text() / c.req.json() on a POST/PUT route threw, turning webhooks and form posts into 500s.

Root cause

A Web-Fetch Request/Response in Perry is not a heap JS object — it's a native registry handle (a small id NaN-boxed as a pointer) whose methods are resolved by native handle dispatch, not via a JS prototype chain. class X extends Request {} + new X(...) produced an ordinary heap JS object with no link to any native handle: property/method lookup walked the JS prototype chain, found nothing (the body methods live in native dispatch, not on Request.prototype), and returned undefined.

Fix

super(...) on a Request/Response parent now allocates the underlying native handle and stashes its id on this under the hidden field __perry_fetch_handle__ — mirroring the Web-Streams __perry_stream_handle__ mechanism (#562). Wired across construction paths:

  • direct class extends Request/Response: codegen Expr::SuperCall arm + the no-constructor lower_new hook → js_request_subclass_init / js_response_subclass_init (object/global_this.rs);
  • aliased parent (extends GlobalRequest where GlobalRequest = global.Request): the runtime-value super() routes through a new js_fetch_or_value_super, which identifies the parent constructor via identify_global_builtin_constructor and attaches (falling back to the ordinary js_native_call_value for every other runtime-value parent);
  • dynamic class-expression / ClassRef construction: a fetch-parent side table recorded at js_register_class_parent_dynamic drives a construction-time attach in js_new_function_construct.

At access time:

  • method calls (native_call_method.rs): inherited body methods on a subclass receiver are forwarded to the native handle dispatcher (only after all user-defined dispatch — own fields, vtable, prototype walk — has missed, so a subclass override still wins);
  • property reads (field_get_set.rs): a missed read forwards to the handle, so url/method/headers/body/bodyUsed/… and body-method-as-value reads resolve;
  • instanceof (instanceof.rs): the Request/Response/Headers/Blob arm unwraps the stashed handle and runs the existing fetch kind-probe.

Non-subclassed Request/Response and other native-builtin subclasses are unaffected — the new paths only trigger when a heap object carries __perry_fetch_handle__.

Verification

  • Minimal repro passes (the exact one from the issue): typeof sub.text"function", sub instanceof Requesttrue, await sub.text()"hello".
  • New regression test tests/test_request_subclass_body.sh (style of existing tests/test_*.sh, prints PASS/FAIL): subclasses native Request as both a no-constructor class and an explicit-constructor class, asserts .text()/.json() round-trip a body and instanceof Request. PASS.
  • Aliased class … extends GlobalRequest with an explicit constructor (the @hono/node-server shape) verified to expose working body methods when constructed via the static path.

Known remaining gaps for the full @hono/node-server end-to-end (separate from this dispatch bug)

While validating against a real Hono app I found two distinct, pre-existing gaps that also stand between this fix and a green webhook, and are larger than the documented dispatch bug:

  1. Request does not read a ReadableStream body. The body coercion stringifies the stream handle: new Request(url, { body: stream }).text() returns the handle id (e.g. "1048576") instead of the bytes. @hono/node-server always wraps the incoming body as Readable.toWeb(incoming) (a pull-based stream), so even with dispatch fixed, c.req.text() returns garbage. Fixing this needs the request-body coercion to drain a (pull-based) web stream.
  2. Deeply-nested dynamic construction of an aliased class-expression Request (node-server's var Request = class extends GlobalRequest {…}, built lazily inside getRequestCache()) does not yet wire the fetch parent in every path. This PR adds the dynamic-construction side table toward it, but the node-server shape still needs follow-up.

These are tracked as follow-ups; this PR fixes the documented root cause (subclass instances missing body methods) with a passing regression test.

…y methods (v0.5.1130)

A Web-Fetch Request/Response in Perry is a native registry handle (a small id
NaN-boxed as a pointer) whose methods are resolved by native handle dispatch,
not via a JS prototype chain. `class X extends Request {}` + `new X(...)`
produced an ordinary heap JS object with no link to a native handle, so
`sub.text` was `undefined`, calling it threw, and `sub instanceof Request` was
`false`. This breaks `@hono/node-server` (`class Request extends GlobalRequest`).

Fix: `super(...)` on a Request/Response parent now allocates the underlying
native handle and stashes its id on `this` under `__perry_fetch_handle__`
(mirrors the Web-Streams `__perry_stream_handle__` mechanism). Wired across all
construction paths:
- direct `class extends Request/Response`: codegen super arm + lower_new
  no-ctor hook -> js_request/response_subclass_init;
- aliased parent (`extends GlobalRequest`, where GlobalRequest = global.Request):
  runtime-value super() routes through js_fetch_or_value_super, which identifies
  the parent constructor and attaches;
- dynamic class-expression / ClassRef construction: a fetch-parent side table
  (recorded at js_register_class_parent_dynamic) drives a construction-time
  attach in js_new_function_construct.

At access time, body methods (call + value read), inherited property getters,
and `instanceof` on a subclass instance are forwarded to the native handle.
Non-subclassed Request/Response and other native-builtin subclasses are
unaffected. Regression test: tests/test_request_subclass_body.sh.
@proggeramlug proggeramlug force-pushed the fix/request-subclass-body branch from 50fd692 to ef270ae Compare June 7, 2026 11:14
@proggeramlug proggeramlug merged commit b6e4e87 into main Jun 7, 2026
13 checks passed
@proggeramlug proggeramlug deleted the fix/request-subclass-body branch June 7, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant