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
43 changes: 43 additions & 0 deletions packages/fetch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# @constructive-io/fetch

Isomorphic fetch that resolves `*.localhost` subdomains and preserves `Host` headers across Node.js and browsers.

## Why

Node.js has two 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.

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`.

## Install

```bash
npm install @constructive-io/fetch
```

## Usage

```ts
import { createFetch } from '@constructive-io/fetch';

const fetch = createFetch();

// Works in Node.js — DNS resolves, Host header preserved
const res = await fetch('http://auth.localhost:3000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ currentUser { id } }' }),
});
```

## API

### `createFetch(): 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.

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

Returns `true` for `*.localhost` subdomains (e.g. `auth.localhost`), `false` for bare `localhost` and everything else.
104 changes: 104 additions & 0 deletions packages/fetch/__tests__/localhost-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import http from 'node:http';

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

describe('isLocalhostSubdomain', () => {
it('returns true for *.localhost', () => {
expect(isLocalhostSubdomain('auth.localhost')).toBe(true);
expect(isLocalhostSubdomain('api.localhost')).toBe(true);
expect(isLocalhostSubdomain('deep.sub.localhost')).toBe(true);
});

it('returns false for bare localhost', () => {
expect(isLocalhostSubdomain('localhost')).toBe(false);
});

it('returns false for non-localhost', () => {
expect(isLocalhostSubdomain('example.com')).toBe(false);
expect(isLocalhostSubdomain('auth.example.com')).toBe(false);
});
});

describe('createFetch', () => {
it('returns a function', () => {
const fetch = createFetch();
expect(typeof fetch).toBe('function');
});

it('returns the same instance on repeated calls', () => {
const a = createFetch();
const b = createFetch();
expect(a).toBe(b);
});
});

describe('fetch with *.localhost', () => {
let server: http.Server;
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();
});
});

afterAll((done) => {
server.close(done);
});

it('rewrites *.localhost URL to localhost and preserves Host header', async () => {
const fetch = createFetch();
const res = await fetch(`http://auth.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(`auth.localhost:${port}`);
expect(json.method).toBe('POST');
expect(json.url).toBe('/graphql');
expect(JSON.parse(json.body)).toEqual({ query: '{ hello }' });
});

it('preserves plain localhost requests as-is', async () => {
const fetch = createFetch();
const res = await fetch(`http://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(`localhost:${port}`);
});

it('sends correct content-type 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}`);
});
});
18 changes: 18 additions & 0 deletions packages/fetch/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
};
43 changes: 43 additions & 0 deletions packages/fetch/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@constructive-io/fetch",
"version": "0.1.0",
"author": "Constructive <developers@constructive.io>",
"description": "Isomorphic fetch wrapper — resolves *.localhost subdomains and preserves Host headers across Node.js and browsers",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"homepage": "https://github.com/constructive-io/dev-utils",
"license": "MIT",
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/dev-utils"
},
"bugs": {
"url": "https://github.com/constructive-io/dev-utils/issues"
},
"scripts": {
"copy": "makage assets",
"clean": "makage clean",
"prepublishOnly": "npm run build",
"build": "makage build",
"lint": "eslint . --fix",
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [
"fetch",
"isomorphic",
"localhost",
"node",
"browser",
"dns",
"host-header"
],
"devDependencies": {
"makage": "0.1.10"
}
}
2 changes: 2 additions & 0 deletions packages/fetch/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createFetch, isLocalhostSubdomain } from './localhost-fetch';
export type { FetchFunction } from './types';
149 changes: 149 additions & 0 deletions packages/fetch/src/localhost-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { FetchFunction } from './types';

/**
* Returns true for *.localhost subdomains (e.g. auth.localhost)
* but not for bare "localhost".
*/
export function isLocalhostSubdomain(hostname: string): boolean {
return hostname.endsWith('.localhost') && hostname !== 'localhost';
}

/**
* Build a fetch that uses node:http/node:https to bypass two 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.
*
* For non-localhost URLs this delegates to globalThis.fetch.
*/
function buildNodeFetch(
http: typeof import('node:http'),
https: typeof import('node:https'),
): FetchFunction {
return (input, init) => {
const url = new URL(
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url,
);

if (!isLocalhostSubdomain(url.hostname)) {
return globalThis.fetch(input, init);
}

const originalHost = url.host;
url.hostname = 'localhost';

return new Promise((resolve, reject) => {
const headers: Record<string, string> = {
Host: originalHost,
};

// Copy headers from init
if (init?.headers) {
const entries =
init.headers instanceof Headers
? Array.from(init.headers.entries())
: Array.isArray(init.headers)
? init.headers
: Object.entries(init.headers);
for (const [key, value] of entries) {
headers[key] = value;
}
}

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

const req = protocol.request(url, {
method: init?.method ?? 'GET',
headers,
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks);
resolve(new Response(body, {
status: res.statusCode ?? 0,
statusText: res.statusMessage ?? '',
headers: res.headers as Record<string, string>,
}));
});
});

req.on('error', reject);

if (init?.signal) {
const onAbort = () => {
req.destroy(new Error('The operation was aborted'));
};
init.signal.addEventListener('abort', onAbort, { once: true });
req.on('close', () => {
init.signal!.removeEventListener('abort', onAbort);
});
}

if (init?.body != null) {
req.write(
typeof init.body === 'string' || init.body instanceof Uint8Array
? init.body
: String(init.body),
);
}

req.end();
});
};
}

/**
* Cached fetch implementation — resolved once, reused for all calls.
*/
let _fetch: 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`.
*
* The result is cached — calling `createFetch()` multiple times returns
* the same function instance.
*
* @example
* ```ts
* import { createFetch } from '@constructive-io/fetch';
*
* const fetch = createFetch();
* const res = await fetch('http://auth.localhost:3000/graphql', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* body: JSON.stringify({ query: '{ currentUser { id } }' }),
* });
* ```
*/
export function createFetch(): FetchFunction {
if (_fetch) return _fetch;

// In Node.js, build a fetch that handles *.localhost via node:http
if (typeof process !== 'undefined' && process.versions?.node) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require('node:http');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const https = require('node:https');
_fetch = buildNodeFetch(http, https);
return _fetch;
} catch {
// node:http unavailable — fall through to globalThis.fetch
}
}

_fetch = globalThis.fetch;
return _fetch;
}
1 change: 1 addition & 0 deletions packages/fetch/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type FetchFunction = typeof globalThis.fetch;
9 changes: 9 additions & 0 deletions packages/fetch/tsconfig.esm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist/esm",
"module": "es2022",
"rootDir": "src/",
"declaration": false
}
}
9 changes: 9 additions & 0 deletions packages/fetch/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src/"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"]
}
Loading
Loading