Skip to content

Commit dc20423

Browse files
authored
Merge pull request #1033 from constructive-io/feat/simple-localhost-fetch
feat: add createFetch to runtime, remove @constructive-io/node
2 parents 662e599 + 2129d5d commit dc20423

26 files changed

Lines changed: 3313 additions & 8376 deletions

File tree

graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import type {
109109
GraphQLError,
110110
QueryResult,
111111
} from '@constructive-io/graphql-query/runtime';
112+
import { createFetch } from '@constructive-io/graphql-query/runtime';
112113
113114
export type {
114115
GraphQLAdapter,
@@ -118,23 +119,30 @@ export type {
118119
119120
/**
120121
* Default adapter that uses fetch for HTTP requests.
121-
* This is used when no custom adapter is provided.
122+
*
123+
* When no custom fetch is provided, uses @constructive-io/fetch which
124+
* handles *.localhost DNS rewriting and Host header preservation in
125+
* Node.js. Pass a custom fetch to override for test mocking or custom
126+
* proxy/credentials.
122127
*/
123128
export class FetchAdapter implements GraphQLAdapter {
124129
private headers: Record<string, string>;
130+
private fetchFn: typeof globalThis.fetch;
125131
126132
constructor(
127133
private endpoint: string,
128134
headers?: Record<string, string>,
135+
fetchFn?: typeof globalThis.fetch,
129136
) {
130137
this.headers = headers ?? {};
138+
this.fetchFn = fetchFn ?? createFetch();
131139
}
132140
133141
async execute<T>(
134142
document: string,
135143
variables?: Record<string, unknown>,
136144
): Promise<QueryResult<T>> {
137-
const response = await fetch(this.endpoint, {
145+
const response = await this.fetchFn(this.endpoint, {
138146
method: 'POST',
139147
headers: {
140148
'Content-Type': 'application/json',
@@ -188,15 +196,22 @@ export class FetchAdapter implements GraphQLAdapter {
188196
189197
/**
190198
* Configuration for creating an ORM client.
191-
* Either provide endpoint (and optional headers) for HTTP requests,
199+
* Either provide endpoint (and optional headers/fetch) for HTTP requests,
192200
* or provide a custom adapter for alternative execution strategies.
193201
*/
194202
export interface OrmClientConfig {
195203
/** GraphQL endpoint URL (required if adapter not provided) */
196204
endpoint?: string;
197205
/** Default headers for HTTP requests (only used with endpoint) */
198206
headers?: Record<string, string>;
199-
/** Custom adapter for GraphQL execution (overrides endpoint/headers) */
207+
/**
208+
* Custom fetch implementation. Defaults to createFetch() from
209+
* @constructive-io/graphql-query/runtime which handles *.localhost
210+
* DNS and Host headers in Node.js. Pass your own for test mocking
211+
* or custom proxy/credentials.
212+
*/
213+
fetch?: typeof globalThis.fetch;
214+
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
200215
adapter?: GraphQLAdapter;
201216
}
202217
@@ -221,7 +236,11 @@ export class OrmClient {
221236
if (config.adapter) {
222237
this.adapter = config.adapter;
223238
} else if (config.endpoint) {
224-
this.adapter = new FetchAdapter(config.endpoint, config.headers);
239+
this.adapter = new FetchAdapter(
240+
config.endpoint,
241+
config.headers,
242+
config.fetch,
243+
);
225244
} else {
226245
throw new Error(
227246
'OrmClientConfig requires either an endpoint or a custom adapter',

graphql/codegen/src/__tests__/codegen/client-generator.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ describe('client-generator', () => {
6060
expect(result.content).toContain('QueryResult<T>');
6161
expect(result.content).toContain('GraphQLRequestError');
6262
});
63+
64+
it('exposes an optional fetch injection in OrmClientConfig', () => {
65+
const result = generateOrmClientFile();
66+
67+
expect(result.content).toContain('fetch?: typeof globalThis.fetch');
68+
expect(result.content).toContain('config.fetch');
69+
});
70+
71+
it('imports createFetch from @constructive-io/graphql-query/runtime', () => {
72+
const result = generateOrmClientFile();
73+
74+
expect(result.content).toContain(
75+
"import { createFetch } from '@constructive-io/graphql-query/runtime'",
76+
);
77+
expect(result.content).toContain('createFetch()');
78+
});
6379
});
6480

6581
describe('generateQueryBuilderFile', () => {

graphql/codegen/src/core/codegen/templates/orm-client.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
GraphQLError,
1414
QueryResult,
1515
} from '@constructive-io/graphql-query/runtime';
16+
import { createFetch } from '@constructive-io/graphql-query/runtime';
1617

1718
export type {
1819
GraphQLAdapter,
@@ -22,23 +23,30 @@ export type {
2223

2324
/**
2425
* Default adapter that uses fetch for HTTP requests.
25-
* This is used when no custom adapter is provided.
26+
*
27+
* When no custom fetch is provided, uses @constructive-io/fetch which
28+
* handles *.localhost DNS rewriting and Host header preservation in
29+
* Node.js. Pass a custom fetch to override for test mocking or custom
30+
* proxy/credentials.
2631
*/
2732
export class FetchAdapter implements GraphQLAdapter {
2833
private headers: Record<string, string>;
34+
private fetchFn: typeof globalThis.fetch;
2935

3036
constructor(
3137
private endpoint: string,
3238
headers?: Record<string, string>,
39+
fetchFn?: typeof globalThis.fetch,
3340
) {
3441
this.headers = headers ?? {};
42+
this.fetchFn = fetchFn ?? createFetch();
3543
}
3644

3745
async execute<T>(
3846
document: string,
3947
variables?: Record<string, unknown>,
4048
): Promise<QueryResult<T>> {
41-
const response = await fetch(this.endpoint, {
49+
const response = await this.fetchFn(this.endpoint, {
4250
method: 'POST',
4351
headers: {
4452
'Content-Type': 'application/json',
@@ -92,15 +100,22 @@ export class FetchAdapter implements GraphQLAdapter {
92100

93101
/**
94102
* Configuration for creating an ORM client.
95-
* Either provide endpoint (and optional headers) for HTTP requests,
103+
* Either provide endpoint (and optional headers/fetch) for HTTP requests,
96104
* or provide a custom adapter for alternative execution strategies.
97105
*/
98106
export interface OrmClientConfig {
99107
/** GraphQL endpoint URL (required if adapter not provided) */
100108
endpoint?: string;
101109
/** Default headers for HTTP requests (only used with endpoint) */
102110
headers?: Record<string, string>;
103-
/** Custom adapter for GraphQL execution (overrides endpoint/headers) */
111+
/**
112+
* Custom fetch implementation. Defaults to createFetch() from
113+
* @constructive-io/graphql-query/runtime which handles *.localhost
114+
* DNS and Host headers in Node.js. Pass your own for test mocking
115+
* or custom proxy/credentials.
116+
*/
117+
fetch?: typeof globalThis.fetch;
118+
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
104119
adapter?: GraphQLAdapter;
105120
}
106121

@@ -125,7 +140,11 @@ export class OrmClient {
125140
if (config.adapter) {
126141
this.adapter = config.adapter;
127142
} else if (config.endpoint) {
128-
this.adapter = new FetchAdapter(config.endpoint, config.headers);
143+
this.adapter = new FetchAdapter(
144+
config.endpoint,
145+
config.headers,
146+
config.fetch,
147+
);
129148
} else {
130149
throw new Error(
131150
'OrmClientConfig requires either an endpoint or a custom adapter',

graphql/query/src/runtime/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22
* Runtime sub-export for generated ORM code.
33
*
44
* Generated ORM clients need runtime dependencies at execution time.
5-
* This module re-exports two of the three so generated code can consolidate imports:
5+
* This module re-exports so generated code can consolidate imports:
66
* - @0no-co/graphql.web — parseType, print
77
* - @constructive-io/graphql-types — GraphQLAdapter, GraphQLError, QueryResult
8+
* - ./localhost-fetch — createFetch (isomorphic *.localhost-aware fetch)
89
*
910
* gql-ast is intentionally NOT re-exported here because the templates
1011
* use `import * as t from 'gql-ast'` — mixing it into this namespace
1112
* would pollute `t` with unrelated symbols like parseType and print.
1213
*
1314
* Usage in generated templates:
14-
* import { parseType, print } from '@constructive-io/graphql-query/runtime';
15+
* import { parseType, print, createFetch } from '@constructive-io/graphql-query/runtime';
1516
* import * as t from 'gql-ast';
1617
* import type { GraphQLAdapter } from '@constructive-io/graphql-query/runtime';
1718
*/
@@ -21,3 +22,7 @@ export { parseType, print } from '@0no-co/graphql.web';
2122

2223
// From @constructive-io/graphql-types — adapter interface + result types
2324
export type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io/graphql-types';
25+
26+
// Isomorphic fetch with *.localhost DNS + Host header fix for Node.js
27+
export { createFetch } from './localhost-fetch';
28+
export type { FetchFunction } from './localhost-fetch';
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Isomorphic fetch that resolves *.localhost subdomains and preserves
3+
* Host headers across Node.js and browsers.
4+
*
5+
* Node.js has two issues with *.localhost subdomains:
6+
* 1. DNS — fetch('http://auth.localhost:3000/') throws ENOTFOUND
7+
* because undici doesn't resolve *.localhost to loopback.
8+
* 2. Host header — Node's fetch treats Host as forbidden and silently
9+
* drops it, breaking server-side subdomain routing.
10+
*
11+
* In browsers *.localhost resolves natively, so createFetch() returns
12+
* globalThis.fetch as-is.
13+
*/
14+
15+
export type FetchFunction = typeof globalThis.fetch;
16+
17+
export function isLocalhostSubdomain(hostname: string): boolean {
18+
return hostname.endsWith('.localhost') && hostname !== 'localhost';
19+
}
20+
21+
function buildNodeFetch(
22+
http: typeof import('node:http'),
23+
https: typeof import('node:https'),
24+
): FetchFunction {
25+
return (input, init) => {
26+
const url = new URL(
27+
typeof input === 'string'
28+
? input
29+
: input instanceof URL
30+
? input.href
31+
: input.url,
32+
);
33+
34+
if (!isLocalhostSubdomain(url.hostname)) {
35+
return globalThis.fetch(input, init);
36+
}
37+
38+
const originalHost = url.host;
39+
url.hostname = 'localhost';
40+
41+
return new Promise((resolve, reject) => {
42+
const headers: Record<string, string> = {
43+
Host: originalHost,
44+
};
45+
46+
if (init?.headers) {
47+
const entries =
48+
init.headers instanceof Headers
49+
? Array.from(init.headers.entries())
50+
: Array.isArray(init.headers)
51+
? init.headers
52+
: Object.entries(init.headers);
53+
for (const [key, value] of entries) {
54+
headers[key] = value;
55+
}
56+
}
57+
58+
const protocol = url.protocol === 'https:' ? https : http;
59+
60+
const req = protocol.request(
61+
url,
62+
{
63+
method: init?.method ?? 'GET',
64+
headers,
65+
},
66+
(res) => {
67+
const chunks: Buffer[] = [];
68+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
69+
res.on('end', () => {
70+
const body = Buffer.concat(chunks);
71+
resolve(
72+
new Response(body, {
73+
status: res.statusCode ?? 0,
74+
statusText: res.statusMessage ?? '',
75+
headers: res.headers as Record<string, string>,
76+
}),
77+
);
78+
});
79+
},
80+
);
81+
82+
req.on('error', reject);
83+
84+
if (init?.signal) {
85+
const onAbort = () => {
86+
req.destroy(new Error('The operation was aborted'));
87+
};
88+
init.signal.addEventListener('abort', onAbort, { once: true });
89+
req.on('close', () => {
90+
init.signal!.removeEventListener('abort', onAbort);
91+
});
92+
}
93+
94+
if (init?.body != null) {
95+
req.write(
96+
typeof init.body === 'string' || init.body instanceof Uint8Array
97+
? init.body
98+
: String(init.body),
99+
);
100+
}
101+
102+
req.end();
103+
});
104+
};
105+
}
106+
107+
let _fetch: FetchFunction | undefined;
108+
109+
/**
110+
* Create an isomorphic fetch function.
111+
*
112+
* - In browsers (and Deno/Bun/edge): returns globalThis.fetch as-is.
113+
* - In Node.js: returns a wrapper that uses node:http/node:https for
114+
* *.localhost URLs (fixing DNS + Host header) and delegates everything
115+
* else to globalThis.fetch.
116+
*
117+
* The result is cached — calling createFetch() multiple times returns
118+
* the same function instance.
119+
*/
120+
export function createFetch(): FetchFunction {
121+
if (_fetch) return _fetch;
122+
123+
if (typeof process !== 'undefined' && process.versions?.node) {
124+
try {
125+
// eslint-disable-next-line @typescript-eslint/no-require-imports
126+
const http = require('node:http');
127+
// eslint-disable-next-line @typescript-eslint/no-require-imports
128+
const https = require('node:https');
129+
_fetch = buildNodeFetch(http, https);
130+
return _fetch;
131+
} catch {
132+
// node:http unavailable — fall through
133+
}
134+
}
135+
136+
_fetch = globalThis.fetch;
137+
return _fetch;
138+
}

0 commit comments

Comments
 (0)