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
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import type {
GraphQLError,
QueryResult,
} from '@constructive-io/graphql-query/runtime';
import { createFetch } from '@constructive-io/graphql-query/runtime';

export type {
GraphQLAdapter,
Expand All @@ -118,23 +119,30 @@ export type {

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

constructor(
private endpoint: string,
headers?: Record<string, string>,
fetchFn?: typeof globalThis.fetch,
) {
this.headers = headers ?? {};
this.fetchFn = fetchFn ?? createFetch();
}

async execute<T>(
document: string,
variables?: Record<string, unknown>,
): Promise<QueryResult<T>> {
const response = await fetch(this.endpoint, {
const response = await this.fetchFn(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -188,15 +196,22 @@ export class FetchAdapter implements GraphQLAdapter {

/**
* Configuration for creating an ORM client.
* Either provide endpoint (and optional headers) for HTTP requests,
* Either provide endpoint (and optional headers/fetch) for HTTP requests,
* or provide a custom adapter for alternative execution strategies.
*/
export interface OrmClientConfig {
/** GraphQL endpoint URL (required if adapter not provided) */
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record<string, string>;
/** Custom adapter for GraphQL execution (overrides endpoint/headers) */
/**
* Custom fetch implementation. Defaults to createFetch() from
* @constructive-io/graphql-query/runtime which handles *.localhost
* DNS and Host headers in Node.js. Pass your own for test mocking
* or custom proxy/credentials.
*/
fetch?: typeof globalThis.fetch;
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
adapter?: GraphQLAdapter;
}

Expand All @@ -221,7 +236,11 @@ export class OrmClient {
if (config.adapter) {
this.adapter = config.adapter;
} else if (config.endpoint) {
this.adapter = new FetchAdapter(config.endpoint, config.headers);
this.adapter = new FetchAdapter(
config.endpoint,
config.headers,
config.fetch,
);
} else {
throw new Error(
'OrmClientConfig requires either an endpoint or a custom adapter',
Expand Down
16 changes: 16 additions & 0 deletions graphql/codegen/src/__tests__/codegen/client-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ describe('client-generator', () => {
expect(result.content).toContain('QueryResult<T>');
expect(result.content).toContain('GraphQLRequestError');
});

it('exposes an optional fetch injection in OrmClientConfig', () => {
const result = generateOrmClientFile();

expect(result.content).toContain('fetch?: typeof globalThis.fetch');
expect(result.content).toContain('config.fetch');
});

it('imports createFetch from @constructive-io/graphql-query/runtime', () => {
const result = generateOrmClientFile();

expect(result.content).toContain(
"import { createFetch } from '@constructive-io/graphql-query/runtime'",
);
expect(result.content).toContain('createFetch()');
});
});

describe('generateQueryBuilderFile', () => {
Expand Down
29 changes: 24 additions & 5 deletions graphql/codegen/src/core/codegen/templates/orm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
GraphQLError,
QueryResult,
} from '@constructive-io/graphql-query/runtime';
import { createFetch } from '@constructive-io/graphql-query/runtime';

export type {
GraphQLAdapter,
Expand All @@ -22,23 +23,30 @@ export type {

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

constructor(
private endpoint: string,
headers?: Record<string, string>,
fetchFn?: typeof globalThis.fetch,
) {
this.headers = headers ?? {};
this.fetchFn = fetchFn ?? createFetch();
}

async execute<T>(
document: string,
variables?: Record<string, unknown>,
): Promise<QueryResult<T>> {
const response = await fetch(this.endpoint, {
const response = await this.fetchFn(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -92,15 +100,22 @@ export class FetchAdapter implements GraphQLAdapter {

/**
* Configuration for creating an ORM client.
* Either provide endpoint (and optional headers) for HTTP requests,
* Either provide endpoint (and optional headers/fetch) for HTTP requests,
* or provide a custom adapter for alternative execution strategies.
*/
export interface OrmClientConfig {
/** GraphQL endpoint URL (required if adapter not provided) */
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record<string, string>;
/** Custom adapter for GraphQL execution (overrides endpoint/headers) */
/**
* Custom fetch implementation. Defaults to createFetch() from
* @constructive-io/graphql-query/runtime which handles *.localhost
* DNS and Host headers in Node.js. Pass your own for test mocking
* or custom proxy/credentials.
*/
fetch?: typeof globalThis.fetch;
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
adapter?: GraphQLAdapter;
}

Expand All @@ -125,7 +140,11 @@ export class OrmClient {
if (config.adapter) {
this.adapter = config.adapter;
} else if (config.endpoint) {
this.adapter = new FetchAdapter(config.endpoint, config.headers);
this.adapter = new FetchAdapter(
config.endpoint,
config.headers,
config.fetch,
);
} else {
throw new Error(
'OrmClientConfig requires either an endpoint or a custom adapter',
Expand Down
9 changes: 7 additions & 2 deletions graphql/query/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
* Runtime sub-export for generated ORM code.
*
* Generated ORM clients need runtime dependencies at execution time.
* This module re-exports two of the three so generated code can consolidate imports:
* This module re-exports so generated code can consolidate imports:
* - @0no-co/graphql.web — parseType, print
* - @constructive-io/graphql-types — GraphQLAdapter, GraphQLError, QueryResult
* - ./localhost-fetch — createFetch (isomorphic *.localhost-aware fetch)
*
* gql-ast is intentionally NOT re-exported here because the templates
* use `import * as t from 'gql-ast'` — mixing it into this namespace
* would pollute `t` with unrelated symbols like parseType and print.
*
* Usage in generated templates:
* import { parseType, print } from '@constructive-io/graphql-query/runtime';
* import { parseType, print, createFetch } from '@constructive-io/graphql-query/runtime';
* import * as t from 'gql-ast';
* import type { GraphQLAdapter } from '@constructive-io/graphql-query/runtime';
*/
Expand All @@ -21,3 +22,7 @@ export { parseType, print } from '@0no-co/graphql.web';

// From @constructive-io/graphql-types — adapter interface + result types
export type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io/graphql-types';

// Isomorphic fetch with *.localhost DNS + Host header fix for Node.js
export { createFetch } from './localhost-fetch';
export type { FetchFunction } from './localhost-fetch';
138 changes: 138 additions & 0 deletions graphql/query/src/runtime/localhost-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Isomorphic fetch that resolves *.localhost subdomains and preserves
* Host headers across Node.js and browsers.
*
* Node.js has two issues with *.localhost subdomains:
* 1. DNS — fetch('http://auth.localhost:3000/') throws ENOTFOUND
* because undici doesn't resolve *.localhost to loopback.
* 2. Host header — Node's fetch treats Host as forbidden and silently
* drops it, breaking server-side subdomain routing.
*
* In browsers *.localhost resolves natively, so createFetch() returns
* globalThis.fetch as-is.
*/

export type FetchFunction = typeof globalThis.fetch;

export function isLocalhostSubdomain(hostname: string): boolean {
return hostname.endsWith('.localhost') && hostname !== 'localhost';
}

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,
};

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();
});
};
}

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.
*/
export function createFetch(): FetchFunction {
if (_fetch) return _fetch;

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
}
}

_fetch = globalThis.fetch;
return _fetch;
}
Loading
Loading