Skip to content

feat: run node requests through undici's own fetch (0.4.0) - #2

Merged
ildella merged 1 commit into
masterfrom
same-module-fetch
Sep 28, 2026
Merged

ildella merged 1 commit into
masterfrom
same-module-fetch

Conversation

@ildella

@ildella ildella commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

What changed

platform-node now imports both fetch and Agent from the same npm undici package, instead of dispatching globalThis.fetch (the Node-bundled undici) into an npm Agent. Undici range becomes ^7.0.0 || ^8.0.0; engines floor rises to ^22.19.0; CI tests the node x undici matrix.

Why

The builtin fetch builds dispatch handlers whose callback shape is tied to the undici major bundled in the runtime (verified locally): node 22 -> undici 6, node 24 -> undici 7, node 26 -> undici 8. Undici 8's assertRequestHandler strictly requires the new-style callbacks (onRequestStart/onResponseError) — so a mismatched Agent/builtin pair throws UND_ERR_INVALID_ARG at dispatch time (hard fail, not degraded). Undici 7 tolerated both shapes, which is what made it the unique bridge major.

Calling undici's own fetch removes the cross-major pair entirely: handler and Agent always come from one module, whichever major resolved.

Why the range, not ^8

An earlier iteration pinned ^8.0.0. Rejected: it forces a second undici copy (~2 MB, duplicate socket pools, double instrumentation) on every consumer that already uses undici 7 happily. With ^7 || ^8:

  • consumers pinned to 7 -> resolution dedupes to one 7 copy, nothing forced
  • consumers without undici -> resolve to the latest 8
  • same-module invariant holds for both majors, determinism preserved

The engines floor stays at 22.19 because that is where undici 8 itself starts; undici 7 would allow lower, but the floor tracks our Node support, not the lower major's.

Consequences

  • connectTimeout/dispatcher/caching semantics unchanged; client.js untouched. No live bug fixed — the 10s connect wall died in 0.3.1; this is architecture + future-proofing.
  • Direct platformFetch users now get undici's Response class (not Node builtin) — instanceof Response differs. createHttpClient consumers unaffected (plain {data, status, headers}). Documented in README.
  • Injected dispatcher must come from the same undici major the app resolved. Documented in README.

Verification

  • 83/83 on node 22.23 / 24.21 / 26.10 with both undici 7.30 and 8.11 (full local matrix)
  • Blackhole host, 15s budget -> fails at ~15002 ms TimeoutError/TIMEOUT (survives the old builtin 10s cap)
  • Hanging local TLS, 2s connect budget -> fails at ~2486 ms TimeoutError/CONNECT_TIMEOUT
  • Lint clean (eslint 10); CI matrix now node 22/24/26 x undici 7 and 8

Review notes

  • Merge, then tag v0.4.0 to publish (tag triggers the publish workflow; it gates on the suite).
  • Next consumer bump in nucube-app: 0.4.0 exact — only real check there is Codemagic's Node >= 22.19.

@ildella
ildella force-pushed the same-module-fetch branch 2 times, most recently from 32047b3 to 4bdac32 Compare September 28, 2026 10:53

@ildella ildella left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REWORK

Medium: Env-proxy support silently dropped

globalThis.fetch on Node 22.19+/24+ honors NODE_USE_ENV_PROXY=1 and HTTP_PROXY/HTTPS_PROXY/NO_PROXY; npm undici's fetch does not (it needs an explicit EnvHttpProxyAgent dispatcher). A user behind a corporate proxy who sets NODE_USE_ENV_PROXY=1 and upgrades will get connection failures with no code change on their side. Either document this limitation or default the Agent's dispatcher to EnvHttpProxyAgent when proxy env vars are present.

Low: Dead code in tests

tests/platform-node.test.js:30-32 — afterEach calls vi.unstubAllGlobals() but no test in the file uses vi.stubGlobal. Leftover from the previous version where stubFetch used vi.stubGlobal('fetch', mock).

platform-node now imports both fetch and Agent from the same undici
package instead of dispatching into the Node-bundled undici via
globalThis.fetch.

The builtin fetch builds dispatch handlers whose callback shape is tied
to the undici major bundled in the runtime (node 22 -> undici 6, 24 ->
7, 26 -> 8), and undici 8 dropped the tolerant handler bridge, so an
Agent of a different major than the builtin one is rejected with
UND_ERR_INVALID_ARG. Calling undici's own fetch removes the cross-major
pair entirely.

- undici range ^7 || ^8: consumers already on either major dedupe to one
  copy, nobody is forced to jump to 8
- engines floor ^22.19.0, matching undici 8's own requirement
- CI matrix runs node 22/24/26 crossed with undici 7 and 8
- tests mock the undici module, plus an offline proof requests bypass
  globalThis.fetch
- README documents the env-proxy limitation (the adapter installs its own
  dispatcher) and the explicit-dispatcher escape hatch; env-proxy support
  was prototyped but undici 7 does not proxy plain http targets while
  undici 8 does, so a version-divergent feature was rejected
@ildella

ildella commented Sep 28, 2026

Copy link
Copy Markdown
Owner Author

Rework pushed (3f2295b). Both findings addressed:

1. Env-proxy (Medium). I prototyped the suggested fix rather than only documenting: EnvHttpProxyAgent is exported by both supported majors and forwards agent options (so {connect: {timeout}} keeps working), gated on the same opt-in Node uses (NODE_USE_ENV_PROXY=1 / --use-env-proxy). It worked end to end against a local proxy on undici 8 — then failed on undici 7.

Root cause, verified in isolation with a bare ProxyAgent and a local proxy server:

undici 7.30 (ProxyAgent direct)            -> never contacts the proxy (plain http target)
undici 7.30 (EnvHttpProxyAgent HTTP_PROXY) -> never contacts the proxy
undici 8.11 (ProxyAgent direct)            -> proxy contacted, 200
undici 8.11 (EnvHttpProxyAgent HTTP_PROXY) -> proxy contacted, 200

undici 8 proxies plain http:// targets; undici 7 does not (in this scenario at least). Since the range is ^7 || ^8, auto-enabling EnvHttpProxyAgent would ship a feature that silently works on one major and not the other — the same class of cross-major divergence this PR exists to remove. So I took the documented route instead of a version-divergent default:

  • README now calls out that NODE_USE_ENV_PROXY does not apply (the adapter installs its own dispatcher) and gives the escape hatch: get(url, {dispatcher: new EnvHttpProxyAgent()}) or a custom platformFetch — the injected dispatcher already wins over ours.
  • Happy to flip on auto-proxy the moment the supported range drops undici 7, or to gate it to the undici 8 resolution if you prefer that trade-off now.

2. Dead code (Low). Removed the unused afterEach(() => vi.unstubAllGlobals()) and the now-unneeded afterEach import from tests/platform-node.test.js.

Suite: 83/83 on node 22.23 / 24.21 / 26.10 for both undici 7.30 and 8.11, lint clean.

@ildella ildella left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED

Both findings addressed: env-proxy limitation documented with workaround in README, dead vi.unstubAllGlobals() removed from tests.

@ildella
ildella merged commit 29fc05a into master Sep 28, 2026
6 checks passed
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