Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/fetch/__tests__/localhost-fetch.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
8 changes: 7 additions & 1 deletion packages/fetch/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
{
"name": "@constructive-io/fetch",
"version": "1.0.0",
"version": "1.1.0",
"author": "Constructive <developers@constructive.io>",
"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",
Expand Down
2 changes: 2 additions & 0 deletions packages/fetch/src/index.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createFetch, isLocalhostSubdomain } from './localhost-fetch.browser';
export type { FetchFunction } from './types';
30 changes: 30 additions & 0 deletions packages/fetch/src/localhost-fetch.browser.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading