From a1047321d156980e9f7ddcaf9e82025d7d9480d6 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Tue, 27 May 2025 18:34:23 +0800 Subject: [PATCH] feat: introduce runtime path (#132) --- frontend/src/App.tsx | 11 +-- frontend/src/components/AddServerForm.tsx | 2 +- frontend/src/components/EditServerForm.tsx | 3 +- frontend/src/hooks/useGroupData.ts | 2 +- frontend/src/hooks/useMarketData.ts | 2 +- frontend/src/hooks/useServerData.ts | 8 +- frontend/src/hooks/useSettingsData.ts | 2 +- frontend/src/main.tsx | 53 +++++++++-- frontend/src/services/authService.ts | 2 +- frontend/src/services/logService.ts | 2 +- frontend/src/types/runtime.ts | 15 +++ frontend/src/utils/api.ts | 19 ++-- frontend/src/utils/runtime.ts | 105 +++++++++++++++++++++ frontend/src/vite-env.d.ts | 1 - frontend/vite.config.ts | 11 ++- src/controllers/configController.ts | 30 ++++++ src/routes/index.ts | 4 + 17 files changed, 226 insertions(+), 46 deletions(-) create mode 100644 frontend/src/types/runtime.ts create mode 100644 frontend/src/utils/runtime.ts create mode 100644 src/controllers/configController.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 84aff6a..07cc2b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,16 +12,7 @@ import GroupsPage from './pages/GroupsPage'; import SettingsPage from './pages/SettingsPage'; import MarketPage from './pages/MarketPage'; import LogsPage from './pages/LogsPage'; - -// Get base path from environment variable or default to empty string -const getBasePath = (): string => { - const basePath = import.meta.env.BASE_PATH || ''; - // Ensure the path starts with / if it's not empty and doesn't already start with / - if (basePath && !basePath.startsWith('/')) { - return '/' + basePath; - } - return basePath; -}; +import { getBasePath } from './utils/runtime'; function App() { const basename = getBasePath(); diff --git a/frontend/src/components/AddServerForm.tsx b/frontend/src/components/AddServerForm.tsx index 4d2030d..a8e4fee 100644 --- a/frontend/src/components/AddServerForm.tsx +++ b/frontend/src/components/AddServerForm.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import ServerForm from './ServerForm' -import { getApiUrl } from '../utils/api' +import { getApiUrl } from '../utils/runtime'; interface AddServerFormProps { onAdd: () => void diff --git a/frontend/src/components/EditServerForm.tsx b/frontend/src/components/EditServerForm.tsx index 3078be7..fce9cb4 100644 --- a/frontend/src/components/EditServerForm.tsx +++ b/frontend/src/components/EditServerForm.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Server } from '@/types' +import { getApiUrl } from '../utils/runtime' import ServerForm from './ServerForm' interface EditServerFormProps { @@ -17,7 +18,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => { try { setError(null) const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/servers/${server.name}`, { + const response = await fetch(getApiUrl(`/servers/${server.name}`), { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/hooks/useGroupData.ts b/frontend/src/hooks/useGroupData.ts index 3d48c69..852aa01 100644 --- a/frontend/src/hooks/useGroupData.ts +++ b/frontend/src/hooks/useGroupData.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Group, ApiResponse } from '@/types'; -import { getApiUrl } from '../utils/api'; +import { getApiUrl } from '../utils/runtime'; export const useGroupData = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/useMarketData.ts b/frontend/src/hooks/useMarketData.ts index 6b80506..5f61d85 100644 --- a/frontend/src/hooks/useMarketData.ts +++ b/frontend/src/hooks/useMarketData.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { MarketServer, ApiResponse } from '@/types'; -import { getApiUrl } from '../utils/api'; +import { getApiUrl } from '../utils/runtime'; export const useMarketData = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/useServerData.ts b/frontend/src/hooks/useServerData.ts index 69a24c3..49db4e6 100644 --- a/frontend/src/hooks/useServerData.ts +++ b/frontend/src/hooks/useServerData.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Server, ApiResponse } from '@/types'; -import { getApiUrl } from '../utils/api'; +import { getApiUrl } from '../utils/runtime'; // Configuration options const CONFIG = { @@ -204,7 +204,7 @@ export const useServerData = () => { try { // Fetch settings to get the full server config before editing const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/settings`, { + const response = await fetch(getApiUrl('/settings'), { headers: { 'x-auth-token': token || '', }, @@ -241,7 +241,7 @@ export const useServerData = () => { const handleServerRemove = async (serverName: string) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/servers/${serverName}`, { + const response = await fetch(getApiUrl(`/servers/${serverName}`), { method: 'DELETE', headers: { 'x-auth-token': token || '', @@ -265,7 +265,7 @@ export const useServerData = () => { const handleServerToggle = async (server: Server, enabled: boolean) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/servers/${server.name}/toggle`, { + const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/hooks/useSettingsData.ts b/frontend/src/hooks/useSettingsData.ts index 0c20b3c..47565be 100644 --- a/frontend/src/hooks/useSettingsData.ts +++ b/frontend/src/hooks/useSettingsData.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ApiResponse } from '@/types'; import { useToast } from '@/contexts/ToastContext'; -import { getApiUrl } from '../utils/api'; +import { getApiUrl } from '../utils/runtime'; // Define types for the settings data interface RoutingConfig { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 378fa92..f1cd579 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,12 +1,45 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; // Import the i18n configuration -import './i18n' +import './i18n'; +import { loadRuntimeConfig } from './utils/runtime'; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) \ No newline at end of file +// Load runtime configuration before starting the app +async function initializeApp() { + try { + console.log('Loading runtime configuration...'); + const config = await loadRuntimeConfig(); + console.log('Runtime configuration loaded:', config); + + // Store config in window object + window.__MCPHUB_CONFIG__ = config; + + // Start React app + ReactDOM.createRoot(document.getElementById('root')!).render( + + + , + ); + } catch (error) { + console.error('Failed to initialize app:', error); + + // Fallback: start app with default config + console.log('Starting app with default configuration...'); + window.__MCPHUB_CONFIG__ = { + basePath: '', + version: 'dev', + name: 'mcphub', + }; + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + , + ); + } +} + +// Initialize the app +initializeApp(); \ No newline at end of file diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index bf762df..0efa340 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -4,7 +4,7 @@ import { RegisterCredentials, ChangePasswordCredentials, } from '../types'; -import { getApiUrl } from '../utils/api'; +import { getApiUrl } from '../utils/runtime'; // Token key in localStorage const TOKEN_KEY = 'mcphub_token'; diff --git a/frontend/src/services/logService.ts b/frontend/src/services/logService.ts index b4a07b6..e491227 100644 --- a/frontend/src/services/logService.ts +++ b/frontend/src/services/logService.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { getToken } from './authService'; // Import getToken function -import { getApiUrl } from '../utils/api'; +import { getApiUrl } from '../utils/runtime'; export interface LogEntry { timestamp: number; diff --git a/frontend/src/types/runtime.ts b/frontend/src/types/runtime.ts new file mode 100644 index 0000000..2d86857 --- /dev/null +++ b/frontend/src/types/runtime.ts @@ -0,0 +1,15 @@ +// Global runtime configuration interface +export interface RuntimeConfig { + basePath: string; + version: string; + name: string; +} + +// Extend Window interface to include runtime config +declare global { + interface Window { + __MCPHUB_CONFIG__?: RuntimeConfig; + } +} + +export {}; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 5fddcd3..e111cb1 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,27 +1,28 @@ /** * API utility functions for constructing URLs with proper base path support + * + * @deprecated Use functions from utils/runtime.ts instead for runtime configuration support */ +import { getApiBaseUrl as getRuntimeApiBaseUrl, getApiUrl as getRuntimeApiUrl } from './runtime'; + /** * Get the API base URL including base path and /api prefix * @returns The complete API base URL + * @deprecated Use getApiBaseUrl from utils/runtime.ts instead */ export const getApiBaseUrl = (): string => { - const basePath = import.meta.env.BASE_PATH || ''; - // Ensure the path starts with / if it's not empty and doesn't already start with / - const normalizedBasePath = basePath && !basePath.startsWith('/') ? '/' + basePath : basePath; - // Always append /api to the base path for API endpoints - return normalizedBasePath + '/api'; + console.warn('getApiBaseUrl from utils/api.ts is deprecated, use utils/runtime.ts instead'); + return getRuntimeApiBaseUrl(); }; /** * Construct a full API URL with the given endpoint * @param endpoint - The API endpoint (should start with /, e.g., '/auth/login') * @returns The complete API URL + * @deprecated Use getApiUrl from utils/runtime.ts instead */ export const getApiUrl = (endpoint: string): string => { - const baseUrl = getApiBaseUrl(); - // Ensure endpoint starts with / - const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : '/' + endpoint; - return baseUrl + normalizedEndpoint; + console.warn('getApiUrl from utils/api.ts is deprecated, use utils/runtime.ts instead'); + return getRuntimeApiUrl(endpoint); }; diff --git a/frontend/src/utils/runtime.ts b/frontend/src/utils/runtime.ts new file mode 100644 index 0000000..c3ea4e7 --- /dev/null +++ b/frontend/src/utils/runtime.ts @@ -0,0 +1,105 @@ +import type { RuntimeConfig } from '../types/runtime'; + +/** + * Get runtime configuration from window object + */ +export const getRuntimeConfig = (): RuntimeConfig => { + return ( + window.__MCPHUB_CONFIG__ || { + basePath: '', + version: 'dev', + name: 'mcphub', + } + ); +}; + +/** + * Get the base path from runtime configuration + */ +export const getBasePath = (): string => { + const config = getRuntimeConfig(); + const basePath = config.basePath || ''; + + // Ensure the path starts with / if it's not empty and doesn't already start with / + if (basePath && !basePath.startsWith('/')) { + return '/' + basePath; + } + return basePath; +}; + +/** + * Get the API base URL including base path and /api prefix + */ +export const getApiBaseUrl = (): string => { + const basePath = getBasePath(); + // Always append /api to the base path for API endpoints + return basePath + '/api'; +}; + +/** + * Construct a full API URL with the given endpoint + */ +export const getApiUrl = (endpoint: string): string => { + const baseUrl = getApiBaseUrl(); + // Ensure endpoint starts with / + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : '/' + endpoint; + return baseUrl + normalizedEndpoint; +}; + +/** + * Load runtime configuration from server + */ +export const loadRuntimeConfig = async (): Promise => { + try { + // For initial config load, we need to determine the correct path + // Try different possible paths based on current location + const currentPath = window.location.pathname; + const possibleConfigPaths = [ + // If we're already on a subpath, try to use it + currentPath.replace(/\/[^\/]*$/, '') + '/config', + // Try root config + '/config', + // Try with potential base paths + ...(currentPath.includes('/') + ? [currentPath.split('/')[1] ? `/${currentPath.split('/')[1]}/config` : '/config'] + : ['/config']), + ]; + + for (const configPath of possibleConfigPaths) { + try { + const response = await fetch(configPath, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Cache-Control': 'no-cache', + }, + }); + + if (response.ok) { + const data = await response.json(); + if (data.success && data.data) { + return data.data; + } + } + } catch (error) { + // Continue to next path + console.debug(`Failed to load config from ${configPath}:`, error); + } + } + + // Fallback to default config + console.warn('Could not load runtime config from server, using defaults'); + return { + basePath: '', + version: 'dev', + name: 'mcphub', + }; + } catch (error) { + console.error('Error loading runtime config:', error); + return { + basePath: '', + version: 'dev', + name: 'mcphub', + }; + } +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index e58d261..07e677c 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -3,7 +3,6 @@ interface ImportMeta { readonly env: { readonly PACKAGE_VERSION: string; - readonly BASE_PATH?: string; // Add base path environment variable // Add other custom env variables here if needed [key: string]: any; }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 013c921..55720f9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,12 +8,13 @@ import { readFileSync } from 'fs'; // Get package.json version const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')); -// Get base path from environment variable -const basePath = process.env.BASE_PATH || ''; +// For runtime configuration, we'll always use relative paths +// BASE_PATH will be determined at runtime +const basePath = ''; // https://vitejs.dev/config/ export default defineConfig({ - base: basePath || './', // Use base path or relative paths for assets + base: './', // Always use relative paths for runtime configuration plugins: [react(), tailwindcss()], resolve: { alias: { @@ -21,9 +22,9 @@ export default defineConfig({ }, }, define: { - // Make package version and base path available as global variables + // Make package version available as global variable + // BASE_PATH will be loaded at runtime 'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version), - 'import.meta.env.BASE_PATH': JSON.stringify(basePath), }, build: { sourcemap: true, // Enable source maps for production build diff --git a/src/controllers/configController.ts b/src/controllers/configController.ts new file mode 100644 index 0000000..b7a1331 --- /dev/null +++ b/src/controllers/configController.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express'; +import config from '../config/index.js'; + +/** + * Get runtime configuration for frontend + */ +export const getRuntimeConfig = (req: Request, res: Response): void => { + try { + const runtimeConfig = { + basePath: config.basePath, + version: config.mcpHubVersion, + name: config.mcpHubName, + }; + + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + + res.json({ + success: true, + data: runtimeConfig, + }); + } catch (error) { + console.error('Error getting runtime config:', error); + res.status(500).json({ + success: false, + message: 'Failed to get runtime configuration', + }); + } +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 9e29487..60f6ca8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -32,6 +32,7 @@ import { } from '../controllers/marketController.js'; import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js'; import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; +import { getRuntimeConfig } from '../controllers/configController.js'; import { auth } from '../middlewares/auth.js'; const router = express.Router(); @@ -104,6 +105,9 @@ export const initRoutes = (app: express.Application): void => { changePassword, ); + // Runtime configuration endpoint (no auth required for frontend initialization) + app.get(`${config.basePath}/config`, getRuntimeConfig); + app.use(`${config.basePath}/api`, router); };