Skip to content

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

Closed

Description

@proggeramlug

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 function

perry 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:

  1. Box the handle in perry-ext-fetch::js_headers_new (restores typeof, instanceof, String(), and makes the tower see a handle rather than a number).
  2. Add a registered dispatch hook so js_handle_method_dispatch can reach the ext's registry for Headers (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

  1. proggeramlug commented on Sep 15, 2026

    @proggeramlug
    ContributorAuthor

    The root cause is worse than the encoding: Headers is 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 one Headers value is half owned by each, with different handle encodings and different stores.

    perry-ext-fetch (11):  new append delete entries for_each get get_set_cookie has keys set values
    perry-stdlib    (16):  ...the same 11, PLUS
                           init_from_value          ← the one `new Headers(init)` calls
                           from_value
                           fetch_object_json
                           method_value
                           setheaders_entries_json
    

    So new Headers(init) runs ext js_headers_new (bare id, EXT registry) and then stdlib js_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:

    Error: Unexpected error
    Headers constructor: init is not iterable (received 0x4014000000000000)
    

    0x4014000000000000 is the double 5 — 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:

    rung bun perry
    run --model … Error: API key is invalid. Error: Unexpected error / Headers constructor: init is not iterable (received 0x4014000000000000)
    TUI paints 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:

    • Boxing the ext constructor alone (verified by building it) removes the throw but makes every dynamic Headers operation silently wrong — get → undefined, has → undefined, forEach → 0 visits, delete → no-op — because js_handle_method_dispatch routes Headers to perry-stdlib's dispatch_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.
    • Dropping js_headers_new from the ext so stdlib owns Headers outright would strand the ext's OWN js_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_DISPATCH already crosses this boundary; or (b) the ext keeps none of the Headers surface and uses stdlib's handles internally. That is a maintainer call about which crate owns Headers, not something to guess at.

    Worth checking whether Request/Response/FormData/Blob have the same subset split — they are dispatched through the same tower arms.

  2. proggeramlug commented on Sep 15, 2026

    @proggeramlug
    ContributorAuthor

    Ruling out the obvious workaround, so nobody spends a build on it: this cannot be sidestepped with perry.compilePackages.

    Only perry-ext-fetch exports js_headers_new among the ext crates, and well_known_bindings.toml routes two things to it:

    [bindings."node-fetch"]
    crate = "perry-ext-fetch"
    
    [bindings.fetch]
    crate = "perry-ext-fetch"
    alias-of = "node-fetch"   # bare `fetch`, not an npm package

    So compiling the real node-fetch from source would still leave the crate linked through the bare fetch binding — and OpenCode reaches it on the hot path (fetch: options.fetch ?? _config.fetch ?? globalThis.fetch in the generated SDK client). Any program that calls fetch at all gets the split Headers, whether or not node-fetch is 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 fetch and touches a Headers value dynamically.

  3. proggeramlug commented on Sep 15, 2026

    @proggeramlug
    ContributorAuthor

    Fixed — and it was an omission, not a missing feature.

    perry-runtime already exports the mechanism for exactly this case:

    /// Register an extension method dispatcher. External native crates use this
    /// when their handles must coexist with the default stdlib dispatcher.
    pub unsafe extern "C" fn js_register_handle_method_dispatch_extension(...)

    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):

    1. js_headers_new returns a NaN-boxed POINTER_TAG handle — the contract perry-stdlib's twin documents and every handle_id in this crate already accepts.
    2. A handle-method-dispatch extension, registered lazily from 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 — get answers null rather than "", and Symbol.iterator is a synonym for entries.
    3. js_headers_init_from_value, which this crate did not export, so new Headers(init) was populated by a function reading the other registry. Records and pair sequences are read via js_json_stringify rather than by walking a live JS object graph across allocations.

    Verified byte-identical to bun through an any-typed receiver:

    bun before after
    typeof opts.headers object number object
    opts.headers.delete(k) ok TypeError ok
    .get() after delete null undefined null
    .get("X-Keep") 1 undefined 1
    .forEach visits 1 0 1
    .has() true undefined true
    new Headers(h).get("X-Keep") 1 throws 1

    has needed one extra step: this crate's js_headers_has answers 1.0/0.0 while 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 the js_lru_cache_has one 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-fetch selects perry-ext-fetch, and the test asserts the binding actually routed before trusting its own result.

  4. added 8 commits that reference this issue on Sep 15, 2026
    2aa30a5
    fd8ba21
    4698790
    008215e
    bed4dc3
    bb7ce9f
    13bc79f
    ce32754
  5. proggeramlug commented on Oct 12, 2026

    @proggeramlug
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions