Skip to content

Commit 930879f

Browse files
committed
feat(fetch): pin *.localhost connects to loopback (IPv4 by default)
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).
1 parent 070bc41 commit 930879f

7 files changed

Lines changed: 203 additions & 63 deletions

File tree

‎packages/fetch/README.md‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ Isomorphic fetch that resolves `*.localhost` subdomains and preserves `Host` hea
44

55
## Why
66

7-
Node.js has two issues with `*.localhost` subdomains:
7+
Node.js has three issues with `*.localhost` subdomains:
88

99
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)).
1010
2. **Host header** — Node's fetch treats `Host` as a forbidden header and silently drops it, breaking server-side subdomain routing.
11+
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.
1112

12-
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`.
13+
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`.
1314

1415
## Install
1516

@@ -34,9 +35,23 @@ const res = await fetch('http://auth.localhost:3000/graphql', {
3435

3536
## API
3637

37-
### `createFetch(): typeof globalThis.fetch`
38+
### `createFetch(options?: CreateFetchOptions): typeof globalThis.fetch`
3839

39-
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.
40+
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.
41+
42+
**`CreateFetchOptions`**
43+
44+
- `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.
45+
- `'127.0.0.1'` (default) — pin the IPv4 loopback.
46+
- `'::1'` — pin the IPv6 loopback.
47+
- `false` — no pin; rewrite the host to `localhost` and rely on system DNS (the pre-1.2 behavior).
48+
49+
Ignored in browsers, which resolve `*.localhost` natively.
50+
51+
```ts
52+
// IPv6-only loopback
53+
const fetch = createFetch({ loopback: '::1' });
54+
```
4055

4156
### `isLocalhostSubdomain(hostname: string): boolean`
4257

Lines changed: 109 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,52 @@
11
import http from 'node:http';
2+
import { AddressInfo } from 'node:net';
23

34
import { createFetch, isLocalhostSubdomain } from '../src';
45

6+
type ServerInfo = { server: http.Server; port: number };
7+
8+
const openServers: http.Server[] = [];
9+
10+
function startServer(host: string): Promise<ServerInfo> {
11+
return new Promise((resolve, reject) => {
12+
const server = http.createServer((req, res) => {
13+
let body = '';
14+
req.on('data', (chunk) => (body += chunk));
15+
req.on('end', () => {
16+
res.writeHead(200, { 'Content-Type': 'application/json' });
17+
res.end(
18+
JSON.stringify({
19+
host: req.headers.host,
20+
method: req.method,
21+
url: req.url,
22+
body: body || undefined,
23+
}),
24+
);
25+
});
26+
});
27+
server.on('error', reject);
28+
server.listen(0, host, () => {
29+
openServers.push(server);
30+
resolve({ server, port: (server.address() as AddressInfo).port });
31+
});
32+
});
33+
}
34+
35+
/** Whether the host can actually bind an IPv6 loopback listener. */
36+
async function ipv6Available(): Promise<boolean> {
37+
try {
38+
const { server } = await startServer('::1');
39+
server.close();
40+
return true;
41+
} catch {
42+
return false;
43+
}
44+
}
45+
46+
afterAll(() => {
47+
for (const server of openServers) server.close();
48+
});
49+
550
describe('isLocalhostSubdomain', () => {
651
it('returns true for *.localhost', () => {
752
expect(isLocalhostSubdomain('auth.localhost')).toBe(true);
@@ -25,42 +70,27 @@ describe('createFetch', () => {
2570
expect(typeof fetch).toBe('function');
2671
});
2772

28-
it('returns the same instance on repeated calls', () => {
29-
const a = createFetch();
30-
const b = createFetch();
31-
expect(a).toBe(b);
73+
it('returns the same instance on repeated default calls', () => {
74+
expect(createFetch()).toBe(createFetch());
75+
expect(createFetch()).toBe(createFetch({}));
76+
expect(createFetch()).toBe(createFetch({ loopback: '127.0.0.1' }));
77+
});
78+
79+
it('builds a fresh instance for non-default loopback', () => {
80+
expect(createFetch({ loopback: false })).not.toBe(createFetch());
81+
expect(createFetch({ loopback: '::1' })).not.toBe(createFetch());
3282
});
3383
});
3484

35-
describe('fetch with *.localhost', () => {
36-
let server: http.Server;
85+
describe('fetch with *.localhost (default IPv4 loopback)', () => {
3786
let port: number;
3887

39-
beforeAll((done) => {
40-
server = http.createServer((req, res) => {
41-
let body = '';
42-
req.on('data', (chunk) => (body += chunk));
43-
req.on('end', () => {
44-
res.writeHead(200, { 'Content-Type': 'application/json' });
45-
res.end(JSON.stringify({
46-
host: req.headers.host,
47-
method: req.method,
48-
url: req.url,
49-
body: body || undefined,
50-
}));
51-
});
52-
});
53-
server.listen(0, 'localhost', () => {
54-
port = (server.address() as { port: number }).port;
55-
done();
56-
});
88+
beforeAll(async () => {
89+
// IPv4-only listener, mimicking kind / Docker's IPv4 port publishing.
90+
({ port } = await startServer('127.0.0.1'));
5791
});
5892

59-
afterAll((done) => {
60-
server.close(done);
61-
});
62-
63-
it('rewrites *.localhost URL to localhost and preserves Host header', async () => {
93+
it('reaches an IPv4-only ingress and preserves the Host header', async () => {
6494
const fetch = createFetch();
6595
const res = await fetch(`http://auth.localhost:${port}/graphql`, {
6696
method: 'POST',
@@ -76,29 +106,70 @@ describe('fetch with *.localhost', () => {
76106
expect(JSON.parse(json.body)).toEqual({ query: '{ hello }' });
77107
});
78108

79-
it('preserves plain localhost requests as-is', async () => {
109+
it('sends caller headers alongside the preserved Host header', async () => {
110+
const fetch = createFetch();
111+
const res = await fetch(`http://admin.localhost:${port}/graphql`, {
112+
method: 'POST',
113+
headers: {
114+
'Content-Type': 'application/json',
115+
Accept: 'application/json',
116+
},
117+
body: JSON.stringify({ query: '{ test }' }),
118+
});
119+
120+
expect(res.ok).toBe(true);
121+
const json = await res.json();
122+
expect(json.host).toBe(`admin.localhost:${port}`);
123+
});
124+
125+
it('preserves plain localhost requests as-is (delegates to global fetch)', async () => {
126+
// Bare localhost is not a *.localhost subdomain, so it is handled by
127+
// global fetch, which resolves localhost via DNS. Bind on both families
128+
// so the delegated request connects regardless of resolution order.
129+
const { port: dualPort } = await startServer('localhost');
80130
const fetch = createFetch();
81-
const res = await fetch(`http://localhost:${port}/graphql`, {
131+
const res = await fetch(`http://localhost:${dualPort}/graphql`, {
82132
method: 'POST',
83133
headers: { 'Content-Type': 'application/json' },
84134
body: JSON.stringify({ query: '{ hello }' }),
85135
});
86136

87137
expect(res.ok).toBe(true);
88138
const json = await res.json();
89-
expect(json.host).toBe(`localhost:${port}`);
139+
expect(json.host).toBe(`localhost:${dualPort}`);
90140
});
141+
});
91142

92-
it('sends correct content-type header', async () => {
93-
const fetch = createFetch();
94-
const res = await fetch(`http://admin.localhost:${port}/graphql`, {
143+
describe('fetch with *.localhost (loopback option)', () => {
144+
it('loopback: false falls back to DNS resolution of localhost', async () => {
145+
const { port } = await startServer('localhost');
146+
const fetch = createFetch({ loopback: false });
147+
const res = await fetch(`http://api.localhost:${port}/graphql`, {
95148
method: 'POST',
96-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
97-
body: JSON.stringify({ query: '{ test }' }),
149+
headers: { 'Content-Type': 'application/json' },
150+
body: JSON.stringify({ query: '{ hello }' }),
98151
});
99152

100153
expect(res.ok).toBe(true);
101154
const json = await res.json();
102-
expect(json.host).toBe(`admin.localhost:${port}`);
155+
expect(json.host).toBe(`api.localhost:${port}`);
156+
});
157+
158+
it('loopback: "::1" reaches an IPv6-only ingress', async () => {
159+
if (!(await ipv6Available())) {
160+
console.warn('SKIP: IPv6 loopback unavailable in this environment');
161+
return;
162+
}
163+
const { port } = await startServer('::1');
164+
const fetch = createFetch({ loopback: '::1' });
165+
const res = await fetch(`http://api.localhost:${port}/graphql`, {
166+
method: 'POST',
167+
headers: { 'Content-Type': 'application/json' },
168+
body: JSON.stringify({ query: '{ hello }' }),
169+
});
170+
171+
expect(res.ok).toBe(true);
172+
const json = await res.json();
173+
expect(json.host).toBe(`api.localhost:${port}`);
103174
});
104175
});
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { createFetch, isLocalhostSubdomain } from './localhost-fetch.browser';
2-
export type { FetchFunction } from './types';
2+
export type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types';

‎packages/fetch/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { createFetch, isLocalhostSubdomain } from './localhost-fetch';
2-
export type { FetchFunction } from './types';
2+
export type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types';

‎packages/fetch/src/localhost-fetch.browser.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FetchFunction } from './types';
1+
import type { CreateFetchOptions, FetchFunction } from './types';
22

33
/**
44
* Returns true for *.localhost subdomains (e.g. auth.localhost)
@@ -18,12 +18,14 @@ let _fetch: FetchFunction | undefined;
1818
*
1919
* Browsers resolve *.localhost subdomains natively and do not have the
2020
* Host-header restriction that Node.js undici has, so no workaround
21-
* is needed — just return `globalThis.fetch`.
21+
* is needed — just return `globalThis.fetch`. The `options` argument
22+
* (e.g. `loopback`) is accepted for signature parity with the Node build
23+
* and ignored here.
2224
*
2325
* The result is cached — calling `createFetch()` multiple times returns
2426
* the same function instance.
2527
*/
26-
export function createFetch(): FetchFunction {
28+
export function createFetch(_options: CreateFetchOptions = {}): FetchFunction {
2729
if (_fetch) return _fetch;
2830
_fetch = globalThis.fetch.bind(globalThis);
2931
return _fetch;

‎packages/fetch/src/localhost-fetch.ts‎

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { FetchFunction } from './types';
1+
import type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types';
2+
3+
const DEFAULT_LOOPBACK: LoopbackAddress = '127.0.0.1';
24

35
/**
46
* Returns true for *.localhost subdomains (e.g. auth.localhost)
@@ -9,18 +11,24 @@ export function isLocalhostSubdomain(hostname: string): boolean {
911
}
1012

1113
/**
12-
* Build a fetch that uses node:http/node:https to bypass two Node.js
14+
* Build a fetch that uses node:http/node:https to bypass three Node.js
1315
* limitations with *.localhost subdomains:
1416
*
1517
* 1. DNS — Node cannot resolve *.localhost (ENOTFOUND on many OSes).
1618
* 2. Host header — Node's fetch (undici) treats Host as forbidden and
1719
* silently drops it, breaking server-side subdomain routing.
20+
* 3. Loopback family — `localhost` commonly resolves to IPv6 `::1` first,
21+
* but many local dev ingresses (kind, Docker port publishing) listen on
22+
* IPv4 only, so a DNS-driven connect hits `::1` and fails without
23+
* Happy-Eyeballs fallback. The connect is pinned to `loopback` (default
24+
* IPv4 `127.0.0.1`) to take DNS out of the loopback hop entirely.
1825
*
1926
* For non-localhost URLs this delegates to globalThis.fetch.
2027
*/
2128
function buildNodeFetch(
2229
http: typeof import('node:http'),
2330
https: typeof import('node:https'),
31+
loopback: LoopbackAddress | false,
2432
): FetchFunction {
2533
return (input, init) => {
2634
const url = new URL(
@@ -36,7 +44,10 @@ function buildNodeFetch(
3644
}
3745

3846
const originalHost = url.host;
39-
url.hostname = 'localhost';
47+
// Pin the connect target to the loopback interface, keeping the original
48+
// Host header so subdomain routing still works. `false` falls back to
49+
// DNS resolution of bare `localhost`.
50+
const connectHost = loopback === false ? 'localhost' : loopback;
4051

4152
return new Promise((resolve, reject) => {
4253
const headers: Record<string, string> = {
@@ -58,7 +69,11 @@ function buildNodeFetch(
5869

5970
const protocol = url.protocol === 'https:' ? https : http;
6071

61-
const req = protocol.request(url, {
72+
const req = protocol.request({
73+
protocol: url.protocol,
74+
hostname: connectHost,
75+
port: url.port === '' ? undefined : Number(url.port),
76+
path: `${url.pathname}${url.search}`,
6277
method: init?.method ?? 'GET',
6378
headers,
6479
}, (res) => {
@@ -100,20 +115,26 @@ function buildNodeFetch(
100115
}
101116

102117
/**
103-
* Cached fetch implementation — resolved once, reused for all calls.
118+
* Cached default fetch implementation — resolved once, reused for all calls
119+
* that use the default loopback. Non-default options build a fresh instance.
104120
*/
105-
let _fetch: FetchFunction | undefined;
121+
let _defaultFetch: FetchFunction | undefined;
106122

107123
/**
108124
* Create an isomorphic fetch function.
109125
*
110126
* - In **browsers** (and Deno/Bun/edge): returns `globalThis.fetch` as-is.
111127
* - In **Node.js**: returns a wrapper that uses `node:http`/`node:https`
112-
* for `*.localhost` URLs (fixing DNS + Host header) and delegates
113-
* everything else to `globalThis.fetch`.
128+
* for `*.localhost` URLs (fixing DNS, the dropped Host header, and the
129+
* IPv6-first loopback trap) and delegates everything else to
130+
* `globalThis.fetch`.
131+
*
132+
* The default-configuration result is cached — calling `createFetch()` (or
133+
* `createFetch({})`) repeatedly returns the same function instance.
114134
*
115-
* The result is cached — calling `createFetch()` multiple times returns
116-
* the same function instance.
135+
* @param options.loopback Loopback address to pin `*.localhost` connects to
136+
* (Node only). Defaults to `'127.0.0.1'`; pass `'::1'` for IPv6 or `false`
137+
* to fall back to DNS resolution of bare `localhost`.
117138
*
118139
* @example
119140
* ```ts
@@ -127,8 +148,13 @@ let _fetch: FetchFunction | undefined;
127148
* });
128149
* ```
129150
*/
130-
export function createFetch(): FetchFunction {
131-
if (_fetch) return _fetch;
151+
export function createFetch(options: CreateFetchOptions = {}): FetchFunction {
152+
const loopback = options.loopback ?? DEFAULT_LOOPBACK;
153+
const isDefault = loopback === DEFAULT_LOOPBACK;
154+
155+
if (isDefault && _defaultFetch) return _defaultFetch;
156+
157+
let fetchImpl: FetchFunction = globalThis.fetch;
132158

133159
// In Node.js, build a fetch that handles *.localhost via node:http
134160
if (typeof process !== 'undefined' && process.versions?.node) {
@@ -137,13 +163,12 @@ export function createFetch(): FetchFunction {
137163
const http = require('node:http');
138164

139165
const https = require('node:https');
140-
_fetch = buildNodeFetch(http, https);
141-
return _fetch;
166+
fetchImpl = buildNodeFetch(http, https, loopback);
142167
} catch {
143168
// node:http unavailable — fall through to globalThis.fetch
144169
}
145170
}
146171

147-
_fetch = globalThis.fetch;
148-
return _fetch;
172+
if (isDefault) _defaultFetch = fetchImpl;
173+
return fetchImpl;
149174
}

0 commit comments

Comments
 (0)