From 27e17a78ff5e9ff5683f8ac7c2acf19f35202c89 Mon Sep 17 00:00:00 2001 From: huihan3 Date: Fri, 3 Apr 2026 10:59:10 +0800 Subject: [PATCH] fix(web): preserve subpath prefix for api and runtime config --- web/src/api/client.test.ts | 35 +++++++++++++++++++++++++++++++++++ web/src/api/client.ts | 17 +++++++++++++---- web/src/bootstrap.ts | 2 +- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index c07760a2..4e1810cb 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -36,6 +36,7 @@ vi.mock('@/shared/lib/api-error', () => ({ import { WEB_API_PREFIX, buildApiUrl, + fetchText, getDirectAuthRuntimeConfig, getSessionBootstrapRuntimeConfig, } from './client' @@ -45,6 +46,8 @@ beforeEach(() => { }) afterEach(() => { + vi.unstubAllGlobals() + if (originalWindow) { Object.defineProperty(globalThis, 'window', { configurable: true, @@ -79,6 +82,38 @@ describe('buildApiUrl', () => { const url = buildApiUrl('/api/v1/auth/me') expect(url).toBe('https://api.example.com/api/v1/auth/me') }) + + it('preserves base URL path prefixes', () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' } + const url = buildApiUrl('/api/v1/auth/me') + expect(url).toBe('https://api.example.com/skill_hub/api/v1/auth/me') + }) + + it('supports relative base URL path prefixes', () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: '/skill_hub' } + const url = buildApiUrl('/api/v1/auth/me') + expect(url).toBe('/skill_hub/api/v1/auth/me') + }) +}) + +describe('fetchText', () => { + it('applies base URL path prefixes for fetch requests', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'ok', + }) + vi.stubGlobal('fetch', fetchMock) + + await fetchText('/api/v1/auth/me') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/skill_hub/api/v1/auth/me', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ) + }) }) describe('getDirectAuthRuntimeConfig', () => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1f7233e4..928036a0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -252,7 +252,7 @@ function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL { if (!baseUrl || typeof input !== 'string' || !input.startsWith('/')) { return input } - return new URL(input, ensureTrailingSlash(baseUrl)) + return prependApiBaseUrl(baseUrl, input) } export function buildApiUrl(path: string): string { @@ -260,11 +260,20 @@ export function buildApiUrl(path: string): string { if (!baseUrl) { return path } - return new URL(path, ensureTrailingSlash(baseUrl)).toString() + return prependApiBaseUrl(baseUrl, path) } -function ensureTrailingSlash(value: string): string { - return value.endsWith('/') ? value : `${value}/` +function prependApiBaseUrl(baseUrl: string, path: string): string { + const normalizedBaseUrl = trimTrailingSlash(baseUrl) + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${normalizedBaseUrl}${normalizedPath}` +} + +function trimTrailingSlash(value: string): string { + if (value.length > 1 && value.endsWith('/')) { + return value.slice(0, -1) + } + return value } export async function getCurrentUser(): Promise { diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts index 8c8ddc6c..a8ac6927 100644 --- a/web/src/bootstrap.ts +++ b/web/src/bootstrap.ts @@ -7,7 +7,7 @@ async function loadRuntimeConfig() { await new Promise((resolve, reject) => { const script = document.createElement('script') - script.src = '/runtime-config.js' + script.src = new URL('../runtime-config.js', import.meta.url).toString() script.async = false script.onload = () => resolve() script.onerror = () => reject(new Error('Failed to load runtime config'))