diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap
index 6bc1060a1..db5ce9497 100644
--- a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap
+++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap
@@ -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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
headers?: Record,
+ fetchFn?: typeof globalThis.fetch,
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(
document: string,
variables?: Record,
): Promise> {
- 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',
@@ -188,7 +375,7 @@ 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 {
@@ -196,7 +383,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -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',
diff --git a/graphql/codegen/src/__tests__/codegen/client-generator.test.ts b/graphql/codegen/src/__tests__/codegen/client-generator.test.ts
index 29e1ac056..e4cedc4f9 100644
--- a/graphql/codegen/src/__tests__/codegen/client-generator.test.ts
+++ b/graphql/codegen/src/__tests__/codegen/client-generator.test.ts
@@ -60,6 +60,30 @@ describe('client-generator', () => {
expect(result.content).toContain('QueryResult');
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', () => {
diff --git a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts
index 63f18029d..e019dd75b 100644
--- a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts
+++ b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts
@@ -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', () => {
diff --git a/graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts b/graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts
index 29225ea72..2a65aca72 100644
--- a/graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts
+++ b/graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts
@@ -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,
@@ -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);
}
}
diff --git a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts
index 571b8975a..fe3490ff5 100644
--- a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts
+++ b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts
@@ -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,
@@ -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);
}
}
diff --git a/graphql/codegen/src/core/codegen/templates/orm-client.ts b/graphql/codegen/src/core/codegen/templates/orm-client.ts
index 9d474a76e..eb2f0e42b 100644
--- a/graphql/codegen/src/core/codegen/templates/orm-client.ts
+++ b/graphql/codegen/src/core/codegen/templates/orm-client.ts
@@ -20,25 +20,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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
headers?: Record,
+ fetchFn?: typeof globalThis.fetch,
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(
document: string,
variables?: Record,
): Promise> {
- 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',
@@ -92,7 +279,7 @@ 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 {
@@ -100,7 +287,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -125,7 +319,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',
diff --git a/sdk/constructive-node/README.md b/sdk/constructive-node/README.md
index 016677a43..5913412c8 100644
--- a/sdk/constructive-node/README.md
+++ b/sdk/constructive-node/README.md
@@ -12,34 +12,33 @@
-Drop-in replacement for `@constructive-io/sdk` for Node.js applications.
-
-## Why?
-
-The base `@constructive-io/sdk` uses the Fetch API, which works great in browsers but has two limitations in Node.js:
+> [!WARNING]
+> **Deprecated.** Use `@constructive-io/sdk` directly. As of codegen 5.x, every
+> SDK generated by `@constructive-io/graphql-codegen` ships with an isomorphic
+> `FetchAdapter` that transparently handles Node's `*.localhost` DNS and
+> `Host`-header quirks — no Node-specific package required. This package
+> remains published for backwards compatibility and will be removed in a
+> future major release.
+>
+> **Migration:**
+>
+> ```diff
+> - import { admin, auth } from '@constructive-io/node';
+> + import { admin, auth } from '@constructive-io/sdk';
+> ```
+
+## Why this package existed
+
+The base `@constructive-io/sdk` used the Fetch API, which worked great in browsers but had two limitations in Node.js:
1. **DNS resolution**: Node.js cannot resolve `*.localhost` subdomains (e.g., `auth.localhost`), returning `ENOTFOUND`. Browsers handle this automatically.
2. **Host header**: The Fetch API treats `Host` as a forbidden header and silently drops it. The Constructive GraphQL server uses Host-header subdomain routing, so this header must be preserved.
-`@constructive-io/node` includes `NodeHttpAdapter`, which uses `node:http`/`node:https` directly to bypass both issues.
+Both limitations are now handled inside the generated `FetchAdapter` itself, so any SDK produced by `@constructive-io/graphql-codegen` just works in Node. Pass a custom `fetch` via `createClient({ endpoint, fetch })` to override that behavior.
-## Installation
-
-```bash
-npm install @constructive-io/node
-# or
-pnpm add @constructive-io/node
-```
-
-## Usage
-
-Everything from `@constructive-io/sdk` is re-exported, so this is a drop-in replacement. Just change your import:
+## Legacy usage (still works)
```ts
-// Before (browser/universal):
-import { admin, auth, public_ } from '@constructive-io/sdk';
-
-// After (Node.js):
import { admin, auth, public_, NodeHttpAdapter } from '@constructive-io/node';
```
diff --git a/sdk/constructive-node/jest.config.js b/sdk/constructive-node/jest.config.js
new file mode 100644
index 000000000..eecd07335
--- /dev/null
+++ b/sdk/constructive-node/jest.config.js
@@ -0,0 +1,18 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ babelConfig: false,
+ tsconfig: 'tsconfig.json'
+ }
+ ]
+ },
+ transformIgnorePatterns: [`/node_modules/*`],
+ testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ modulePathIgnorePatterns: ['dist/*']
+};
diff --git a/sdk/constructive-node/package.json b/sdk/constructive-node/package.json
index 6442ca309..a1d33fe44 100644
--- a/sdk/constructive-node/package.json
+++ b/sdk/constructive-node/package.json
@@ -2,7 +2,7 @@
"name": "@constructive-io/node",
"version": "0.10.10",
"author": "Constructive ",
- "description": "Constructive SDK for Node.js - Drop-in replacement for @constructive-io/sdk with Node.js HTTP adapter",
+ "description": "DEPRECATED — use @constructive-io/sdk directly. Node-specific quirks (*.localhost, Host header) are now handled by the generated SDK itself.",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
diff --git a/sdk/constructive-node/src/__tests__/fetch.test.ts b/sdk/constructive-node/src/__tests__/fetch.test.ts
new file mode 100644
index 000000000..91aa08f17
--- /dev/null
+++ b/sdk/constructive-node/src/__tests__/fetch.test.ts
@@ -0,0 +1,89 @@
+/**
+ * Tests for the Node-native `fetch` export.
+ *
+ * Covers the two Node-specific quirks that motivated this helper:
+ * - `*.localhost` subdomains are rewritten to a loopback target that
+ * resolves (Node's undici-backed fetch fails with ENOTFOUND otherwise).
+ * - The original hostname is preserved in the `Host` header so server-side
+ * subdomain routing (e.g. PostGraphile's enableServicesApi) still works.
+ */
+import http from 'node:http';
+import type { AddressInfo } from 'node:net';
+
+import { fetch } from '../fetch';
+
+type Captured = {
+ host?: string;
+ method?: string;
+ body: string;
+};
+
+function startEchoServer(): Promise<{
+ port: number;
+ captured: Captured;
+ close: () => Promise;
+}> {
+ const captured: Captured = { body: '' };
+ const server = http.createServer((req, res) => {
+ captured.host = req.headers.host;
+ captured.method = req.method;
+ const chunks: Buffer[] = [];
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
+ req.on('end', () => {
+ captured.body = Buffer.concat(chunks).toString('utf8');
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, echoed: captured.body }));
+ });
+ });
+
+ return new Promise((resolve) => {
+ server.listen(0, 'localhost', () => {
+ const { port } = server.address() as AddressInfo;
+ resolve({
+ port,
+ captured,
+ close: () =>
+ new Promise((r, rj) =>
+ server.close((err) => (err ? rj(err) : r())),
+ ),
+ });
+ });
+ });
+}
+
+describe('fetch (node)', () => {
+ it('routes *.localhost requests to loopback and preserves the original Host header', async () => {
+ const { port, captured, close } = await startEchoServer();
+ try {
+ const url = `http://auth.localhost:${port}/graphql`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ query: '{ __typename }' }),
+ });
+
+ expect(response.ok).toBe(true);
+ expect(captured.method).toBe('POST');
+ expect(captured.host).toBe(`auth.localhost:${port}`);
+ expect(captured.body).toBe('{"query":"{ __typename }"}');
+
+ const json = (await response.json()) as { ok: boolean };
+ expect(json.ok).toBe(true);
+ } finally {
+ await close();
+ }
+ });
+
+ it('leaves non-subdomain hostnames alone', async () => {
+ const { port, captured, close } = await startEchoServer();
+ try {
+ const response = await fetch(`http://localhost:${port}/`, {
+ method: 'GET',
+ });
+ expect(response.ok).toBe(true);
+ expect(captured.host).toBe(`localhost:${port}`);
+ } finally {
+ await close();
+ }
+ });
+});
diff --git a/sdk/constructive-node/src/fetch.ts b/sdk/constructive-node/src/fetch.ts
new file mode 100644
index 000000000..fbc6dbe97
--- /dev/null
+++ b/sdk/constructive-node/src/fetch.ts
@@ -0,0 +1,133 @@
+/**
+ * Node-native fetch implementation.
+ *
+ * Drop-in shape-compatible with `globalThis.fetch` but uses `node:http` /
+ * `node:https` under the hood. Addresses two limitations of Node's built-in
+ * undici-backed fetch that matter when talking to a local Constructive /
+ * PostGraphile server:
+ *
+ * 1. `*.localhost` subdomains (e.g. `api.localhost`) resolve via DNS and
+ * fail with `ENOTFOUND`. Here 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 still works.
+ *
+ * 2. The Fetch spec classifies `Host` as a forbidden request header and
+ * silently drops it. `node:http` has no such restriction, so the
+ * rewritten Host survives to the server.
+ *
+ * Pass to any consumer that accepts `typeof globalThis.fetch`:
+ *
+ * import { fetch } from '@constructive-io/node';
+ * const db = auth.createClient({
+ * endpoint: 'http://auth.localhost:3000/graphql',
+ * fetch,
+ * });
+ */
+
+import http from 'node:http';
+import https from 'node:https';
+
+export const fetch: typeof globalThis.fetch = async (input, init) => {
+ const url = resolveUrl(input);
+ const headers = normalizeHeaders(init?.headers);
+ const method = init?.method ?? 'GET';
+ const signal = init?.signal ?? undefined;
+ const body = init?.body ?? undefined;
+
+ let requestUrl = url;
+ if (isLocalhostSubdomain(url.hostname)) {
+ headers['host'] = url.host;
+ requestUrl = new URL(url.href);
+ requestUrl.hostname = 'localhost';
+ }
+
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(toAbortError(signal));
+ return;
+ }
+
+ const transport = requestUrl.protocol === 'https:' ? https : http;
+
+ const req = transport.request(
+ requestUrl,
+ { method, headers },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
+ res.on('end', () => {
+ resolve(
+ new Response(Buffer.concat(chunks), {
+ status: res.statusCode ?? 0,
+ statusText: res.statusMessage ?? '',
+ headers: toHeadersInit(res.headers),
+ }),
+ );
+ });
+ 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' || Buffer.isBuffer(body) ? body : String(body));
+ }
+ req.end();
+ });
+};
+
+function resolveUrl(input: RequestInfo | URL): URL {
+ if (input instanceof URL) return input;
+ if (typeof input === 'string') return new URL(input);
+ return new URL(input.url);
+}
+
+function normalizeHeaders(
+ headers: HeadersInit | undefined,
+): Record {
+ if (!headers) return {};
+ if (headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+function toHeadersInit(
+ nodeHeaders: http.IncomingHttpHeaders,
+): [string, string][] {
+ const out: [string, string][] = [];
+ for (const [k, v] of Object.entries(nodeHeaders)) {
+ if (v === undefined) continue;
+ out.push([k, Array.isArray(v) ? v.join(', ') : v]);
+ }
+ return out;
+}
+
+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;
+}
diff --git a/sdk/constructive-node/src/index.ts b/sdk/constructive-node/src/index.ts
index 358613cca..78206c39c 100644
--- a/sdk/constructive-node/src/index.ts
+++ b/sdk/constructive-node/src/index.ts
@@ -1,35 +1,22 @@
/**
* @constructive-io/node
*
- * Drop-in replacement for @constructive-io/sdk with Node.js HTTP adapter.
+ * @deprecated Use `@constructive-io/sdk` directly. As of codegen 5.x, every
+ * generated SDK ships with an isomorphic `FetchAdapter` that transparently
+ * handles Node's `*.localhost` DNS and `Host`-header quirks — no Node-specific
+ * package required. This package remains for backwards compatibility and will
+ * be removed in a future major release.
*
- * For Node.js applications, use this package instead of @constructive-io/sdk.
- * It re-exports everything from the base SDK and adds NodeHttpAdapter,
- * which uses node:http/node:https instead of the Fetch API to handle:
- *
- * 1. *.localhost subdomain DNS resolution (Node can't resolve these natively)
- * 2. Host header preservation (Fetch API silently drops it)
- *
- * @example
- * ```typescript
- * import { auth, NodeHttpAdapter } from '@constructive-io/node';
- *
- * const adapter = new NodeHttpAdapter(
- * 'http://auth.localhost:3000/graphql',
- * { Authorization: 'Bearer token' },
- * );
- *
- * const db = auth.orm.createClient({ adapter });
- *
- * const users = await db.user.findMany({
- * select: { id: true, name: true },
- * }).execute();
+ * @example Migration
+ * ```diff
+ * - import { admin, auth } from '@constructive-io/node';
+ * + import { admin, auth } from '@constructive-io/sdk';
* ```
*/
-// Re-export everything from the base SDK
export * from '@constructive-io/sdk';
-// Export the Node.js HTTP adapter
+export { fetch } from './fetch';
+
export { NodeHttpAdapter } from './node-http-adapter';
export type { NodeHttpExecuteOptions } from './node-http-adapter';
diff --git a/sdk/constructive-node/src/node-http-adapter.ts b/sdk/constructive-node/src/node-http-adapter.ts
index c0d07f0e8..2a44ba3f2 100644
--- a/sdk/constructive-node/src/node-http-adapter.ts
+++ b/sdk/constructive-node/src/node-http-adapter.ts
@@ -1,97 +1,11 @@
-/**
- * Node HTTP Adapter for Node.js applications
- *
- * Implements the GraphQLAdapter interface using node:http / node:https
- * instead of the Fetch API. This solves two Node.js limitations:
- *
- * 1. DNS: Node.js cannot resolve *.localhost subdomains (ENOTFOUND).
- * Browsers handle this automatically, but Node requires manual resolution.
- *
- * 2. Host header: The Fetch API treats "Host" as a forbidden request header
- * and silently drops it. The Constructive GraphQL server uses Host-header
- * subdomain routing (enableServicesApi), so this header must be preserved.
- *
- * By using node:http.request directly, both issues are bypassed cleanly
- * without any global patching.
- */
-
-import http from 'node:http';
-import https from 'node:https';
-
import type {
GraphQLAdapter,
GraphQLError,
QueryResult,
} from '@constructive-io/graphql-types';
-interface HttpResponse {
- statusCode: number;
- statusMessage: string;
- data: string;
-}
-
-/**
- * Check if a hostname is a localhost subdomain that needs special handling.
- * Returns true for *.localhost (e.g. auth.localhost) but not bare "localhost".
- */
-function isLocalhostSubdomain(hostname: string): boolean {
- return hostname.endsWith('.localhost') && hostname !== 'localhost';
-}
-
-/**
- * Make an HTTP/HTTPS request using native Node modules.
- * Supports optional AbortSignal for request cancellation.
- */
-function makeRequest(
- url: URL,
- options: http.RequestOptions,
- body: string,
- signal?: AbortSignal,
-): Promise {
- return new Promise((resolve, reject) => {
- if (signal?.aborted) {
- reject(new Error('The operation was aborted'));
- return;
- }
-
- const protocol = url.protocol === 'https:' ? https : http;
-
- const req = protocol.request(url, options, (res) => {
- let data = '';
- res.setEncoding('utf8');
- res.on('data', (chunk: string) => {
- data += chunk;
- });
- res.on('end', () => {
- resolve({
- statusCode: res.statusCode || 0,
- statusMessage: res.statusMessage || '',
- data,
- });
- });
- });
-
- req.on('error', reject);
+import { fetch } from './fetch';
- if (signal) {
- const onAbort = () => {
- req.destroy(new Error('The operation was aborted'));
- };
- signal.addEventListener('abort', onAbort, { once: true });
- req.on('close', () => {
- signal.removeEventListener('abort', onAbort);
- });
- }
-
- req.write(body);
- req.end();
- });
-}
-
-/**
- * Options for individual execute calls.
- * Allows per-request header overrides and request cancellation.
- */
export interface NodeHttpExecuteOptions {
/** Additional headers to include in this request only */
headers?: Record;
@@ -100,29 +14,24 @@ export interface NodeHttpExecuteOptions {
}
/**
- * GraphQL adapter that uses node:http/node:https for requests.
+ * GraphQL adapter that uses Node's native HTTP for requests.
*
- * Handles *.localhost subdomains by rewriting the hostname to "localhost"
- * and injecting the original Host header for server-side subdomain routing.
+ * Preserved for backwards compatibility. New code should prefer:
*
- * @example
- * ```typescript
- * import { NodeHttpAdapter } from '@constructive-io/node';
+ * import { fetch } from '@constructive-io/node';
+ * auth.createClient({ endpoint, fetch });
*
- * const adapter = new NodeHttpAdapter('http://auth.localhost:3000/graphql');
- * const db = createClient({ adapter });
- * ```
+ * The adapter now delegates to that same `fetch` internally, so behaviour
+ * (`*.localhost` rewriting, Host header preservation) matches exactly.
*/
export class NodeHttpAdapter implements GraphQLAdapter {
private headers: Record;
- private url: URL;
constructor(
private endpoint: string,
headers?: Record,
) {
this.headers = headers ?? {};
- this.url = new URL(endpoint);
}
async execute(
@@ -130,67 +39,41 @@ export class NodeHttpAdapter implements GraphQLAdapter {
variables?: Record,
options?: NodeHttpExecuteOptions,
): Promise> {
- const requestUrl = new URL(this.url.href);
- const requestHeaders: Record = {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- ...this.headers,
- ...options?.headers,
- };
-
- // For *.localhost subdomains, rewrite hostname and inject Host header
- if (isLocalhostSubdomain(requestUrl.hostname)) {
- requestHeaders['Host'] = requestUrl.host;
- requestUrl.hostname = 'localhost';
- }
-
- const body = JSON.stringify({
- query: document,
- variables: variables ?? {},
- });
-
- const requestOptions: http.RequestOptions = {
+ const response = await fetch(this.endpoint, {
method: 'POST',
- headers: requestHeaders,
- };
-
- const response = await makeRequest(
- requestUrl,
- requestOptions,
- body,
- options?.signal,
- );
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ ...this.headers,
+ ...options?.headers,
+ },
+ body: JSON.stringify({
+ query: document,
+ variables: variables ?? {},
+ }),
+ signal: options?.signal,
+ });
- if (response.statusCode < 200 || response.statusCode >= 300) {
+ if (!response.ok) {
return {
ok: false,
data: null,
errors: [
- {
- message: `HTTP ${response.statusCode}: ${response.statusMessage}`,
- },
+ { message: `HTTP ${response.status}: ${response.statusText}` },
],
};
}
- const json = JSON.parse(response.data) as {
+ const json = (await response.json()) as {
data?: T;
errors?: GraphQLError[];
};
if (json.errors && json.errors.length > 0) {
- return {
- ok: false,
- data: null,
- errors: json.errors,
- };
+ return { ok: false, data: null, errors: json.errors };
}
- return {
- ok: true,
- data: json.data as T,
- errors: undefined,
- };
+ return { ok: true, data: json.data as T, errors: undefined };
}
setHeaders(headers: Record): void {
diff --git a/sdk/constructive-react/src/admin/orm/client.ts b/sdk/constructive-react/src/admin/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-react/src/admin/orm/client.ts
+++ b/sdk/constructive-react/src/admin/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}
diff --git a/sdk/constructive-react/src/auth/orm/client.ts b/sdk/constructive-react/src/auth/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-react/src/auth/orm/client.ts
+++ b/sdk/constructive-react/src/auth/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}
diff --git a/sdk/constructive-react/src/objects/orm/client.ts b/sdk/constructive-react/src/objects/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-react/src/objects/orm/client.ts
+++ b/sdk/constructive-react/src/objects/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}
diff --git a/sdk/constructive-react/src/public/orm/client.ts b/sdk/constructive-react/src/public/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-react/src/public/orm/client.ts
+++ b/sdk/constructive-react/src/public/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}
diff --git a/sdk/constructive-sdk/src/admin/orm/client.ts b/sdk/constructive-sdk/src/admin/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-sdk/src/admin/orm/client.ts
+++ b/sdk/constructive-sdk/src/admin/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}
diff --git a/sdk/constructive-sdk/src/auth/orm/client.ts b/sdk/constructive-sdk/src/auth/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-sdk/src/auth/orm/client.ts
+++ b/sdk/constructive-sdk/src/auth/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}
diff --git a/sdk/constructive-sdk/src/objects/orm/client.ts b/sdk/constructive-sdk/src/objects/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-sdk/src/objects/orm/client.ts
+++ b/sdk/constructive-sdk/src/objects/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}
diff --git a/sdk/constructive-sdk/src/public/orm/client.ts b/sdk/constructive-sdk/src/public/orm/client.ts
index c0f12c466..23e92ef73 100644
--- a/sdk/constructive-sdk/src/public/orm/client.ts
+++ b/sdk/constructive-sdk/src/public/orm/client.ts
@@ -7,22 +7,188 @@ import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io
export type { GraphQLAdapter, GraphQLError, 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 | undefined;
+
+function resolveDefaultFetch(): Promise {
+ 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;
+ 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((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 {
+ if (!headers) return {};
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
+ const out: Record = {};
+ headers.forEach((value, key) => {
+ out[key] = value;
+ });
+ return out;
+ }
+ if (Array.isArray(headers)) {
+ const out: Record = {};
+ for (const [k, v] of headers) out[k] = v;
+ return out;
+ }
+ return { ...(headers as Record) };
+}
+
+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;
+ private fetchFn: typeof globalThis.fetch | undefined;
constructor(
private endpoint: string,
- headers?: Record
+ headers?: Record,
+ fetchFn?: typeof globalThis.fetch
) {
this.headers = headers ?? {};
+ this.fetchFn = fetchFn;
}
async execute(document: string, variables?: Record): Promise> {
- 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',
@@ -74,7 +240,7 @@ 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 {
@@ -82,7 +248,14 @@ export interface OrmClientConfig {
endpoint?: string;
/** Default headers for HTTP requests (only used with endpoint) */
headers?: Record;
- /** 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;
}
@@ -107,7 +280,7 @@ 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');
}