diff --git a/packages/fetch/__tests__/localhost-fetch.browser.test.ts b/packages/fetch/__tests__/localhost-fetch.browser.test.ts new file mode 100644 index 0000000..6b3fa85 --- /dev/null +++ b/packages/fetch/__tests__/localhost-fetch.browser.test.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { createFetch, isLocalhostSubdomain } from '../src/localhost-fetch.browser'; + +describe('browser build — static analysis', () => { + const distDir = path.resolve(__dirname, '../dist'); + + const browserFiles = [ + 'index.browser.js', + 'localhost-fetch.browser.js', + path.join('esm', 'index.browser.js'), + path.join('esm', 'localhost-fetch.browser.js'), + ]; + + it.each(browserFiles)('%s contains no node: scheme imports', (file) => { + const filePath = path.join(distDir, file); + if (!fs.existsSync(filePath)) { + // In CI, missing artifacts must fail — otherwise this regression + // check silently passes when tests run before the build step. + if (process.env.CI) { + throw new Error( + `Built artifact missing: ${filePath} — run 'makage build' before tests in CI`, + ); + } + console.warn(`SKIP: ${filePath} not found (run 'makage build' first)`); + return; + } + const content = fs.readFileSync(filePath, 'utf8'); + // Catch any node: URI scheme import (node:http, node:https, node:crypto, ...) + expect(content).not.toMatch(/['"]node:[a-z]+['"]/); + expect(content).not.toContain("require('node:"); + expect(content).not.toContain('require("node:'); + }); +}); + +describe('browser build — createFetch behavior', () => { + it('returns a function', () => { + const fetch = createFetch(); + expect(typeof fetch).toBe('function'); + }); + + it('returns the same instance on repeated calls', () => { + const a = createFetch(); + const b = createFetch(); + expect(a).toBe(b); + }); + + it('delegates to globalThis.fetch', async () => { + // The shim caches the bound fetch at module scope, so to observe the spy + // we must load a fresh module instance *after* installing the spy. + const spy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('ok')); + try { + await jest.isolateModulesAsync(async () => { + const { createFetch: freshCreateFetch } = await import( + '../src/localhost-fetch.browser' + ); + const fetch = freshCreateFetch(); + const res = await fetch('https://example.com'); + expect(spy).toHaveBeenCalledWith('https://example.com'); + expect(await res.text()).toBe('ok'); + }); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('browser build — isLocalhostSubdomain', () => { + it('returns true for *.localhost', () => { + expect(isLocalhostSubdomain('auth.localhost')).toBe(true); + expect(isLocalhostSubdomain('api.localhost')).toBe(true); + }); + + it('returns false for bare localhost', () => { + expect(isLocalhostSubdomain('localhost')).toBe(false); + }); + + it('returns false for non-localhost', () => { + expect(isLocalhostSubdomain('example.com')).toBe(false); + }); +}); diff --git a/packages/fetch/package.json b/packages/fetch/package.json index 402da9c..5d3bcbc 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -1,10 +1,16 @@ { "name": "@constructive-io/fetch", - "version": "1.0.0", + "version": "1.1.0", "author": "Constructive ", "description": "Isomorphic fetch wrapper — resolves *.localhost subdomains and preserves Host headers across Node.js and browsers", "main": "index.js", "module": "esm/index.js", + "browser": { + "./index.js": "./index.browser.js", + "./esm/index.js": "./esm/index.browser.js", + "./localhost-fetch.js": "./localhost-fetch.browser.js", + "./esm/localhost-fetch.js": "./esm/localhost-fetch.browser.js" + }, "types": "index.d.ts", "homepage": "https://github.com/constructive-io/dev-utils", "license": "MIT", diff --git a/packages/fetch/src/index.browser.ts b/packages/fetch/src/index.browser.ts new file mode 100644 index 0000000..c79889a --- /dev/null +++ b/packages/fetch/src/index.browser.ts @@ -0,0 +1,2 @@ +export { createFetch, isLocalhostSubdomain } from './localhost-fetch.browser'; +export type { FetchFunction } from './types'; diff --git a/packages/fetch/src/localhost-fetch.browser.ts b/packages/fetch/src/localhost-fetch.browser.ts new file mode 100644 index 0000000..7e37b5c --- /dev/null +++ b/packages/fetch/src/localhost-fetch.browser.ts @@ -0,0 +1,30 @@ +import type { FetchFunction } from './types'; + +/** + * Returns true for *.localhost subdomains (e.g. auth.localhost) + * but not for bare "localhost". + */ +export function isLocalhostSubdomain(hostname: string): boolean { + return hostname.endsWith('.localhost') && hostname !== 'localhost'; +} + +/** + * Cached fetch implementation — resolved once, reused for all calls. + */ +let _fetch: FetchFunction | undefined; + +/** + * Create a fetch function for browser environments. + * + * Browsers resolve *.localhost subdomains natively and do not have the + * Host-header restriction that Node.js undici has, so no workaround + * is needed — just return `globalThis.fetch`. + * + * The result is cached — calling `createFetch()` multiple times returns + * the same function instance. + */ +export function createFetch(): FetchFunction { + if (_fetch) return _fetch; + _fetch = globalThis.fetch.bind(globalThis); + return _fetch; +}