This commit is contained in:
2025-10-12 21:03:07 +02:00
parent 8c41dd626d
commit 665ce5a6e7
17 changed files with 603 additions and 50 deletions
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { getApiBaseUrl, buildResourceUrl } from '../url';
const originalLocation = window.location;
const setLocation = (origin: string) => {
const parsed = new URL(origin);
Object.defineProperty(window, 'location', {
value: {
origin: parsed.origin,
hostname: parsed.hostname,
href: parsed.href,
},
configurable: true,
});
};
describe('url utilities', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
setLocation('https://example.com');
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
});
});
it('returns relative API base by default', () => {
vi.unstubAllEnvs();
expect(getApiBaseUrl()).toBe('/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('https://example.com/api/gallery/test');
});
it('honours absolute API URLs for non-local hosts', () => {
vi.stubEnv('VITE_API_URL', 'https://api.picpeak.cloud/api');
expect(getApiBaseUrl()).toBe('https://api.picpeak.cloud/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('https://api.picpeak.cloud/api/gallery/test');
expect(buildResourceUrl('/uploads/logo.png')).toBe('https://api.picpeak.cloud/uploads/logo.png');
});
it('falls back to relative when build-time URL is localhost but browser host is remote', () => {
vi.stubEnv('VITE_API_URL', 'http://localhost:3001/api');
setLocation('https://photos.example.com');
expect(getApiBaseUrl()).toBe('/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('https://photos.example.com/api/gallery/test');
expect(buildResourceUrl('uploads/logo.png')).toBe('https://photos.example.com/uploads/logo.png');
});
it('keeps localhost API URL when browser is also localhost', () => {
vi.stubEnv('VITE_API_URL', 'http://127.0.0.1:3001/api');
setLocation('http://127.0.0.1:3000');
expect(getApiBaseUrl()).toBe('http://127.0.0.1:3001/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('http://127.0.0.1:3001/api/gallery/test');
});
});
+108 -20
View File
@@ -2,39 +2,126 @@
* Utility functions for URL handling in production environments
*/
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
const isBrowser = typeof window !== 'undefined' && typeof window.location !== 'undefined';
const normalizeBase = (value: string): string => value.replace(/\/+$/, '');
const getEnvApiUrl = (): string | undefined => {
const raw = import.meta.env?.VITE_API_URL;
if (!raw || raw === '') {
return undefined;
}
if (raw === '/') {
return '/api';
}
return raw;
};
const isLocalHostname = (hostname: string): boolean => LOCAL_HOSTNAMES.has(hostname.toLowerCase());
const shouldFallbackToRelative = (url: string): boolean => {
if (!ABSOLUTE_URL_REGEX.test(url)) {
return false;
}
if (!isBrowser) {
return false;
}
try {
const parsed = new URL(url);
const envHostIsLocal = isLocalHostname(parsed.hostname);
const browserHost = window.location.hostname?.toLowerCase?.() ?? '';
const browserHostIsLocal = isLocalHostname(browserHost);
// Only fallback when the build-time URL points to localhost/loopback
// but the runtime browser location is remote (non-local).
return envHostIsLocal && !browserHostIsLocal;
} catch {
return false;
}
};
const buildFromOrigin = (path: string): string => {
if (!isBrowser) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${window.location.origin}${normalizedPath}`;
};
/**
* Get the base API URL, preferring relative URLs for production
* Get the base API URL, preferring relative URLs for production and
* falling back to relative when the build was created with localhost
* endpoints but is being accessed from a remote browser.
* @returns The API base URL
*/
export const getApiBaseUrl = (): string => {
// If VITE_API_URL is explicitly set, use it
if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') {
return import.meta.env.VITE_API_URL;
const envUrl = getEnvApiUrl();
if (envUrl && envUrl !== '/api') {
if (ABSOLUTE_URL_REGEX.test(envUrl) && shouldFallbackToRelative(envUrl)) {
return '/api';
}
return envUrl;
}
// In production, use relative URL
return '/api';
};
const buildFromAbsoluteApi = (base: string, path: string): string => {
const trimmedBase = normalizeBase(base);
// When the path already targets /api we want to preserve the suffix
if (path.startsWith('/api')) {
const pathWithoutLeadingApi = path.replace(/^\/api/, '');
return `${trimmedBase}${pathWithoutLeadingApi}`;
}
// For non-API assets (uploads, thumbnails, etc.) drop any /api suffix
const origin = trimmedBase.replace(/\/api$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${origin}${normalizedPath}`;
};
/**
* Build a full URL for resources (images, files, etc.)
* In production, this will use the current origin
* In production, this will prefer the current origin unless an absolute
* API URL is explicitly configured and applicable.
* @param path - The resource path
* @returns The full URL
*/
export const buildResourceUrl = (path: string): string => {
// Remove leading slash if present
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
// If we have an explicit API URL that's not relative, use it
const apiUrl = import.meta.env.VITE_API_URL;
if (apiUrl && apiUrl !== '/api' && apiUrl.startsWith('http')) {
const baseUrl = apiUrl.replace(/\/api\/?$/, ''); // Remove /api suffix if present
return `${baseUrl}/${cleanPath}`;
if (!path) {
return '';
}
// In production (relative API), use current origin
return `${window.location.origin}/${cleanPath}`;
// Absolute paths (http/https) should generally be respected,
// except when they point to localhost but we're running remotely.
if (ABSOLUTE_URL_REGEX.test(path)) {
if (!shouldFallbackToRelative(path)) {
return path;
}
try {
const parsed = new URL(path);
return buildFromOrigin(`${parsed.pathname}${parsed.search}${parsed.hash}`);
} catch {
return path;
}
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const apiBase = getApiBaseUrl();
if (ABSOLUTE_URL_REGEX.test(apiBase)) {
return buildFromAbsoluteApi(apiBase, normalizedPath);
}
return buildFromOrigin(normalizedPath);
};
/**
@@ -42,5 +129,6 @@ export const buildResourceUrl = (path: string): string => {
* @returns True if in production mode
*/
export const isProductionMode = (): boolean => {
return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api';
};
const apiBase = getApiBaseUrl();
return !ABSOLUTE_URL_REGEX.test(apiBase);
};