From 263fa930a796298031763c13683c3075db00a6b4 Mon Sep 17 00:00:00 2001 From: Gefei Hou Date: Thu, 21 May 2026 07:49:31 -0700 Subject: [PATCH 1/2] Fix fetch build error for web --- .../__tests__/localhost-fetch.browser.test.ts | 84 +++++++++++++++++++ packages/fetch/package.json | 24 +++++- packages/fetch/src/index.browser.ts | 2 + packages/fetch/src/localhost-fetch.browser.ts | 30 +++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 packages/fetch/__tests__/localhost-fetch.browser.test.ts create mode 100644 packages/fetch/src/index.browser.ts create mode 100644 packages/fetch/src/localhost-fetch.browser.ts 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 00000000..6b3fa85f --- /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 402da9cd..48d4bb13 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -1,11 +1,33 @@ { "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.browser.js", "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "browser": { + "import": "./esm/index.browser.js", + "require": "./index.browser.js" + }, + "workerd": { + "import": "./esm/index.browser.js", + "require": "./index.browser.js" + }, + "node": { + "import": "./esm/index.js", + "require": "./index.js" + }, + "default": { + "import": "./esm/index.browser.js", + "require": "./index.browser.js" + } + } + }, "homepage": "https://github.com/constructive-io/dev-utils", "license": "MIT", "publishConfig": { diff --git a/packages/fetch/src/index.browser.ts b/packages/fetch/src/index.browser.ts new file mode 100644 index 00000000..c79889ac --- /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 00000000..7e37b5c2 --- /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; +} From efe5ae2a3715cedba178f532b148399b9f0efdd8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 21 May 2026 20:33:35 +0000 Subject: [PATCH 2/2] refactor: replace exports map with browser field object map Remove the exports field (anti-pattern per constructive-pnpm standards) and use the browser field object form instead. This tells webpack and other bundlers to swap node-specific files for their browser-safe equivalents without requiring exports map complexity. The browser field maps both CJS and ESM entry points plus the internal localhost-fetch module to their .browser counterparts, ensuring no node:http/node:https references appear in browser bundles. --- packages/fetch/package.json | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/packages/fetch/package.json b/packages/fetch/package.json index 48d4bb13..5d3bcbc1 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -5,29 +5,13 @@ "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.browser.js", - "types": "index.d.ts", - "exports": { - ".": { - "types": "./index.d.ts", - "browser": { - "import": "./esm/index.browser.js", - "require": "./index.browser.js" - }, - "workerd": { - "import": "./esm/index.browser.js", - "require": "./index.browser.js" - }, - "node": { - "import": "./esm/index.js", - "require": "./index.js" - }, - "default": { - "import": "./esm/index.browser.js", - "require": "./index.browser.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", "publishConfig": {