@@ -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 */
123306export 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 */
194381export 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 ',
0 commit comments