Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions packages/fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`

Expand Down
147 changes: 109 additions & 38 deletions packages/fetch/__tests__/localhost-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<ServerInfo> {
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<boolean> {
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);
Expand All @@ -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',
Expand All @@ -76,29 +106,70 @@ 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 }' }),
});

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}`);
});
});
2 changes: 1 addition & 1 deletion packages/fetch/src/index.browser.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { createFetch, isLocalhostSubdomain } from './localhost-fetch.browser';
export type { FetchFunction } from './types';
export type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types';
2 changes: 1 addition & 1 deletion packages/fetch/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { createFetch, isLocalhostSubdomain } from './localhost-fetch';
export type { FetchFunction } from './types';
export type { CreateFetchOptions, FetchFunction, LoopbackAddress } from './types';
8 changes: 5 additions & 3 deletions packages/fetch/src/localhost-fetch.browser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FetchFunction } from './types';
import type { CreateFetchOptions, FetchFunction } from './types';

/**
* Returns true for *.localhost subdomains (e.g. auth.localhost)
Expand All @@ -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;
Expand Down
57 changes: 41 additions & 16 deletions packages/fetch/src/localhost-fetch.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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(
Expand All @@ -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<string, string> = {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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;
}
Loading
Loading