Skip to content
Open
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 @@ -116,25 +116,212 @@ export type {
QueryResult,
} from '@constructive-io/graphql-types';

// ============================================================================
// Isomorphic default fetch
// ============================================================================
//
// In browsers and non-Node runtimes (Deno, Bun, edge workers), the native
// \`globalThis.fetch\` is used directly. In Node, we wrap it with a
// \`node:http\`/\`node:https\` implementation to work around two limitations of
// Node's undici-backed fetch when talking to local PostGraphile servers:
//
// 1. \`*.localhost\` subdomains (e.g. \`auth.localhost\`) fail DNS resolution
// with ENOTFOUND — the connection target is rewritten to plain
// \`localhost\` (which resolves via both IPv4 and IPv6 loopback per
// RFC 6761) while the original \`Host\` header is preserved so
// server-side subdomain routing keeps working.
// 2. The Fetch spec classifies \`Host\` as a forbidden request header and
// silently drops it. \`node:http\` has no such restriction.
//
// Callers can bypass this auto-detection by passing their own \`fetch\` to
// \`OrmClientConfig\` / \`FetchAdapter\`.

let _defaultFetchPromise: Promise<typeof globalThis.fetch> | undefined;

function resolveDefaultFetch(): Promise<typeof globalThis.fetch> {
if (_defaultFetchPromise) return _defaultFetchPromise;
return (_defaultFetchPromise = (async () => {
const g = globalThis as {
document?: unknown;
process?: { versions?: { node?: string } };
};
// Browser or any runtime with a DOM: native fetch is fine.
if (typeof g.document !== 'undefined') {
return globalThis.fetch;
}
// Non-Node runtimes (Deno, Bun, edge workers): native fetch is fine.
const isNode = !!g.process?.versions?.node;
if (!isNode) return globalThis.fetch;
try {
// Bundler-opaque dynamic import — browser bundlers cannot statically
// resolve \`node:http\` through \`new Function\`, so this branch is treated
// as dead code in non-Node bundles.
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
const dynImport = new Function('s', 'return import(s)') as (
spec: string,
) => Promise<unknown>;
const [http, https] = await Promise.all([
dynImport('node:http'),
dynImport('node:https'),
]);
return buildNodeFetch(http, https);
} catch {
return globalThis.fetch;
}
})());
}

function buildNodeFetch(
http: unknown,
https: unknown,
): typeof globalThis.fetch {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const httpMod: any = (http as any).default ?? http;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const httpsMod: any = (https as any).default ?? https;

return async (input, init) => {
const url = toUrl(input);
const headers = toHeaderRecord(init?.headers);
const method = init?.method ?? 'GET';
const body = init?.body ?? undefined;
const signal = init?.signal ?? undefined;

let requestUrl = url;
if (isLocalhostSubdomain(url.hostname)) {
headers['host'] = url.host;
requestUrl = new URL(url.href);
requestUrl.hostname = 'localhost';
}

return new Promise<Response>((resolve, reject) => {
if (signal?.aborted) {
reject(toAbortError(signal));
return;
}
const transport = requestUrl.protocol === 'https:' ? httpsMod : httpMod;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req: any = transport.request(
requestUrl,
{ method, headers },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(res: any) => {
const chunks: Uint8Array[] = [];
res.on('data', (chunk: Uint8Array) => chunks.push(chunk));
res.on('end', () => {
let total = 0;
for (const c of chunks) total += c.length;
const buf = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
buf.set(c, offset);
offset += c.length;
}
const outHeaders: [string, string][] = [];
for (const [k, v] of Object.entries(res.headers ?? {})) {
if (v === undefined) continue;
outHeaders.push([
k,
Array.isArray(v) ? v.join(', ') : String(v),
]);
}
resolve(
new Response(buf, {
status: res.statusCode ?? 0,
statusText: res.statusMessage ?? '',
headers: outHeaders,
}),
);
});
res.on('error', reject);
},
);

req.on('error', reject);
if (signal) {
const onAbort = () => req.destroy(toAbortError(signal));
signal.addEventListener('abort', onAbort, { once: true });
req.on('close', () =>
signal.removeEventListener('abort', onAbort),
);
}
if (body !== null && body !== undefined) {
req.write(
typeof body === 'string' || body instanceof Uint8Array
? body
: String(body),
);
}
req.end();
});
};
}

function toUrl(input: RequestInfo | URL): URL {
if (input instanceof URL) return input;
if (typeof input === 'string') return new URL(input);
return new URL((input as { url: string }).url);
}

function toHeaderRecord(
headers: HeadersInit | undefined,
): Record<string, string> {
if (!headers) return {};
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
const out: Record<string, string> = {};
headers.forEach((value, key) => {
out[key] = value;
});
return out;
}
if (Array.isArray(headers)) {
const out: Record<string, string> = {};
for (const [k, v] of headers) out[k] = v;
return out;
}
return { ...(headers as Record<string, string>) };
}

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

function toAbortError(signal: AbortSignal): Error {
const message =
(signal.reason as Error | undefined)?.message ??
'The operation was aborted';
const err = new Error(message);
err.name = 'AbortError';
return err;
}

/**
* Default adapter that uses fetch for HTTP requests.
* This is used when no custom adapter is provided.
*
* When no \`fetchFn\` is provided, defaults to an isomorphic fetch that uses
* \`globalThis.fetch\` in browsers/Deno/Bun and falls back to a Node-native
* wrapper in Node to handle \`*.localhost\` subdomain DNS and \`Host\` header
* preservation. Pass an explicit \`fetchFn\` to bypass this behavior.
*/
export class FetchAdapter implements GraphQLAdapter {
private headers: Record<string, string>;
private fetchFn: typeof globalThis.fetch | undefined;

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

async execute<T>(
document: string,
variables?: Record<string, unknown>,
): Promise<QueryResult<T>> {
const response = await fetch(this.endpoint, {
const fetchImpl = this.fetchFn ?? (await resolveDefaultFetch());
const response = await fetchImpl(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -188,15 +375,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. If omitted, an isomorphic default is
* used that auto-handles Node's \`*.localhost\` / Host-header quirks.
* Pass your own fetch to override that behavior (e.g. a mock in tests,
* or a fetch with preconfigured credentials/proxy).
*/
fetch?: typeof globalThis.fetch;
/** Custom adapter for GraphQL execution (overrides endpoint/headers/fetch) */
adapter?: GraphQLAdapter;
}

Expand All @@ -221,7 +415,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
24 changes: 24 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,30 @@ describe('client-generator', () => {
expect(result.content).toContain('QueryResult<T>');
expect(result.content).toContain('GraphQLRequestError');
});

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

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

it('bakes in isomorphic default fetch with Node quirks handling (issue #754)', () => {
const result = generateOrmClientFile();

// Browser/runtime detection short-circuits first
expect(result.content).toContain("g.document");
// Node-only code path uses bundler-opaque dynamic import
expect(result.content).toContain("new Function('s', 'return import(s)')");
expect(result.content).toContain("'node:http'");
expect(result.content).toContain("'node:https'");
// *.localhost rewrite + Host header preservation
expect(result.content).toContain(".endsWith('.localhost')");
expect(result.content).toContain("requestUrl.hostname = 'localhost'");
expect(result.content).toContain("headers['host'] = url.host");
// Execute uses the resolved default when no fetchFn is injected
expect(result.content).toContain('await resolveDefaultFetch()');
});
});

describe('generateQueryBuilderFile', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,39 @@ describe('collectInputTypeNames', () => {

expect(result.has('UserFilter')).toBe(true);
});

it('collects ENUM arg type names without Input/Filter suffix', () => {
const operations = [
{
args: [
{ name: 'role', type: createTypeRef('ENUM', 'UserRole') },
{
name: 'status',
type: createNonNull(createTypeRef('ENUM', 'AccountStatus')),
},
] as Argument[],
},
];

const result = collectInputTypeNames(operations);

expect(result.has('UserRole')).toBe(true);
expect(result.has('AccountStatus')).toBe(true);
});

it('collects INPUT_OBJECT arg type names without suffix match', () => {
const operations = [
{
args: [
{ name: 'custom', type: createTypeRef('INPUT_OBJECT', 'CustomPatch') },
] as Argument[],
},
];

const result = collectInputTypeNames(operations);

expect(result.has('CustomPatch')).toBe(true);
});
});

describe('collectPayloadTypeNames', () => {
Expand Down
17 changes: 10 additions & 7 deletions graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Argument, Operation } from '../../../types/schema';
import { addJSDocComment, generateCode } from '../babel-ast';
import { NON_SELECT_TYPES, getSelectTypeName } from '../select-helpers';
import {
getBaseTypeKind,
getTypeBaseName,
isTypeRequired,
scalarToTsType,
Expand All @@ -32,13 +33,15 @@ function collectInputTypeNamesFromOps(operations: Operation[]): string[] {
for (const op of operations) {
for (const arg of op.args) {
const baseName = getTypeBaseName(arg.type);
if (
baseName &&
(baseName.endsWith('Input') ||
baseName.endsWith('Filter') ||
baseName.endsWith('OrderBy') ||
baseName.endsWith('Condition'))
) {
if (!baseName) continue;
const baseKind = getBaseTypeKind(arg.type);
const isInputShape =
baseKind === 'INPUT_OBJECT' ||
baseName.endsWith('Input') ||
baseName.endsWith('Filter') ||
baseName.endsWith('OrderBy') ||
baseName.endsWith('Condition');
if (isInputShape || baseKind === 'ENUM') {
inputTypes.add(baseName);
}
}
Expand Down
14 changes: 9 additions & 5 deletions graphql/codegen/src/core/codegen/orm/input-types-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
} from '../../../types/schema';
import { addJSDocComment, addLineComment, generateCode } from '../babel-ast';
import { BASE_FILTER_TYPE_NAMES, SCALAR_NAMES, scalarToFilterType, scalarToTsType } from '../scalars';
import { getTypeBaseName } from '../type-resolver';
import { getBaseTypeKind, getTypeBaseName } from '../type-resolver';
import {
getCreateInputTypeName,
getFilterTypeName,
Expand Down Expand Up @@ -1521,10 +1521,14 @@ export function collectInputTypeNames(

function collectFromTypeRef(typeRef: Argument['type']) {
const baseName = getTypeBaseName(typeRef);
if (baseName && baseName.endsWith('Input')) {
inputTypes.add(baseName);
}
if (baseName && baseName.endsWith('Filter')) {
if (!baseName) return;
const baseKind = getBaseTypeKind(typeRef);
if (
baseName.endsWith('Input') ||
baseName.endsWith('Filter') ||
baseKind === 'INPUT_OBJECT' ||
baseKind === 'ENUM'
) {
inputTypes.add(baseName);
}
}
Expand Down
Loading
Loading