Skip to content

Commit 8dae36d

Browse files
yyyyaaaclaude
andcommitted
feat(codegen): bake isomorphic fetch into ORM client template (#754)
Generated SDKs now handle Node's *.localhost DNS and Host-header quirks out of the box, so no separate @constructive-io/node shim is required. The template resolves a default fetch at runtime: browsers/Deno/Bun use globalThis.fetch; Node wraps it with a node:http/node:https implementation that rewrites *.localhost hostnames to plain localhost (RFC 6761 — resolves on both IPv4 and IPv6 loopback) while preserving the original Host header so server-side subdomain routing keeps working. @constructive-io/node is now a thin deprecation shim over @constructive-io/sdk for backwards compatibility. Also ports the codegen enum-type collection fix so input types referenced only by ENUM arguments aren't dropped. Validated end-to-end against a live PostGraphile dev server: sign-in at auth.localhost:3000 returns a Bearer token that unlocks authenticated queries against api.localhost:3000 — all handled by the generated SDK's baked-in FetchAdapter with zero caller-side configuration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d172d10 commit 8dae36d

21 files changed

Lines changed: 2211 additions & 258 deletions

File tree

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

Lines changed: 203 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,25 +116,212 @@ export type {
116116
QueryResult,
117117
} from '@constructive-io/graphql-types';
118118
119+
// ============================================================================
120+
// Isomorphic default fetch
121+
// ============================================================================
122+
//
123+
// In browsers and non-Node runtimes (Deno, Bun, edge workers), the native
124+
// \`globalThis.fetch\` is used directly. In Node, we wrap it with a
125+
// \`node:http\`/\`node:https\` implementation to work around two limitations of
126+
// Node's undici-backed fetch when talking to local PostGraphile servers:
127+
//
128+
// 1. \`*.localhost\` subdomains (e.g. \`auth.localhost\`) fail DNS resolution
129+
// with ENOTFOUND — the connection target is rewritten to plain
130+
// \`localhost\` (which resolves via both IPv4 and IPv6 loopback per
131+
// RFC 6761) while the original \`Host\` header is preserved so
132+
// server-side subdomain routing keeps working.
133+
// 2. The Fetch spec classifies \`Host\` as a forbidden request header and
134+
// silently drops it. \`node:http\` has no such restriction.
135+
//
136+
// Callers can bypass this auto-detection by passing their own \`fetch\` to
137+
// \`OrmClientConfig\` / \`FetchAdapter\`.
138+
139+
let _defaultFetchPromise: Promise<typeof globalThis.fetch> | undefined;
140+
141+
function resolveDefaultFetch(): Promise<typeof globalThis.fetch> {
142+
if (_defaultFetchPromise) return _defaultFetchPromise;
143+
return (_defaultFetchPromise = (async () => {
144+
const g = globalThis as {
145+
document?: unknown;
146+
process?: { versions?: { node?: string } };
147+
};
148+
// Browser or any runtime with a DOM: native fetch is fine.
149+
if (typeof g.document !== 'undefined') {
150+
return globalThis.fetch;
151+
}
152+
// Non-Node runtimes (Deno, Bun, edge workers): native fetch is fine.
153+
const isNode = !!g.process?.versions?.node;
154+
if (!isNode) return globalThis.fetch;
155+
try {
156+
// Bundler-opaque dynamic import — browser bundlers cannot statically
157+
// resolve \`node:http\` through \`new Function\`, so this branch is treated
158+
// as dead code in non-Node bundles.
159+
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
160+
const dynImport = new Function('s', 'return import(s)') as (
161+
spec: string,
162+
) => Promise<unknown>;
163+
const [http, https] = await Promise.all([
164+
dynImport('node:http'),
165+
dynImport('node:https'),
166+
]);
167+
return buildNodeFetch(http, https);
168+
} catch {
169+
return globalThis.fetch;
170+
}
171+
})());
172+
}
173+
174+
function buildNodeFetch(
175+
http: unknown,
176+
https: unknown,
177+
): typeof globalThis.fetch {
178+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
179+
const httpMod: any = (http as any).default ?? http;
180+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
181+
const httpsMod: any = (https as any).default ?? https;
182+
183+
return async (input, init) => {
184+
const url = toUrl(input);
185+
const headers = toHeaderRecord(init?.headers);
186+
const method = init?.method ?? 'GET';
187+
const body = init?.body ?? undefined;
188+
const signal = init?.signal ?? undefined;
189+
190+
let requestUrl = url;
191+
if (isLocalhostSubdomain(url.hostname)) {
192+
headers['host'] = url.host;
193+
requestUrl = new URL(url.href);
194+
requestUrl.hostname = 'localhost';
195+
}
196+
197+
return new Promise<Response>((resolve, reject) => {
198+
if (signal?.aborted) {
199+
reject(toAbortError(signal));
200+
return;
201+
}
202+
const transport = requestUrl.protocol === 'https:' ? httpsMod : httpMod;
203+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
204+
const req: any = transport.request(
205+
requestUrl,
206+
{ method, headers },
207+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
208+
(res: any) => {
209+
const chunks: Uint8Array[] = [];
210+
res.on('data', (chunk: Uint8Array) => chunks.push(chunk));
211+
res.on('end', () => {
212+
let total = 0;
213+
for (const c of chunks) total += c.length;
214+
const buf = new Uint8Array(total);
215+
let offset = 0;
216+
for (const c of chunks) {
217+
buf.set(c, offset);
218+
offset += c.length;
219+
}
220+
const outHeaders: [string, string][] = [];
221+
for (const [k, v] of Object.entries(res.headers ?? {})) {
222+
if (v === undefined) continue;
223+
outHeaders.push([
224+
k,
225+
Array.isArray(v) ? v.join(', ') : String(v),
226+
]);
227+
}
228+
resolve(
229+
new Response(buf, {
230+
status: res.statusCode ?? 0,
231+
statusText: res.statusMessage ?? '',
232+
headers: outHeaders,
233+
}),
234+
);
235+
});
236+
res.on('error', reject);
237+
},
238+
);
239+
240+
req.on('error', reject);
241+
if (signal) {
242+
const onAbort = () => req.destroy(toAbortError(signal));
243+
signal.addEventListener('abort', onAbort, { once: true });
244+
req.on('close', () =>
245+
signal.removeEventListener('abort', onAbort),
246+
);
247+
}
248+
if (body !== null && body !== undefined) {
249+
req.write(
250+
typeof body === 'string' || body instanceof Uint8Array
251+
? body
252+
: String(body),
253+
);
254+
}
255+
req.end();
256+
});
257+
};
258+
}
259+
260+
function toUrl(input: RequestInfo | URL): URL {
261+
if (input instanceof URL) return input;
262+
if (typeof input === 'string') return new URL(input);
263+
return new URL((input as { url: string }).url);
264+
}
265+
266+
function toHeaderRecord(
267+
headers: HeadersInit | undefined,
268+
): Record<string, string> {
269+
if (!headers) return {};
270+
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
271+
const out: Record<string, string> = {};
272+
headers.forEach((value, key) => {
273+
out[key] = value;
274+
});
275+
return out;
276+
}
277+
if (Array.isArray(headers)) {
278+
const out: Record<string, string> = {};
279+
for (const [k, v] of headers) out[k] = v;
280+
return out;
281+
}
282+
return { ...(headers as Record<string, string>) };
283+
}
284+
285+
function isLocalhostSubdomain(hostname: string): boolean {
286+
return hostname.endsWith('.localhost') && hostname !== 'localhost';
287+
}
288+
289+
function toAbortError(signal: AbortSignal): Error {
290+
const message =
291+
(signal.reason as Error | undefined)?.message ??
292+
'The operation was aborted';
293+
const err = new Error(message);
294+
err.name = 'AbortError';
295+
return err;
296+
}
297+
119298
/**
120299
* Default adapter that uses fetch for HTTP requests.
121-
* This is used when no custom adapter is provided.
300+
*
301+
* When no \`fetchFn\` is provided, defaults to an isomorphic fetch that uses
302+
* \`globalThis.fetch\` in browsers/Deno/Bun and falls back to a Node-native
303+
* wrapper in Node to handle \`*.localhost\` subdomain DNS and \`Host\` header
304+
* preservation. Pass an explicit \`fetchFn\` to bypass this behavior.
122305
*/
123306
export class FetchAdapter implements GraphQLAdapter {
124307
private headers: Record<string, string>;
308+
private fetchFn: typeof globalThis.fetch | undefined;
125309
126310
constructor(
127311
private endpoint: string,
128312
headers?: Record<string, string>,
313+
fetchFn?: typeof globalThis.fetch,
129314
) {
130315
this.headers = headers ?? {};
316+
this.fetchFn = fetchFn;
131317
}
132318
133319
async execute<T>(
134320
document: string,
135321
variables?: Record<string, unknown>,
136322
): Promise<QueryResult<T>> {
137-
const response = await fetch(this.endpoint, {
323+
const fetchImpl = this.fetchFn ?? (await resolveDefaultFetch());
324+
const response = await fetchImpl(this.endpoint, {
138325
method: 'POST',
139326
headers: {
140327
'Content-Type': 'application/json',
@@ -188,15 +375,22 @@ export class FetchAdapter implements GraphQLAdapter {
188375
189376
/**
190377
* Configuration for creating an ORM client.
191-
* Either provide endpoint (and optional headers) for HTTP requests,
378+
* Either provide endpoint (and optional headers/fetch) for HTTP requests,
192379
* or provide a custom adapter for alternative execution strategies.
193380
*/
194381
export interface OrmClientConfig {
195382
/** GraphQL endpoint URL (required if adapter not provided) */
196383
endpoint?: string;
197384
/** Default headers for HTTP requests (only used with endpoint) */
198385
headers?: Record<string, string>;
199-
/** Custom adapter for GraphQL execution (overrides endpoint/headers) */
386+
/**
387+
* Custom fetch implementation. If omitted, an isomorphic default is
388+
* used that auto-handles Node's \`*.localhost\` / Host-header quirks.
389+
* Pass your own fetch to override that behavior (e.g. a mock in tests,
390+
* or a fetch with preconfigured credentials/proxy).
391+
*/
392+
fetch?: typeof globalThis.fetch;
393+
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
200394
adapter?: GraphQLAdapter;
201395
}
202396
@@ -221,7 +415,11 @@ export class OrmClient {
221415
if (config.adapter) {
222416
this.adapter = config.adapter;
223417
} else if (config.endpoint) {
224-
this.adapter = new FetchAdapter(config.endpoint, config.headers);
418+
this.adapter = new FetchAdapter(
419+
config.endpoint,
420+
config.headers,
421+
config.fetch,
422+
);
225423
} else {
226424
throw new Error(
227425
'OrmClientConfig requires either an endpoint or a custom adapter',

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,30 @@ 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 (issue #754)', () => {
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('bakes in isomorphic default fetch with Node quirks handling (issue #754)', () => {
72+
const result = generateOrmClientFile();
73+
74+
// Browser/runtime detection short-circuits first
75+
expect(result.content).toContain("g.document");
76+
// Node-only code path uses bundler-opaque dynamic import
77+
expect(result.content).toContain("new Function('s', 'return import(s)')");
78+
expect(result.content).toContain("'node:http'");
79+
expect(result.content).toContain("'node:https'");
80+
// *.localhost rewrite + Host header preservation
81+
expect(result.content).toContain(".endsWith('.localhost')");
82+
expect(result.content).toContain("requestUrl.hostname = 'localhost'");
83+
expect(result.content).toContain("headers['host'] = url.host");
84+
// Execute uses the resolved default when no fetchFn is injected
85+
expect(result.content).toContain('await resolveDefaultFetch()');
86+
});
6387
});
6488

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

graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,39 @@ describe('collectInputTypeNames', () => {
863863

864864
expect(result.has('UserFilter')).toBe(true);
865865
});
866+
867+
it('collects ENUM arg type names without Input/Filter suffix', () => {
868+
const operations = [
869+
{
870+
args: [
871+
{ name: 'role', type: createTypeRef('ENUM', 'UserRole') },
872+
{
873+
name: 'status',
874+
type: createNonNull(createTypeRef('ENUM', 'AccountStatus')),
875+
},
876+
] as Argument[],
877+
},
878+
];
879+
880+
const result = collectInputTypeNames(operations);
881+
882+
expect(result.has('UserRole')).toBe(true);
883+
expect(result.has('AccountStatus')).toBe(true);
884+
});
885+
886+
it('collects INPUT_OBJECT arg type names without suffix match', () => {
887+
const operations = [
888+
{
889+
args: [
890+
{ name: 'custom', type: createTypeRef('INPUT_OBJECT', 'CustomPatch') },
891+
] as Argument[],
892+
},
893+
];
894+
895+
const result = collectInputTypeNames(operations);
896+
897+
expect(result.has('CustomPatch')).toBe(true);
898+
});
866899
});
867900

868901
describe('collectPayloadTypeNames', () => {

graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { Argument, Operation } from '../../../types/schema';
1010
import { addJSDocComment, generateCode } from '../babel-ast';
1111
import { NON_SELECT_TYPES, getSelectTypeName } from '../select-helpers';
1212
import {
13+
getBaseTypeKind,
1314
getTypeBaseName,
1415
isTypeRequired,
1516
scalarToTsType,
@@ -32,13 +33,15 @@ function collectInputTypeNamesFromOps(operations: Operation[]): string[] {
3233
for (const op of operations) {
3334
for (const arg of op.args) {
3435
const baseName = getTypeBaseName(arg.type);
35-
if (
36-
baseName &&
37-
(baseName.endsWith('Input') ||
38-
baseName.endsWith('Filter') ||
39-
baseName.endsWith('OrderBy') ||
40-
baseName.endsWith('Condition'))
41-
) {
36+
if (!baseName) continue;
37+
const baseKind = getBaseTypeKind(arg.type);
38+
const isInputShape =
39+
baseKind === 'INPUT_OBJECT' ||
40+
baseName.endsWith('Input') ||
41+
baseName.endsWith('Filter') ||
42+
baseName.endsWith('OrderBy') ||
43+
baseName.endsWith('Condition');
44+
if (isInputShape || baseKind === 'ENUM') {
4245
inputTypes.add(baseName);
4346
}
4447
}

graphql/codegen/src/core/codegen/orm/input-types-generator.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
} from '../../../types/schema';
2121
import { addJSDocComment, addLineComment, generateCode } from '../babel-ast';
2222
import { BASE_FILTER_TYPE_NAMES, SCALAR_NAMES, scalarToFilterType, scalarToTsType } from '../scalars';
23-
import { getTypeBaseName } from '../type-resolver';
23+
import { getBaseTypeKind, getTypeBaseName } from '../type-resolver';
2424
import {
2525
getCreateInputTypeName,
2626
getFilterTypeName,
@@ -1521,10 +1521,14 @@ export function collectInputTypeNames(
15211521

15221522
function collectFromTypeRef(typeRef: Argument['type']) {
15231523
const baseName = getTypeBaseName(typeRef);
1524-
if (baseName && baseName.endsWith('Input')) {
1525-
inputTypes.add(baseName);
1526-
}
1527-
if (baseName && baseName.endsWith('Filter')) {
1524+
if (!baseName) return;
1525+
const baseKind = getBaseTypeKind(typeRef);
1526+
if (
1527+
baseName.endsWith('Input') ||
1528+
baseName.endsWith('Filter') ||
1529+
baseKind === 'INPUT_OBJECT' ||
1530+
baseKind === 'ENUM'
1531+
) {
15281532
inputTypes.add(baseName);
15291533
}
15301534
}

0 commit comments

Comments
 (0)