Skip to content

node-fetch: fetch() throws 'Function.prototype.call was called on a value that is not a function' — same message class as #5135 and the require('stream') case #10759

Closed

Description

@proggeramlug

Compiling node-fetch 3.3.2 from real source succeeds (23 modules, 3m8s), but calling fetch() throws:

Uncaught (in promise) TypeError: Function.prototype.call was called on a value that is not a function

Found by the package-compilability probe on 91a566c8af (v0.5.1605), Linux x64.

Isolated to fetch() itself, not the test scaffolding

  • A stripped-down GET-only fixture (no POST, no body) against a local node:http server still crashes — immediately after the server starts listening, before any response is read.
  • An isolated ESM harness doing http.createServer plus a Promise, with no node-fetch involved, runs clean.

So the failure is inside node-fetch's own fetch() machinery rather than in the ESM/http scaffolding around it.

This looks like a recurring class, not a one-off

That error string is Perry's generic runtime message for .call() on a non-function value, and there is prior art in-repo for the same message arising from module-shape problems:

  • crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs
  • a comment at crates/perry-runtime/src/node_stream_dispatch.rs:230 describing the same message following require('stream')

Which suggests the underlying cause is something resolving to a non-function where a function is expected — plausibly a module-export shape — rather than a defect unique to node-fetch. Worth checking against those existing cases before treating it as new.

Reproduction

mkdir nf-probe && cd nf-probe
npm install node-fetch@3.3.2
# package.json: { "perry": { "compilePackages": ["node-fetch"] } }
# fixture.ts: start an http server, then `await fetch("http://127.0.0.1:PORT/")`
perry compile fixture.ts -o fixture && ./fixture

Not determined

Which call site inside node-fetch receives the non-function value. Narrowed to "inside fetch()", not to a specific line, within the probe's time budget.

Activity

  1. added
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 19, 2026
  2. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Diagnosis, recorded before it is verified so it isn't lost — this is a hypothesis traced from source reading, not yet run against a Perry build. Flagged as such deliberately.

    Both sibling cases are the same bug family

    • crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs — immer's Function.toString.call(Ctor) threw this exact error because the static member read collapsed to globalThis.toString, a non-function.
    • crates/perry-runtime/src/object/native_module/constants.rs:203-220 and node_stream_dispatch.rs:210-223 — readable-stream's Stream.prototype.on.call(this, …) threw it because require('stream').prototype resolved to undefined rather than the legacy Stream.prototype.

    The family: a built-in or native prototype method read as a value — not called directly as recv.method(...) — resolves to a non-function, and later code does .call()/.apply() on it.

    The single throw site for this message is throw_fn_proto_not_callable in crates/perry-runtime/src/object/native_call_method/proto_dispatch.rs:104-118, which confirms it is a generic "receiver isn't a real closure" check rather than anything node-fetch-specific.

    The node-fetch trigger

    node-fetch/src/headers.js:

    export default class Headers extends URLSearchParams {
      constructor(init) {
        ...
        super(result);
        return new Proxy(this, {
          get(target, p, receiver) {
            switch (p) {
              case 'append': case 'set':
                return (name, value) => URLSearchParams.prototype[p].call(target, ...);
              case 'delete': case 'has': case 'getAll':
                return name => URLSearchParams.prototype[p].call(target, ...);
              ...
            }
          }
        });
      }
    }

    request.js:231 calls headers.has('Accept') unconditionally inside getNodeRequestOptions(), which every fetch() reaches before the socket opens — matching the observed "crashes immediately after the server starts listening, before any response is read".

    So the shape is a three-way compound, each part individually plausible as a gap:

    1. computed/dynamic-key member access on a builtin's .prototype — p is a closure-captured, non-literal string;
    2. a constructor that overrides its return value — return new Proxy(this, …);
    3. a Proxy get trap sitting between the access and the object.

    Next step for whoever picks this up

    Build perry-dev, then test an isolated repro — a user class plus a Proxy get trap returning SomeClass.prototype[p].call(target, args) with p a captured variable — against both a plain user class and a global builtin such as URLSearchParams, since the difference between those two tells you whether the defect is in builtin-prototype resolution or in the computed-key path generally. Then --trace hir --focus on the trap closure to see what SomeClass.prototype[p] actually lowers to.

    One trap already hit, worth not repeating: a first draft repro also exercised Headers's inherited .toString(), which triggers a genuine and unrelated Node error (ERR_INVALID_THIS on URLSearchParams.prototype.toString called through a Proxy receiver). That is a quirk of the fixture, not the bug. Call only .has()/.append().

    Status

    Not compiled, not verified, no fix, no gap test. Recorded so the reasoning survives independently of any one session.

  3. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Correcting the diagnosis above, and bounding the bug class it implies.

    The comment above states that URLSearchParams "had no arm at all" in populate_builtin_prototype_methods, falling through to _ => {}. That is wrong. On origin/main, crates/perry-runtime/src/object/global_this/proto_methods.rs:1093:

    "URLSearchParams" => unsafe {
        install_web_builtin_to_string_tag(proto_obj, "URLSearchParams")
    },

    What is actually happening

    That arm belongs to a deliberate group, and the comment immediately above it documents the intent — these builtins' methods are reached by "type-directed static dispatch or the small-int/handle dispatch tables", so they were never given reified prototype closures; each arm installs only the Symbol.toStringTag descriptor for reflection.

    That design is sound for x.method() and has no answer for a method read as a value: URLSearchParams.prototype.append, .prototype["has"], or anything reached through a Proxy get trap. The value-read finds nothing installed and yields undefined.

    So this is a gap in a deliberate design, not a missing entry. The distinction matters for how it should be fixed: the question is not "why was this one forgotten" but "what should a statically-dispatched builtin do when one of its methods is read as a value rather than called".

    The class is bounded and enumerable

    Every arm in that group installs only a toStringTag:

    AbortController, AbortSignal, CustomEvent, Event, EventTarget, URL, URLSearchParams
    

    Seven, of which this issue covers one. The other six are candidates for the identical defect — reading any of their prototype methods as a value should return undefined by the same mechanism.

    That is a checkable prediction rather than a worry, and it is being tested: one small fixture per builtin, reading a prototype method as a value and calling it (const f = Event.prototype.preventDefault; f.call(ev)). Results to follow. If some of the six pass, that is equally useful — it would mean the toStringTag-only arm is not sufficient to cause the defect, and something else distinguishes them.

    The earlier framing — "every builtin without an arm has this latent bug" — was both wrong and unfalsifiable. This version names seven specific candidates and predicts what each should do.

  4. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    Fixed on main. PR #10807 landed — verified by content rather than PR state, since merge trains close source PRs without firing close keywords: install_noop_proto_methods now installs URLSearchParams's prototype methods in proto_methods.rs.

    Note the fix as landed also merged with the pre-existing #10555 toStringTag arm rather than shadowing it. An earlier revision inserted the new arm first in the match, which produced an unreachable pattern warning and silently dropped the install_web_builtin_to_string_tag call — a fresh instance of this very bug class, in a diff that read as pure addition.

    The six sibling builtins remain unfixed and are tracked in #10808, including URL.prototype.toString silently resolving to the wrong function. And node-fetch itself is still blocked, now by #10809 (derived class returning a Proxy) — this fix was necessary but not sufficient for that package.

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

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions