Skip to content

feat(fetch): pin *.localhost connects to loopback (IPv4 by default) - #119

Merged
pyramation merged 1 commit into
mainfrom
feat/fetch-loopback-pin
Aug 7, 2026
Merged

feat(fetch): pin *.localhost connects to loopback (IPv4 by default)#119
pyramation merged 1 commit into
mainfrom
feat/fetch-loopback-pin

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

@constructive-io/fetch's createFetch() node:http path rewrote *.localhost → bare localhost and then relied on system DNS to reach the loopback. localhost commonly resolves to IPv6 ::1 first, but many local dev ingresses (kind, Docker's port publishing) listen on IPv4 only — so the DNS-driven connect hit ::1 and failed with ECONNREFUSED/socket hang up on setups without Node's Happy-Eyeballs fallback. This was the root cause of a false-negative health gate in constructive-db (fun up --k8s step 15 failing healthy clusters).

Fix: point the connect directly at the loopback interface (IPv4 127.0.0.1 by default), taking DNS out of the loopback hop entirely. The original Host header is still sent, so Traefik/subdomain routing is unaffected.

- url.hostname = 'localhost';
- protocol.request(url, { method, headers: { Host: originalHost, ... } })
+ const connectHost = loopback === false ? 'localhost' : loopback; // default '127.0.0.1'
+ protocol.request({
+   protocol: url.protocol,
+   hostname: connectHost,
+   port: url.port === '' ? undefined : Number(url.port),
+   path: `${url.pathname}${url.search}`,
+   method, headers: { Host: originalHost, ... },
+ })

New opt-in knob (default preserves the common case, false restores pre-1.2 behavior):

createFetch();                       // pin 127.0.0.1 (default)
createFetch({ loopback: '::1' });    // pin IPv6 loopback
createFetch({ loopback: false });    // no pin: rewrite to localhost, use DNS

Notes:

  • Only the default configuration is cached; a non-default loopback builds a fresh instance.
  • Browser build accepts and ignores options for signature parity (browsers resolve *.localhost natively).
  • Non-*.localhost URLs still delegate to globalThis.fetch unchanged.

Test plan

  • Rewrote __tests__/localhost-fetch.test.ts to bind real servers on explicit families:
    • default createFetch() reaches an IPv4-only (127.0.0.1) server and preserves the Host header (this is the regression the fix targets);
    • loopback: false reaches a DNS-resolved localhost server;
    • loopback: '::1' reaches an IPv6-only server (guarded — skips when IPv6 loopback is unavailable);
    • caching: default calls return the same instance; non-default builds a fresh one.
  • pnpm build + pnpm test (21 passing) + eslint packages/fetch clean.

Follow-up

Once released, constructive-db's fun-k8s/health.ts probe (PR #2846) can drop its bespoke http.request({ family: 4 }) plumbing and just use createFetch().

Link to Devin session: https://app.devin.ai/sessions/6f4d8dd9a5834871a379b7e598ccde8c
Requested by: @pyramation

createFetch now points the *.localhost node:http path straight at the
loopback interface instead of resolving bare localhost, taking DNS out of
the loopback hop. localhost commonly resolves to IPv6 ::1 first while many
local dev ingresses (kind, Docker port publishing) listen on IPv4 only, so
the old DNS-driven connect hit ::1 and failed without Happy-Eyeballs
fallback. The original Host header is still preserved for subdomain routing.

Adds createFetch({ loopback }) — '127.0.0.1' (default) | '::1' | false
(pre-1.2 DNS behavior).
@pyramation pyramation self-assigned this Aug 7, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit 834b8b8 into main Aug 7, 2026
59 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