From 4d4477640e2461d37694b2f89bc1b9ab4975c410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= <15015690+renjfk@users.noreply.github.com> Date: Wed, 24 Sep 2025 00:23:19 +0300 Subject: [PATCH] Add dynamic subpath support (#1011) * Add dynamic subpath support for WebUI - Add SubpathConfig utility for server-side subpath detection - Configure React Router basename from server config - Update API base URL handling for subpath compatibility - Fix asset loading with relative paths - Add VITE_SUBPATH environment variable for development - Update locales and manifest paths for subpath support Closes #174 * Move VITE_SUBPATH interface to vite-env.d.ts * Ensure production bundle always uses relative base --- .env.template | 4 ++ public/site.webmanifest | 6 +- src/base/contexts/AppContext.tsx | 3 +- src/i18n/index.ts | 3 +- src/lib/requests/client/BaseClient.ts | 6 +- src/lib/utils/SubpathConfig.ts | 89 +++++++++++++++++++++++++++ src/vite-env.d.ts | 1 + vite.config.ts | 3 +- 8 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/lib/utils/SubpathConfig.ts diff --git a/.env.template b/.env.template index e06c84f8..666cfc00 100644 --- a/.env.template +++ b/.env.template @@ -3,6 +3,10 @@ ALLOWED_HOSTS = VITE_SERVER_URL_DEFAULT = http://localhost:4567 +# Optional: Set a subpath for the WebUI during development (e.g. /suwayomi) +# For production use, configure webUISubpath in server settings instead +# VITE_SUBPATH = /suwayomi + CODEGEN_SERVER_URL_GQL = http://localhost:4567/api/graphql GITHUB_TOKEN = diff --git a/public/site.webmanifest b/public/site.webmanifest index 10876f53..d6da608d 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -4,18 +4,18 @@ "start_url": ".", "icons": [ { - "src": "/web-app-manifest-192x192.png", + "src": "./web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/web-app-manifest-192x192.png", + "src": "./web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/web-app-manifest-512x512.png", + "src": "./web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/src/base/contexts/AppContext.tsx b/src/base/contexts/AppContext.tsx index c1b515d2..9fe978fd 100644 --- a/src/base/contexts/AppContext.tsx +++ b/src/base/contexts/AppContext.tsx @@ -19,6 +19,7 @@ import { AppPageHistoryContextProvider } from '@/base/contexts/AppPageHistoryCon import { AppThemeContextProvider } from '@/features/theme/AppThemeContext.tsx'; import { NavBarContextProvider } from '@/features/navigation-bar/NavbarContext.tsx'; import { SessionContextProvider } from '@/features/authentication/SessionContext.tsx'; +import { SubpathConfig } from '@/lib/utils/SubpathConfig.ts'; interface Props { children: React.ReactNode; @@ -26,7 +27,7 @@ interface Props { export const AppContext: React.FC = ({ children }) => ( - + diff --git a/src/i18n/index.ts b/src/i18n/index.ts index fab14b35..0120eefb 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -10,6 +10,7 @@ import { use } from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import HttpBackend from 'i18next-http-backend'; +import { SubpathConfig } from '@/lib/utils/SubpathConfig.ts'; /** * Keys have to match {@link IsoLanguages} codes, they're used for showing the language name in the dropdown in the {@link Settings}.
@@ -45,7 +46,7 @@ export const i18n = use(initReactI18next) fallbackLng: 'en', backend: { - loadPath: '/locales/{{lng}}.json', + loadPath: `${SubpathConfig.getSubpath()}/locales/{{lng}}.json`, allowMultiLoading: true, }, diff --git a/src/lib/requests/client/BaseClient.ts b/src/lib/requests/client/BaseClient.ts index 57d99364..99d64836 100644 --- a/src/lib/requests/client/BaseClient.ts +++ b/src/lib/requests/client/BaseClient.ts @@ -10,6 +10,7 @@ import { AppStorage } from '@/lib/storage/AppStorage.ts'; import { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts'; import { AuthManager } from '@/features/authentication/AuthManager.ts'; import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts'; +import { SubpathConfig } from '@/lib/utils/SubpathConfig.ts'; export abstract class BaseClient { protected abstract client: Client; @@ -68,7 +69,10 @@ export abstract class BaseClient { ? import.meta.env.VITE_SERVER_URL_DEFAULT : `${protocol}//${hostname}:${port}`; - return AppStorage.local.getItemParsed('serverBaseURL', defaultUrl); + const serverBaseURL = AppStorage.local.getItemParsed('serverBaseURL', defaultUrl); + + // Apply subpath configuration to the base URL + return SubpathConfig.getApiBaseUrl(serverBaseURL); } public abstract updateConfig(config: Partial): void; diff --git a/src/lib/utils/SubpathConfig.ts b/src/lib/utils/SubpathConfig.ts new file mode 100644 index 00000000..81e26afc --- /dev/null +++ b/src/lib/utils/SubpathConfig.ts @@ -0,0 +1,89 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +interface SuwayomiConfig { + webUISubpath?: string; +} + +declare global { + interface Window { + __SUWAYOMI_CONFIG__?: SuwayomiConfig; + } +} + +/** + * Utility for handling dynamic subpath configuration for the WebUI + * Server-side configuration only - WebUI automatically detects server subpath + */ +export class SubpathConfig { + private static cachedSubpath: string | null = null; + + /** + * Get the current subpath from server config, environment variables, or fallback + */ + public static getSubpath(): string { + // Return cached result if available + if (this.cachedSubpath !== null) { + return this.cachedSubpath; + } + + // Priority 1: Development environment variable (VITE_SUBPATH) + const envSubpath = import.meta.env.VITE_SUBPATH; + if (envSubpath && envSubpath !== '/') { + this.cachedSubpath = envSubpath; + return this.cachedSubpath; + } + + // Priority 2: Server-side configuration (injected by server) + const serverSubpath = this.getServerSubpath(); + if (serverSubpath && serverSubpath !== '/') { + this.cachedSubpath = serverSubpath; + return this.cachedSubpath; + } + + // Priority 3: No subpath found + this.cachedSubpath = ''; + return this.cachedSubpath; + } + + /** + * Get server-configured subpath from `window.__SUWAYOMI_CONFIG__` + */ + private static getServerSubpath(): string | null { + if (typeof window === 'undefined') { + return null; + } + + // eslint-disable-next-line no-underscore-dangle + return window.__SUWAYOMI_CONFIG__?.webUISubpath || null; + } + + /** + * Get subpath for React Router basename prop + * @returns Subpath string or undefined (React Router expects undefined when no basename) + */ + public static getRouterBasename(): string | undefined { + const subpath = this.getSubpath(); + return subpath || undefined; + } + + /** + * Get the API base URL with subpath consideration + * This ensures API calls work correctly when the app is served under a subpath + */ + public static getApiBaseUrl(serverUrl: string): string { + const baseUrl = serverUrl; + const subpath = this.getSubpath(); + + if (!subpath) { + return baseUrl; + } + + return `${baseUrl}${subpath}`; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b126cb5d..68b19aba 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -16,6 +16,7 @@ interface ViteTypeOptions { interface ImportMetaEnv { readonly VITE_SERVER_URL_DEFAULT: string; + readonly VITE_SUBPATH?: string; // more env variables... } diff --git a/vite.config.ts b/vite.config.ts index 473522fd..903aa77f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,7 +17,8 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; import 'dotenv/config'; // eslint-disable-next-line import/no-default-export -export default defineConfig(() => ({ +export default defineConfig(({ command }) => ({ + base: command === 'serve' ? process.env.VITE_SUBPATH || './' : './', build: { outDir: 'build', },