Repository navigation
new Headers() is a NUMBER when node-fetch/undici routes to perry-ext-fetch — two js_headers_new implementations disagree on the handle encoding #10310
Description
Pull node-fetch or undici into a graph and the global Headers stops being an object:
perry: typeof h = "number" String(h) = "1" h instanceof Headers = false
bun: typeof h = "object" String(h) = "[object Headers]" h instanceof Headers = true
Every DYNAMIC dispatch on such a value then throws. Statically-typed call sites hide it, because they lower to native calls that take the raw id.
Repro
// @ts-nocheck
import fetch2 from "node-fetch" // routes `node-fetch` → perry-ext-fetch
const h: any = new Headers({ "Content-Type": "application/json" })
console.log(typeof h, String(h), h instanceof Headers)
console.log(h.get("Content-Type")) // works — static lowering
const opts: any = { headers: h }
opts.headers.delete("Content-Type") // TypeError: (number).delete is not a functionperry compile main.ts --platform bun. Without the node-fetch import the same program is correct, because the bundled binding is used instead.
Cause
Two implementations of the same #[no_mangle] symbol, with different encodings, and the ext one wins the link when the well-known routing fires.
perry-stdlib/src/fetch/headers.rs — and its doc comment states the contract:
/// new Headers() — returns NaN-boxed POINTER_TAG handle as f64.
/// See `handle_to_f64` / `handle_id` for the encoding contract.
#[no_mangle]
pub extern "C" fn js_headers_new() -> f64 {
handle_to_f64(alloc_headers(HeadersStore::default())) // js_nanbox_pointer
}perry-ext-fetch/src/lib.rs:
#[no_mangle]
pub extern "C" fn js_headers_new() -> f64 {
store_headers(HeadersStore::default()) as f64 // BARE double
}The ext's own handle_id() already accepts both encodings (top16 >= 0x7FF8 masks the tag off), so the constructor is the only place that diverges.
It needs BOTH halves — measured, not assumed
I applied the one-line boxing fix and re-ran the probe. typeof becomes "object" and the throw goes away, but the dynamic path then answers silently and wrongly:
| bun | perry, boxed handle | |
|---|---|---|
opts.headers.get("X-Keep") |
1 |
undefined |
opts.headers.has("X-Keep") |
true |
undefined |
opts.headers.forEach count |
1 |
0 |
opts.headers.delete(k) |
removes | no-op, returns undefined |
Because js_handle_method_dispatch (perry-stdlib) routes Headers through crate::fetch::dispatch_headers_method, which checks perry-stdlib's HEADERS_REGISTRY — while the handle now lives in perry-ext-fetch's HEADERS_HANDLES. So boxing alone trades a loud TypeError for silent data loss, which is worse. I reverted it rather than ship half.
The complete fix is the established cross-boundary pattern already used for node:sqlite — JS_NATIVE_SQLITE_DISPATCH, an AtomicPtr the ext registers into and the tower calls:
- Box the handle in
perry-ext-fetch::js_headers_new(restorestypeof,instanceof,String(), and makes the tower see a handle rather than a number). - Add a registered dispatch hook so
js_handle_method_dispatchcan reach the ext's registry forHeaders(and, by the same argument,Request/Response/FormData/Blob, which have the same two-implementation split).
Why it matters
This is the current wall for OpenCode's TUI (#10107). @opencode-ai/sdk's generated client does:
const opts = { ..._config, ...options, headers: mergeHeaders(_config.headers, options.headers), … }
if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type")
}Instrumenting the real source proves the value is a bare number from new Headers() onward — ids 1, 3, 4, 5… incrementing per request:
[diag] inside mergeHeaders: new Headers() typeof=number str=1 tag=[object Number] instanceof=false
[diag] mergeHeaders returning typeof=number str=1
[diag] opts.headers typeof=number str=4 same=true
so tui bootstrap failed { error: '(number).delete is not a function' } and the TUI never mounts.
Adjacent, same probe
import fetch2 from "node-fetch" gives typeof fetch2 === "object" under perry and "function" under bun.
Activity
proggeramlug commentedon Sep 15, 2026
The root cause is worse than the encoding:
Headersis assembled from TWO crates with separate registries.perry-ext-fetch exports a strict SUBSET of the
js_headers_*surface. When the well-known routing fires, the overlapping symbols resolve to the ext and the rest still resolve to perry-stdlib — so oneHeadersvalue is half owned by each, with different handle encodings and different stores.So
new Headers(init)runs extjs_headers_new(bare id, EXT registry) and then stdlibjs_headers_init_from_value(expects a NaN-boxed handle, reads the STDLIB registry). It finds nothing, and the init value — itself a bare-number Headers — is not iterable:0x4014000000000000is the double5— the ext's handle id.This blocks two OpenCode rungs, not one. The
run --model …rung (previously byte-identical apart from the auth error) now fails here, and the TUI bootstrap fails on the dynamic-dispatch half of the same split:run --model …Error: API key is invalid.Error: Unexpected error/Headers constructor: init is not iterable (received 0x4014000000000000)tui bootstrap failed { error: '(number).delete is not a function' }Neither half-fix is safe on its own, which is why I am not sending a patch:
get→undefined,has→undefined,forEach→ 0 visits,delete→ no-op — becausejs_handle_method_dispatchroutes Headers to perry-stdlib'sdispatch_headers_method, which reads the stdlib registry while the handle lives in the ext's. Silent data loss in an HTTP client is worse than the current loud failure, so I reverted it.js_headers_newfrom the ext so stdlib owns Headers outright would strand the ext's OWNjs_headers_for_each/get_set_cookie/etc., which read its store internally.The two coherent shapes are: (a) the ext owns the whole surface — implement the missing five symbols there and register a dispatch hook the tower can call, the way
JS_NATIVE_SQLITE_DISPATCHalready crosses this boundary; or (b) the ext keeps none of theHeaderssurface and uses stdlib's handles internally. That is a maintainer call about which crate ownsHeaders, not something to guess at.Worth checking whether
Request/Response/FormData/Blobhave the same subset split — they are dispatched through the same tower arms.proggeramlug commentedon Sep 15, 2026
Ruling out the obvious workaround, so nobody spends a build on it: this cannot be sidestepped with
perry.compilePackages.Only
perry-ext-fetchexportsjs_headers_newamong the ext crates, andwell_known_bindings.tomlroutes two things to it:So compiling the real
node-fetchfrom source would still leave the crate linked through the barefetchbinding — and OpenCode reaches it on the hot path (fetch: options.fetch ?? _config.fetch ?? globalThis.fetchin the generated SDK client). Any program that callsfetchat all gets the splitHeaders, whether or notnode-fetchis in its dependency tree.That also widens the blast radius from the title: it is not "programs that depend on node-fetch/undici", it is any program that uses
fetchand touches aHeadersvalue dynamically.proggeramlug commentedon Sep 15, 2026
Fixed — and it was an omission, not a missing feature.
perry-runtime already exports the mechanism for exactly this case:
perry-ext-net, perry-ext-ws, perry-ext-http and perry-ext-mysql2 all register into it. perry-ext-fetch never did — that is the whole reason its handles were invisible to the tower.
Three parts, and all three are required (boxing alone converts the loud TypeError into silent wrong answers, which I measured and reverted earlier in this thread):
js_headers_newreturns a NaN-boxed POINTER_TAG handle — the contract perry-stdlib's twin documents and everyhandle_idin this crate already accepts.js_headers_new, gated on registry membership so another subsystem's handle id falls through untouched. The method set mirrors perry-stdlib's dispatcher including its two non-obvious cases —getanswersnullrather than"", andSymbol.iteratoris a synonym forentries.js_headers_init_from_value, which this crate did not export, sonew Headers(init)was populated by a function reading the other registry. Records and pair sequences are read viajs_json_stringifyrather than by walking a live JS object graph across allocations.Verified byte-identical to bun through an any-typed receiver:
typeof opts.headersopts.headers.delete(k).get()after delete.get("X-Keep").forEachvisits.has()new Headers(h).get("X-Keep")hasneeded one extra step: this crate'sjs_headers_hasanswers1.0/0.0while perry-stdlib's twin answers a NaN-boxed boolean, and the STATIC lowering consumes the numeric form — so it is normalised on the way out of the dynamic tower only, leaving the static path untouched. Same two-implementation split as thejs_lru_cache_hasone in #10293.Regression test needs no real dependency: the binding routes on the specifier and replaces the package source, so a stub
node_modules/node-fetchselects perry-ext-fetch, and the test asserts the binding actually routed before trusting its own result.fix(ext-fetch): own the Headers surface this crate already half-imple…
test(ext-fetch): a Headers must survive reaching a dynamic receiver
fix(ext-fetch): serve Request property reads from this crate's registry
fix(ext-fetch): box handles and register the dispatch extensions (Per…
fix(ext-fetch): box handles and register the dispatch extensions (#10310
fix(ext-fetch): box handles and register the dispatch extensions (#10310
fix(ext-fetch): box handles and register the dispatch extensions (#10310
Merge origin/main into turnloop/integration (v0.5.1635)
proggeramlug commentedon Oct 12, 2026
Fixed by 13bc79f (ext-fetch boxes handles and registers the dispatch extensions). Re-run on current main: new Headers() is an object and get() works.