Skip to content

Commit 263fa93

Browse files
committed
Fix fetch build error for web
1 parent 5b0110a commit 263fa93

4 files changed

Lines changed: 139 additions & 1 deletion

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { createFetch, isLocalhostSubdomain } from '../src/localhost-fetch.browser';
5+
6+
describe('browser build — static analysis', () => {
7+
const distDir = path.resolve(__dirname, '../dist');
8+
9+
const browserFiles = [
10+
'index.browser.js',
11+
'localhost-fetch.browser.js',
12+
path.join('esm', 'index.browser.js'),
13+
path.join('esm', 'localhost-fetch.browser.js'),
14+
];
15+
16+
it.each(browserFiles)('%s contains no node: scheme imports', (file) => {
17+
const filePath = path.join(distDir, file);
18+
if (!fs.existsSync(filePath)) {
19+
// In CI, missing artifacts must fail — otherwise this regression
20+
// check silently passes when tests run before the build step.
21+
if (process.env.CI) {
22+
throw new Error(
23+
`Built artifact missing: ${filePath} — run 'makage build' before tests in CI`,
24+
);
25+
}
26+
console.warn(`SKIP: ${filePath} not found (run 'makage build' first)`);
27+
return;
28+
}
29+
const content = fs.readFileSync(filePath, 'utf8');
30+
// Catch any node: URI scheme import (node:http, node:https, node:crypto, ...)
31+
expect(content).not.toMatch(/['"]node:[a-z]+['"]/);
32+
expect(content).not.toContain("require('node:");
33+
expect(content).not.toContain('require("node:');
34+
});
35+
});
36+
37+
describe('browser build — createFetch behavior', () => {
38+
it('returns a function', () => {
39+
const fetch = createFetch();
40+
expect(typeof fetch).toBe('function');
41+
});
42+
43+
it('returns the same instance on repeated calls', () => {
44+
const a = createFetch();
45+
const b = createFetch();
46+
expect(a).toBe(b);
47+
});
48+
49+
it('delegates to globalThis.fetch', async () => {
50+
// The shim caches the bound fetch at module scope, so to observe the spy
51+
// we must load a fresh module instance *after* installing the spy.
52+
const spy = jest
53+
.spyOn(globalThis, 'fetch')
54+
.mockResolvedValueOnce(new Response('ok'));
55+
try {
56+
await jest.isolateModulesAsync(async () => {
57+
const { createFetch: freshCreateFetch } = await import(
58+
'../src/localhost-fetch.browser'
59+
);
60+
const fetch = freshCreateFetch();
61+
const res = await fetch('https://example.com');
62+
expect(spy).toHaveBeenCalledWith('https://example.com');
63+
expect(await res.text()).toBe('ok');
64+
});
65+
} finally {
66+
spy.mockRestore();
67+
}
68+
});
69+
});
70+
71+
describe('browser build — isLocalhostSubdomain', () => {
72+
it('returns true for *.localhost', () => {
73+
expect(isLocalhostSubdomain('auth.localhost')).toBe(true);
74+
expect(isLocalhostSubdomain('api.localhost')).toBe(true);
75+
});
76+
77+
it('returns false for bare localhost', () => {
78+
expect(isLocalhostSubdomain('localhost')).toBe(false);
79+
});
80+
81+
it('returns false for non-localhost', () => {
82+
expect(isLocalhostSubdomain('example.com')).toBe(false);
83+
});
84+
});

packages/fetch/package.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
11
{
22
"name": "@constructive-io/fetch",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"author": "Constructive <developers@constructive.io>",
55
"description": "Isomorphic fetch wrapper — resolves *.localhost subdomains and preserves Host headers across Node.js and browsers",
66
"main": "index.js",
77
"module": "esm/index.js",
8+
"browser": "index.browser.js",
89
"types": "index.d.ts",
10+
"exports": {
11+
".": {
12+
"types": "./index.d.ts",
13+
"browser": {
14+
"import": "./esm/index.browser.js",
15+
"require": "./index.browser.js"
16+
},
17+
"workerd": {
18+
"import": "./esm/index.browser.js",
19+
"require": "./index.browser.js"
20+
},
21+
"node": {
22+
"import": "./esm/index.js",
23+
"require": "./index.js"
24+
},
25+
"default": {
26+
"import": "./esm/index.browser.js",
27+
"require": "./index.browser.js"
28+
}
29+
}
30+
},
931
"homepage": "https://github.com/constructive-io/dev-utils",
1032
"license": "MIT",
1133
"publishConfig": {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { createFetch, isLocalhostSubdomain } from './localhost-fetch.browser';
2+
export type { FetchFunction } from './types';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { FetchFunction } from './types';
2+
3+
/**
4+
* Returns true for *.localhost subdomains (e.g. auth.localhost)
5+
* but not for bare "localhost".
6+
*/
7+
export function isLocalhostSubdomain(hostname: string): boolean {
8+
return hostname.endsWith('.localhost') && hostname !== 'localhost';
9+
}
10+
11+
/**
12+
* Cached fetch implementation — resolved once, reused for all calls.
13+
*/
14+
let _fetch: FetchFunction | undefined;
15+
16+
/**
17+
* Create a fetch function for browser environments.
18+
*
19+
* Browsers resolve *.localhost subdomains natively and do not have the
20+
* Host-header restriction that Node.js undici has, so no workaround
21+
* is needed — just return `globalThis.fetch`.
22+
*
23+
* The result is cached — calling `createFetch()` multiple times returns
24+
* the same function instance.
25+
*/
26+
export function createFetch(): FetchFunction {
27+
if (_fetch) return _fetch;
28+
_fetch = globalThis.fetch.bind(globalThis);
29+
return _fetch;
30+
}

0 commit comments

Comments
 (0)