From 2f903f0255d59851f17784918a5cbe61233656fa Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 01:13:24 -0700 Subject: [PATCH] Read native handle methods as function values Methods of Web objects held in untyped code (Request, Response, Headers, ReadableStream, its reader, AbortSignal) answered member calls only by static name, so a property read such as hono's `raw[key]()` or `typeof req.json` saw undefined. A handle class now lists the methods its invoke answers; a read returns one shared function per method that calls invoke on its `this` and raises Node's ERR_INVALID_THIS TypeError (or a rejected promise for the async operations) on another receiver. The in operator sees the methods and the modeled properties. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_dyn.h | 23 +++++ packages/runtime/src/scr_fetch.c | 64 +++++++++++-- packages/runtime/src/scr_json.c | 87 ++++++++++++++++- tests/corpus/7740-handle-method-values.js | 110 ++++++++++++++++++++++ 4 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 tests/corpus/7740-handle-method-values.js diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 553b897d2..5251c6f6c 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1472,6 +1472,19 @@ ScrDyn *scr_dyn_visit_view(const ScrDyn *capsule); * (scr_http_dyn_install / scr_net_dyn_install — emitted main() calls * them exactly when the unit is linked, the scr_net_install story), so * this always-linked core never references gated units. */ +/* A method a handle class answers through invoke, read as a value + * (`typeof req.json`, `raw[key]()`): the read answers one function per + * (tag, method) that calls invoke on its `this`. length is the method's + * Function.prototype.length; rejects marks a method whose call on another + * receiver answers a rejected promise instead of throwing (the async + * WebIDL operations: Body's json(), ReadableStream's cancel()). */ +typedef struct ScrDynHandleMethod { + const char *name; + uint32_t length; + bool rejects; + const char *invalid_this; /* NULL = the class's (ScrDynHandleOps) */ +} ScrDynHandleMethod; + typedef struct ScrDynHandleOps { const char *cls; /* Node's constructor name — error texts and dynCheck's "got ..." */ void *(*retain)(void *h); @@ -1537,6 +1550,16 @@ typedef struct ScrDynHandleOps { * stream unit builds the handle's Transform from these operations on its * first stream use. NULL = the class is not a stream. */ const struct ScrDynHandleTransform *transform; + /* The class's methods as values, terminated by a NULL name; NULL = a + * property read answers no methods (member calls still reach invoke). + * See ScrDynHandleMethod. */ + const ScrDynHandleMethod *methods; + /* The TypeError (code ERR_INVALID_THIS) a method value called on another + * receiver raises; NULL = WebIDL's "Illegal invocation". */ + const char *invalid_this; + /* A promise rejected with the pending exception (+1), for the rejects + * methods; the owning unit supplies it, since promises are a gated unit. */ + ScrDyn *(*rejected)(void); } ScrDynHandleOps; /* The operations a Transform handle gives the stream unit (scr_stream.c): diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index 7cc2beef4..916fc6bf9 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -4990,7 +4990,7 @@ static ScrDyn *sf_request_get(void *ptr, const char *key, size_t len) { if (default_value) { ScrStr *s = scr_str_new(default_value, strlen(default_value)); ScrDyn *v = scr_dyn_new_str(s); scr_str_release(s); return v; } if (sf_name(key, len, "keepalive") || sf_name(key, len, "isReloadNavigation") || sf_name(key, len, "isHistoryNavigation")) return scr_dyn_new_bool(false); if (sf_name(key, len, "body") || sf_name(key, len, "bodyUsed") || sf_name(key, len, "headers")) return sf_response_get(r->payload, key, len); - return scr_dyn_undefined(); + return NULL; } static ScrDyn *sf_request_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { SfRequest *r = ptr; @@ -5495,33 +5495,73 @@ static bool sf_no_set(void *ptr, const char *key, size_t len, return false; } +/* The members invoke answers, as values (ScrDynHandleMethod), with Node's + * lengths and the brand check each one runs on another receiver. */ +static const ScrDynHandleMethod sf_signal_methods[] = { + {"throwIfAborted", 0, false, + "Value of \"this\" must be of type AbortSignal"}, + {"addEventListener", 2, false}, + {"removeEventListener", 2, false}, {"dispatchEvent", 1, false}, + {NULL, 0, false}}; +static const ScrDynHandleMethod sf_stream_methods[] = { + {"cancel", 0, true}, {"getReader", 0, false}, {NULL, 0, false}}; +static const ScrDynHandleMethod sf_reader_methods[] = { + {"read", 0, true}, {"releaseLock", 0, false}, {"cancel", 0, true}, + {NULL, 0, false}}; +static const ScrDynHandleMethod sf_body_methods[] = { + {"json", 0, true}, {"text", 0, true}, {"bytes", 0, true}, + {"arrayBuffer", 0, true}, {NULL, 0, false}}; +static const ScrDynHandleMethod sf_headers_methods[] = { + {"append", 2, false}, {"delete", 1, false}, {"entries", 0, false}, + {"forEach", 1, false}, {"get", 1, false}, {"getSetCookie", 0, false}, + {"has", 1, false}, {"keys", 0, false}, {"set", 2, false}, + {"values", 0, false}, {NULL, 0, false}}; + +static ScrDyn *sf_rejected(void) { + ScrPromise *p = scr_promise_settled_ref(NULL, &scr_dyn_retain_v, + &scr_dyn_release_v, NULL); + ScrDyn *out = scr_dyn_new_promise(p); + scr_promise_release(p); + return out; +} + static const ScrDynHandleOps sf_signal_ops = { "AbortSignal", &sf_signal_retain_v, &sf_signal_release_v, - &sf_signal_invoke, &sf_signal_get, &sf_signal_set, NULL}; + &sf_signal_invoke, &sf_signal_get, &sf_signal_set, NULL, + .methods = sf_signal_methods, + .invalid_this = "Value of \"this\" must be of type EventTarget"}; static const ScrDynHandleOps sf_abort_controller_ops = { "AbortController", &sf_signal_retain_v, &sf_signal_release_v, &sf_abort_controller_invoke, &sf_abort_controller_get, &sf_no_set, NULL}; static const ScrDynHandleOps sf_stream_ops = { "ReadableStream", &sf_stream_retain_v, &sf_stream_release_v, - &sf_stream_invoke, &sf_stream_get, &sf_no_set, NULL}; + &sf_stream_invoke, &sf_stream_get, &sf_no_set, NULL, + .methods = sf_stream_methods, + .invalid_this = "Value of \"this\" must be of type ReadableStream", + .rejected = &sf_rejected}; static const ScrDynHandleOps sf_reader_ops = { "ReadableStreamDefaultReader", &sf_reader_handle_retain_v, &sf_reader_handle_release_v, &sf_reader_invoke, &sf_reader_get, - &sf_no_set, NULL}; + &sf_no_set, NULL, .methods = sf_reader_methods, + .invalid_this = + "Value of \"this\" must be of type ReadableStreamDefaultReader", + .rejected = &sf_rejected}; static const ScrDynHandleOps sf_controller_ops = { "ReadableStreamDefaultController", &sf_stream_retain_v, &sf_stream_release_v, &sf_controller_invoke, &sf_controller_get, &sf_no_set, NULL}; static const ScrDynHandleOps sf_request_ops = { "Request", &sf_request_retain_v, &sf_request_release_v, - &sf_request_invoke, &sf_request_get, &sf_no_set, NULL}; + &sf_request_invoke, &sf_request_get, &sf_no_set, NULL, + .methods = sf_body_methods, .rejected = &sf_rejected}; static const ScrDynHandleOps sf_response_ops = { "Response", &sf_response_retain_v, &sf_response_release_v, - &sf_response_invoke, &sf_response_get, &sf_no_set, NULL}; + &sf_response_invoke, &sf_response_get, &sf_no_set, NULL, + .methods = sf_body_methods, .rejected = &sf_rejected}; static const ScrDynHandleOps sf_headers_ops = { "Headers", &sf_headers_retain_v, &sf_headers_release_v, &sf_headers_invoke, &sf_headers_get, &sf_no_set, NULL, NULL, - &sf_headers_step}; + &sf_headers_step, .methods = sf_headers_methods}; static void sf_teardown(void) { while (sf_live) { @@ -5572,9 +5612,17 @@ static bool sf_no_set(void *ptr, const char *key, size_t len, const ScrDyn *valu (void)ptr; (void)key; (void)len; (void)value; return false; } +static const ScrDynHandleMethod sf_signal_methods[] = { + {"throwIfAborted", 0, false, + "Value of \"this\" must be of type AbortSignal"}, + {"addEventListener", 2, false}, + {"removeEventListener", 2, false}, {"dispatchEvent", 1, false}, + {NULL, 0, false}}; static const ScrDynHandleOps sf_signal_ops = { "AbortSignal", &sf_signal_retain_v, &sf_signal_release_v, - &sf_signal_invoke, &sf_signal_get, &sf_signal_set, NULL }; + &sf_signal_invoke, &sf_signal_get, &sf_signal_set, NULL, + .methods = sf_signal_methods, + .invalid_this = "Value of \"this\" must be of type EventTarget" }; static const ScrDynHandleOps sf_abort_controller_ops = { "AbortController", &sf_signal_retain_v, &sf_signal_release_v, &sf_abort_controller_invoke, &sf_abort_controller_get, &sf_no_set, NULL }; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 615785687..5fab5d26e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4975,19 +4975,103 @@ ScrDyn *scr_dyn_promise_key_get(const ScrDyn *d, const ScrStr *k) { return method ? method : scr_dyn_retain(scr_dyn_undefined()); } +/* ── handle methods as values (ScrDynHandleMethod) ────────────────── + * One function per (tag, method), as Node's prototype methods are shared + * by every instance. The function calls invoke on its `this` when that is + * a handle of its tag, and otherwise raises the class's ERR_INVALID_THIS + * TypeError (or answers a promise rejected with it), as the WebIDL brand + * checks do. closure->fn holds the method entry, caps[0] the tag. */ +static SCR_TL ScrDyn **scr_dynh_method_values[SCR_DYNH_CAPACITY]; +static SCR_TL bool scr_dynh_method_values_registered; + +static void scr_dynh_method_values_cleanup(void) { + for (size_t tag = 0; tag < SCR_DYNH_CAPACITY; tag++) { + ScrDyn **values = scr_dynh_method_values[tag]; + if (!values) continue; + scr_dynh_method_values[tag] = NULL; + const ScrDynHandleMethod *methods = scr_dynh_ops[tag]->methods; + for (size_t i = 0; methods[i].name; i++) + if (values[i]) scr_dyn_release(values[i]); + free(values); + } +} + +static ScrDyn *scr_dynh_method_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + const ScrDynHandleMethod *method = closure->fn; + ScrDynHandleTag tag = (ScrDynHandleTag)scr_box_get_f64(closure->caps[0]); + const ScrDynHandleOps *ops = scr_dyn_handle_ops(tag); + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result; + if (self->kind == SCR_DYN_HANDLE && self->v.handle.tag == tag) { + result = ops->invoke(self->v.handle.ptr, self, method->name, args, argc, method->name); + } else { + const char *message = method->invalid_this ? method->invalid_this + : ops->invalid_this ? ops->invalid_this + : "Illegal invocation"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, strlen(message), "ERR_INVALID_THIS"); + result = method->rejects && ops->rejected ? ops->rejected() : NULL; + } + scr_dyn_release(self); + return result; +} + +/* The method `key` of the handle's class as a function value (+1), or NULL + * when the class has no such method. */ +static ScrDyn *scr_dynh_method_value(ScrDynHandleTag tag, const ScrDynHandleOps *ops, const char *key, size_t len) { + const ScrDynHandleMethod *methods = ops->methods; + if (!methods || !ops->invoke) return NULL; + for (size_t i = 0; methods[i].name; i++) { + if (strlen(methods[i].name) != len || memcmp(methods[i].name, key, len) != 0) continue; + if (!scr_dynh_method_values[tag]) { + size_t count = i + 1; + while (methods[count].name) count++; + scr_dynh_method_values[tag] = calloc(count, sizeof(ScrDyn *)); + if (!scr_dynh_method_values[tag]) scr_trap("scriptc: out of memory\n"); + if (!scr_dynh_method_values_registered) { + scr_dynh_method_values_registered = true; + scr_atexit(scr_dynh_method_values_cleanup); + } + } + ScrDyn **slot = &scr_dynh_method_values[tag][i]; + if (!*slot) { + ScrClosure *closure = scr_closure_new((void *)&methods[i], 1); + closure->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(closure->caps[0], (double)tag); + *slot = scr_dyn_new_func(closure, scr_dynh_method_call, methods[i].length, "", methods[i].name); + } + return scr_dyn_retain(*slot); + } + return NULL; +} + ScrDyn *scr_dyn_handle_key_get(const ScrDyn *d, const ScrStr *k) { - const ScrDynHandleOps *ops = scr_dyn_handle_ops(d->v.handle.tag); + ScrDynHandleTag tag = d->v.handle.tag; + const ScrDynHandleOps *ops = scr_dyn_handle_ops(tag); ScrDyn *r = ops->get(d->v.handle.ptr, k->data, k->len); if (scr_exc_pending()) { scr_dyn_release(r); return NULL; } + if (!r) r = scr_dynh_method_value(tag, ops, k->data, k->len); /* Unmodeled names answer undefined — the checked-dynamic tree's own-property stance * (real-but-unmodeled members throw "not supported yet" inside ops->get instead; * SEMANTICS.md documents the remainder). */ return r ? r : scr_dyn_retain(scr_dyn_undefined()); } +/* `key in handle`: the class's methods, and the properties its get models. */ +static bool scr_dyn_handle_in(const ScrDyn *d, const ScrStr *k) { + const ScrDynHandleOps *ops = scr_dyn_handle_ops(d->v.handle.tag); + const ScrDynHandleMethod *methods = ops->invoke ? ops->methods : NULL; + for (size_t i = 0; methods && methods[i].name; i++) + if (strlen(methods[i].name) == k->len && memcmp(methods[i].name, k->data, k->len) == 0) return true; + if (!methods) return false; + ScrDyn *value = ops->get(d->v.handle.ptr, k->data, k->len); + if (!value) return scr_dyn_object_proto_name(k); + scr_dyn_release(value); + return true; +} + /* ── the ambient receiver (scr_runtime.h's design note) ─────────────── * A strictly nested push/pop stack: firing sites bind the owner around * each listener call, dyn OBJ method dispatch binds the object, and the @@ -9196,6 +9280,7 @@ bool scr_dyn_in(const ScrDyn *v, const ScrStr *key) { } if (v->kind == SCR_DYN_PROXY) return scr_dyn_proxy_has(v, key); if (v->kind == SCR_DYN_BYTES) return scr_dyn_bytes_in(v, key); + if (v->kind == SCR_DYN_HANDLE) return scr_dyn_handle_in(v, key); if (scr_dyn_has_key(v, key)) return true; if (scr_exc_pending()) return false; if (v->kind == SCR_DYN_OBJ) return scr_dyn_builtin_inherited(v, key); diff --git a/tests/corpus/7740-handle-method-values.js b/tests/corpus/7740-handle-method-values.js new file mode 100644 index 000000000..3694c204f --- /dev/null +++ b/tests/corpus/7740-handle-method-values.js @@ -0,0 +1,110 @@ +// Methods of native Web objects read as values in untyped code: hono's +// HonoRequest calls `raw[key]()` with a computed body-method name, and code +// probes `typeof x.json` or `"get" in headers`. Each method is one shared +// function that runs on its `this`; another receiver fails Node's brand check. +const report = (label, e) => console.log(label, e.name, e.message, e.code); +// An untyped holder, as values reach a JavaScript library. +const untyped = (value) => { + const holder = JSON.parse("{}"); + holder.value = value; + return holder.value; +}; + +async function bodies() { + const holder = JSON.parse("{}"); + holder.req = new Request("http://example.test/books", { method: "POST", body: '{"title":"Dune"}' }); + const cache = {}; + const cachedBody = (key) => { + const raw = holder.req; + if (cache[key]) return cache[key]; + return (cache[key] = raw[key]()); + }; + console.log(await cachedBody("json")); + for (const key of ["text", "arrayBuffer", "bytes"]) { + const res = new Response("abc"); + const value = await res[key](); + console.log(key, typeof value, value instanceof ArrayBuffer, value.length ?? value.byteLength); + } +} + +function shape() { + const req = JSON.parse("{}"); + req.value = new Request("http://example.test/"); + const r = req.value; + console.log(typeof r.json, typeof r.text, typeof r.blobby, typeof r.url); + console.log("json" in r, "url" in r, "toString" in r, "missing" in r); + const other = JSON.parse("{}"); + other.res = new Response("x"); + console.log(r.json === r.json, r.json === other.res.json, r.json === r.text); + console.log(r.json.name, r.json.length, r.arrayBuffer.name); + const box = { h: untyped(new Headers({ a: "1" })) }; + console.log(box.h.append.length, box.h.forEach.length, box.h.get.name, "has" in box.h, "nope" in box.h); + const name = "get"; + console.log(box.h[name]("a"), box.h["has"]("b")); + box.h["set"]("b", "2"); + const entries = []; + box.h["forEach"]((v, k) => entries.push(k + "=" + v)); + console.log(entries.join(",")); +} + +async function receivers() { + const box = { + h: untyped(new Headers({ a: "1" })), + req: untyped(new Request("http://example.test/", { method: "POST", body: "x" })), + }; + const get = box.h.get; + console.log(get.call(box.h, "a"), get.bind(box.h)("a"), get.apply(box.h, ["a"])); + try { + get("a"); + } catch (e) { + report("unbound get", e); + } + try { + get.call(box.req, "a"); + } catch (e) { + report("foreign get", e); + } + const text = box.req.text; + const pending = text(); + console.log("text returns", pending instanceof Promise); + await pending.then( + () => console.log("unexpected"), + (e) => report("unbound text", e), + ); + await text.call(box.h).catch((e) => report("foreign text", e)); + console.log(await text.call(box.req)); + const signal = { s: untyped(new AbortController().signal) }; + const throwIfAborted = signal.s.throwIfAborted; + try { + throwIfAborted(); + } catch (e) { + report("unbound throwIfAborted", e); + } + try { + signal.s.addEventListener.call(box.h, "abort", () => {}); + } catch (e) { + report("foreign addEventListener", e); + } +} + +async function streams() { + const box = { body: untyped(new Response("stream").body) }; + const key = "getReader"; + const reader = box.body[key](); + const read = reader.read; + const first = await read.call(reader); + console.log(first.done, new TextDecoder().decode(first.value)); + await read().catch((e) => report("unbound read", e)); + try { + box.body.getReader.call(reader); + } catch (e) { + report("foreign getReader", e); + } +} + +(async () => { + await bodies(); + shape(); + await receivers(); + await streams(); +})();