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
This commit is contained in:
Soner Köksal
2025-09-24 00:23:19 +03:00
committed by GitHub
parent 0706e4671f
commit 4d4477640e
8 changed files with 108 additions and 7 deletions

View File

@@ -3,6 +3,10 @@ ALLOWED_HOSTS =
VITE_SERVER_URL_DEFAULT = http://localhost:4567 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 CODEGEN_SERVER_URL_GQL = http://localhost:4567/api/graphql
GITHUB_TOKEN = GITHUB_TOKEN =

View File

@@ -4,18 +4,18 @@
"start_url": ".", "start_url": ".",
"icons": [ "icons": [
{ {
"src": "/web-app-manifest-192x192.png", "src": "./web-app-manifest-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/web-app-manifest-192x192.png", "src": "./web-app-manifest-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/web-app-manifest-512x512.png", "src": "./web-app-manifest-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"

View File

@@ -19,6 +19,7 @@ import { AppPageHistoryContextProvider } from '@/base/contexts/AppPageHistoryCon
import { AppThemeContextProvider } from '@/features/theme/AppThemeContext.tsx'; import { AppThemeContextProvider } from '@/features/theme/AppThemeContext.tsx';
import { NavBarContextProvider } from '@/features/navigation-bar/NavbarContext.tsx'; import { NavBarContextProvider } from '@/features/navigation-bar/NavbarContext.tsx';
import { SessionContextProvider } from '@/features/authentication/SessionContext.tsx'; import { SessionContextProvider } from '@/features/authentication/SessionContext.tsx';
import { SubpathConfig } from '@/lib/utils/SubpathConfig.ts';
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@@ -26,7 +27,7 @@ interface Props {
export const AppContext: React.FC<Props> = ({ children }) => ( export const AppContext: React.FC<Props> = ({ children }) => (
<SessionContextProvider> <SessionContextProvider>
<Router> <Router basename={SubpathConfig.getRouterBasename()}>
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
<AppThemeContextProvider> <AppThemeContextProvider>
<QueryParamProvider adapter={ReactRouter6Adapter}> <QueryParamProvider adapter={ReactRouter6Adapter}>

View File

@@ -10,6 +10,7 @@ import { use } from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend'; 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}.<br/> * Keys have to match {@link IsoLanguages} codes, they're used for showing the language name in the dropdown in the {@link Settings}.<br/>
@@ -45,7 +46,7 @@ export const i18n = use(initReactI18next)
fallbackLng: 'en', fallbackLng: 'en',
backend: { backend: {
loadPath: '/locales/{{lng}}.json', loadPath: `${SubpathConfig.getSubpath()}/locales/{{lng}}.json`,
allowMultiLoading: true, allowMultiLoading: true,
}, },

View File

@@ -10,6 +10,7 @@ import { AppStorage } from '@/lib/storage/AppStorage.ts';
import { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts'; import { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts';
import { AuthManager } from '@/features/authentication/AuthManager.ts'; import { AuthManager } from '@/features/authentication/AuthManager.ts';
import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts'; import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
import { SubpathConfig } from '@/lib/utils/SubpathConfig.ts';
export abstract class BaseClient<Client, ClientConfig, Fetcher> { export abstract class BaseClient<Client, ClientConfig, Fetcher> {
protected abstract client: Client; protected abstract client: Client;
@@ -68,7 +69,10 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
? import.meta.env.VITE_SERVER_URL_DEFAULT ? import.meta.env.VITE_SERVER_URL_DEFAULT
: `${protocol}//${hostname}:${port}`; : `${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<ClientConfig>): void; public abstract updateConfig(config: Partial<ClientConfig>): void;

View File

@@ -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}`;
}
}

1
src/vite-env.d.ts vendored
View File

@@ -16,6 +16,7 @@ interface ViteTypeOptions {
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_SERVER_URL_DEFAULT: string; readonly VITE_SERVER_URL_DEFAULT: string;
readonly VITE_SUBPATH?: string;
// more env variables... // more env variables...
} }

View File

@@ -17,7 +17,8 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
import 'dotenv/config'; import 'dotenv/config';
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default defineConfig(() => ({ export default defineConfig(({ command }) => ({
base: command === 'serve' ? process.env.VITE_SUBPATH || './' : './',
build: { build: {
outDir: 'build', outDir: 'build',
}, },