From 930879f7ea401125bfc9c5f5da336a57cefab05e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 7 Aug 2026 17:01:14 +0000 Subject: [PATCH] feat(fetch): pin *.localhost connects to loopback (IPv4 by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/fetch/README.md | 23 ++- .../fetch/__tests__/localhost-fetch.test.ts | 147 +++++++++++++----- packages/fetch/src/index.browser.ts | 2 +- packages/fetch/src/index.ts | 2 +- packages/fetch/src/localhost-fetch.browser.ts | 8 +- packages/fetch/src/localhost-fetch.ts | 57 +++++-- packages/fetch/src/types.ts | 27 ++++ 7 files changed, 203 insertions(+), 63 deletions(-) diff --git a/packages/fetch/README.md b/packages/fetch/README.md index 337bae9e..06bdf5d4 100644 --- a/packages/fetch/README.md +++ b/packages/fetch/README.md @@ -4,12 +4,13 @@ Isomorphic fetch that resolves `*.localhost` subdomains and preserves `Host` hea ## Why -Node.js has two issues with `*.localhost` subdomains: +Node.js has three issues with `*.localhost` subdomains: 1. **DNS** — `fetch('http://auth.localhost:3000/')` throws `ENOTFOUND` because Node (undici) doesn't resolve `*.localhost` to loopback ([nodejs/node#50871](https://github.com/nodejs/node/issues/50871)). 2. **Host header** — Node's fetch treats `Host` as a forbidden header and silently drops it, breaking server-side subdomain routing. +3. **Loopback family** — `localhost` commonly resolves to IPv6 `::1` first, but many local dev ingresses (kind, Docker's port publishing) listen on IPv4 only, so a DNS-driven connect reaches `::1` and fails on setups without Node's Happy-Eyeballs fallback. -Browsers handle both correctly. This package fixes both in Node by using `node:http`/`node:https` for `*.localhost` URLs and passing everything else through to `globalThis.fetch`. +Browsers handle all three correctly. This package fixes them in Node by using `node:http`/`node:https` for `*.localhost` URLs — pinning the connect to the loopback interface (IPv4 by default) while preserving the original `Host` header — and passing everything else through to `globalThis.fetch`. ## Install @@ -34,9 +35,23 @@ const res = await fetch('http://auth.localhost:3000/graphql', { ## API -### `createFetch(): typeof globalThis.fetch` +### `createFetch(options?: CreateFetchOptions): typeof globalThis.fetch` -Returns a fetch function. In Node.js, `*.localhost` URLs are handled via `node:http`/`node:https`. Everything else delegates to `globalThis.fetch`. The result is cached. +Returns a fetch function. In Node.js, `*.localhost` URLs are handled via `node:http`/`node:https`. Everything else delegates to `globalThis.fetch`. The default-configuration result is cached. + +**`CreateFetchOptions`** + +- `loopback?: '127.0.0.1' | '::1' | false` — how to reach the loopback interface for `*.localhost` URLs (Node only). The connect is pinned to this address, removing DNS from the loopback hop; the original `Host` header is preserved so subdomain routing still works. + - `'127.0.0.1'` (default) — pin the IPv4 loopback. + - `'::1'` — pin the IPv6 loopback. + - `false` — no pin; rewrite the host to `localhost` and rely on system DNS (the pre-1.2 behavior). + + Ignored in browsers, which resolve `*.localhost` natively. + +```ts +// IPv6-only loopback +const fetch = createFetch({ loopback: '::1' }); +``` ### `isLocalhostSubdomain(hostname: string): boolean` diff --git a/packages/fetch/__tests__/localhost-fetch.test.ts b/packages/fetch/__tests__/localhost-fetch.test.ts index b32bfd1a..1729619e 100644 --- a/packages/fetch/__tests__/localhost-fetch.test.ts +++ b/packages/fetch/__tests__/localhost-fetch.test.ts @@ -1,7 +1,52 @@ import http from 'node:http'; +import { AddressInfo } from 'node:net'; import { createFetch, isLocalhostSubdomain } from '../src'; +type ServerInfo = { server: http.Server; port: number }; + +const openServers: http.Server[] = []; + +function startServer(host: string): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + host: req.headers.host, + method: req.method, + url: req.url, + body: body || undefined, + }), + ); + }); + }); + server.on('error', reject); + server.listen(0, host, () => { + openServers.push(server); + resolve({ server, port: (server.address() as AddressInfo).port }); + }); + }); +} + +/** Whether the host can actually bind an IPv6 loopback listener. */ +async function ipv6Available(): Promise { + try { + const { server } = await startServer('::1'); + server.close(); + return true; + } catch { + return false; + } +} + +afterAll(() => { + for (const server of openServers) server.close(); +}); + describe('isLocalhostSubdomain', () => { it('returns true for *.localhost', () => { expect(isLocalhostSubdomain('auth.localhost')).toBe(true); @@ -25,42 +70,27 @@ describe('createFetch', () => { expect(typeof fetch).toBe('function'); }); - it('returns the same instance on repeated calls', () => { - const a = createFetch(); - const b = createFetch(); - expect(a).toBe(b); + it('returns the same instance on repeated default calls', () => { + expect(createFetch()).toBe(createFetch()); + expect(createFetch()).toBe(createFetch({})); + expect(createFetch()).toBe(createFetch({ loopback: '127.0.0.1' })); + }); + + it('builds a fresh instance for non-default loopback', () => { + expect(createFetch({ loopback: false })).not.toBe(createFetch()); + expect(createFetch({ loopback: '::1' })).not.toBe(createFetch()); }); }); -describe('fetch with *.localhost', () => { - let server: http.Server; +describe('fetch with *.localhost (default IPv4 loopback)', () => { let port: number; - beforeAll((done) => { - server = http.createServer((req, res) => { - let body = ''; - req.on('data', (chunk) => (body += chunk)); - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - host: req.headers.host, - method: req.method, - url: req.url, - body: body || undefined, - })); - }); - }); - server.listen(0, 'localhost', () => { - port = (server.address() as { port: number }).port; - done(); - }); + beforeAll(async () => { + // IPv4-only listener, mimicking kind / Docker's IPv4 port publishing. + ({ port } = await startServer('127.0.0.1')); }); - afterAll((done) => { - server.close(done); - }); - - it('rewrites *.localhost URL to localhost and preserves Host header', async () => { + it('reaches an IPv4-only ingress and preserves the Host header', async () => { const fetch = createFetch(); const res = await fetch(`http://auth.localhost:${port}/graphql`, { method: 'POST', @@ -76,9 +106,29 @@ describe('fetch with *.localhost', () => { expect(JSON.parse(json.body)).toEqual({ query: '{ hello }' }); }); - it('preserves plain localhost requests as-is', async () => { + it('sends caller headers alongside the preserved Host header', async () => { + const fetch = createFetch(); + const res = await fetch(`http://admin.localhost:${port}/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ query: '{ test }' }), + }); + + expect(res.ok).toBe(true); + const json = await res.json(); + expect(json.host).toBe(`admin.localhost:${port}`); + }); + + it('preserves plain localhost requests as-is (delegates to global fetch)', async () => { + // Bare localhost is not a *.localhost subdomain, so it is handled by + // global fetch, which resolves localhost via DNS. Bind on both families + // so the delegated request connects regardless of resolution order. + const { port: dualPort } = await startServer('localhost'); const fetch = createFetch(); - const res = await fetch(`http://localhost:${port}/graphql`, { + const res = await fetch(`http://localhost:${dualPort}/graphql`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: '{ hello }' }), @@ -86,19 +136,40 @@ describe('fetch with *.localhost', () => { expect(res.ok).toBe(true); const json = await res.json(); - expect(json.host).toBe(`localhost:${port}`); + expect(json.host).toBe(`localhost:${dualPort}`); }); +}); - it('sends correct content-type header', async () => { - const fetch = createFetch(); - const res = await fetch(`http://admin.localhost:${port}/graphql`, { +describe('fetch with *.localhost (loopback option)', () => { + it('loopback: false falls back to DNS resolution of localhost', async () => { + const { port } = await startServer('localhost'); + const fetch = createFetch({ loopback: false }); + const res = await fetch(`http://api.localhost:${port}/graphql`, { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ query: '{ test }' }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ hello }' }), }); expect(res.ok).toBe(true); const json = await res.json(); - expect(json.host).toBe(`admin.localhost:${port}`); + expect(json.host).toBe(`api.localhost:${port}`); + }); + + it('loopback: "::1" reaches an IPv6-only ingress', async () => { + if (!(await ipv6Available())) { + console.warn('SKIP: IPv6 loopback unavailable in this environment'); + return; + } + const { port } = await startServer('::1'); + const fetch = createFetch({ loopback: '::1' }); + const res = await fetch(`http://api.localhost:${port}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ hello }' }), + }); + + expect(res.ok).toBe(true); + const json = await res.json(); + expect(json.host).toBe(`api.localhost:${port}`); }); }); diff --git a/packages/fetch/src/index.browser.ts b/packages/fetch/src/index.browser.ts index c79889ac..8b9cb8ea 100644 --- a/packages/fetch/src/index.browser.ts +++ b/packages/fetch/src/index.browser.ts @@ -1,2 +1,2 @@ export { createFetch, isLocalhostSubdomain } from './localhost-fetch.browser'; -export type { FetchFunction } from './types'; +export type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types'; diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index 5b248219..88866bda 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -1,2 +1,2 @@ export { createFetch, isLocalhostSubdomain } from './localhost-fetch'; -export type { FetchFunction } from './types'; +export type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types'; diff --git a/packages/fetch/src/localhost-fetch.browser.ts b/packages/fetch/src/localhost-fetch.browser.ts index 7e37b5c2..3095d7cb 100644 --- a/packages/fetch/src/localhost-fetch.browser.ts +++ b/packages/fetch/src/localhost-fetch.browser.ts @@ -1,4 +1,4 @@ -import type { FetchFunction } from './types'; +import type { CreateFetchOptions, FetchFunction } from './types'; /** * Returns true for *.localhost subdomains (e.g. auth.localhost) @@ -18,12 +18,14 @@ let _fetch: FetchFunction | undefined; * * Browsers resolve *.localhost subdomains natively and do not have the * Host-header restriction that Node.js undici has, so no workaround - * is needed — just return `globalThis.fetch`. + * is needed — just return `globalThis.fetch`. The `options` argument + * (e.g. `loopback`) is accepted for signature parity with the Node build + * and ignored here. * * The result is cached — calling `createFetch()` multiple times returns * the same function instance. */ -export function createFetch(): FetchFunction { +export function createFetch(_options: CreateFetchOptions = {}): FetchFunction { if (_fetch) return _fetch; _fetch = globalThis.fetch.bind(globalThis); return _fetch; diff --git a/packages/fetch/src/localhost-fetch.ts b/packages/fetch/src/localhost-fetch.ts index 494bf23a..def67c65 100644 --- a/packages/fetch/src/localhost-fetch.ts +++ b/packages/fetch/src/localhost-fetch.ts @@ -1,4 +1,6 @@ -import type { FetchFunction } from './types'; +import type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types'; + +const DEFAULT_LOOPBACK: LoopbackAddress = '127.0.0.1'; /** * Returns true for *.localhost subdomains (e.g. auth.localhost) @@ -9,18 +11,24 @@ export function isLocalhostSubdomain(hostname: string): boolean { } /** - * Build a fetch that uses node:http/node:https to bypass two Node.js + * Build a fetch that uses node:http/node:https to bypass three Node.js * limitations with *.localhost subdomains: * * 1. DNS — Node cannot resolve *.localhost (ENOTFOUND on many OSes). * 2. Host header — Node's fetch (undici) treats Host as forbidden and * silently drops it, breaking server-side subdomain routing. + * 3. Loopback family — `localhost` commonly resolves to IPv6 `::1` first, + * but many local dev ingresses (kind, Docker port publishing) listen on + * IPv4 only, so a DNS-driven connect hits `::1` and fails without + * Happy-Eyeballs fallback. The connect is pinned to `loopback` (default + * IPv4 `127.0.0.1`) to take DNS out of the loopback hop entirely. * * For non-localhost URLs this delegates to globalThis.fetch. */ function buildNodeFetch( http: typeof import('node:http'), https: typeof import('node:https'), + loopback: LoopbackAddress | false, ): FetchFunction { return (input, init) => { const url = new URL( @@ -36,7 +44,10 @@ function buildNodeFetch( } const originalHost = url.host; - url.hostname = 'localhost'; + // Pin the connect target to the loopback interface, keeping the original + // Host header so subdomain routing still works. `false` falls back to + // DNS resolution of bare `localhost`. + const connectHost = loopback === false ? 'localhost' : loopback; return new Promise((resolve, reject) => { const headers: Record = { @@ -58,7 +69,11 @@ function buildNodeFetch( const protocol = url.protocol === 'https:' ? https : http; - const req = protocol.request(url, { + const req = protocol.request({ + protocol: url.protocol, + hostname: connectHost, + port: url.port === '' ? undefined : Number(url.port), + path: `${url.pathname}${url.search}`, method: init?.method ?? 'GET', headers, }, (res) => { @@ -100,20 +115,26 @@ function buildNodeFetch( } /** - * Cached fetch implementation — resolved once, reused for all calls. + * Cached default fetch implementation — resolved once, reused for all calls + * that use the default loopback. Non-default options build a fresh instance. */ -let _fetch: FetchFunction | undefined; +let _defaultFetch: FetchFunction | undefined; /** * Create an isomorphic fetch function. * * - In **browsers** (and Deno/Bun/edge): returns `globalThis.fetch` as-is. * - In **Node.js**: returns a wrapper that uses `node:http`/`node:https` - * for `*.localhost` URLs (fixing DNS + Host header) and delegates - * everything else to `globalThis.fetch`. + * for `*.localhost` URLs (fixing DNS, the dropped Host header, and the + * IPv6-first loopback trap) and delegates everything else to + * `globalThis.fetch`. + * + * The default-configuration result is cached — calling `createFetch()` (or + * `createFetch({})`) repeatedly returns the same function instance. * - * The result is cached — calling `createFetch()` multiple times returns - * the same function instance. + * @param options.loopback Loopback address to pin `*.localhost` connects to + * (Node only). Defaults to `'127.0.0.1'`; pass `'::1'` for IPv6 or `false` + * to fall back to DNS resolution of bare `localhost`. * * @example * ```ts @@ -127,8 +148,13 @@ let _fetch: FetchFunction | undefined; * }); * ``` */ -export function createFetch(): FetchFunction { - if (_fetch) return _fetch; +export function createFetch(options: CreateFetchOptions = {}): FetchFunction { + const loopback = options.loopback ?? DEFAULT_LOOPBACK; + const isDefault = loopback === DEFAULT_LOOPBACK; + + if (isDefault && _defaultFetch) return _defaultFetch; + + let fetchImpl: FetchFunction = globalThis.fetch; // In Node.js, build a fetch that handles *.localhost via node:http if (typeof process !== 'undefined' && process.versions?.node) { @@ -137,13 +163,12 @@ export function createFetch(): FetchFunction { const http = require('node:http'); const https = require('node:https'); - _fetch = buildNodeFetch(http, https); - return _fetch; + fetchImpl = buildNodeFetch(http, https, loopback); } catch { // node:http unavailable — fall through to globalThis.fetch } } - _fetch = globalThis.fetch; - return _fetch; + if (isDefault) _defaultFetch = fetchImpl; + return fetchImpl; } diff --git a/packages/fetch/src/types.ts b/packages/fetch/src/types.ts index d88f4265..fc395ed8 100644 --- a/packages/fetch/src/types.ts +++ b/packages/fetch/src/types.ts @@ -1 +1,28 @@ export type FetchFunction = typeof globalThis.fetch; + +/** + * A loopback interface address the `*.localhost` connect path can be pinned to. + */ +export type LoopbackAddress = '127.0.0.1' | '::1'; + +export interface CreateFetchOptions { + /** + * How to reach the loopback interface for `*.localhost` URLs (Node only). + * + * The `*.localhost` connect path is pointed straight at this address, + * removing DNS from the loopback hop entirely. This matters because + * `localhost` commonly resolves to IPv6 `::1` first while many local dev + * ingresses (kind, Docker's port publishing) listen on IPv4 only — a plain + * DNS connect then reaches `::1` and fails on setups without Node's + * Happy-Eyeballs fallback. The original `Host` header is preserved either + * way, so subdomain routing is unaffected. + * + * - `'127.0.0.1'` (default) — pin the IPv4 loopback. + * - `'::1'` — pin the IPv6 loopback. + * - `false` — no pin; rewrite the host to `localhost` and rely on system + * DNS (the pre-1.2 behavior). + * + * Ignored in browser environments, which resolve `*.localhost` natively. + */ + loopback?: LoopbackAddress | false; +}