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