diff --git a/.env.example b/.env.example index 4b3cbbc2..d5d0640e 100755 --- a/.env.example +++ b/.env.example @@ -39,3 +39,13 @@ VITE_CONTEXT_WINDOW=160000 CONTEXT_WINDOW=160000 # VITE_IS_PLATFORM=false + +# ============================================================================= +# DEPLOYMENT CONFIGURATION +# ============================================================================= + +# Base path for sub-directory deployment +# Use '/' for root deployment (default) +# Use '/your-path' for sub-directory deployment (e.g., '/claudeui') +# This affects all routes, WebSocket connections, and static assets +# APP_BASE_PATH=/ diff --git a/server/index.js b/server/index.js index a094d9dc..5bb2af40 100755 --- a/server/index.js +++ b/server/index.js @@ -163,8 +163,18 @@ async function setupProjectsWatcher() { } -const app = express(); -const server = http.createServer(app); +// Define base path for sub-directory deployment +const APP_BASE_PATH = process.env.APP_BASE_PATH || '/'; +console.log(`[INFO] Application Base Path: ${APP_BASE_PATH}`); + +// Create main Express app and server +const expressApp = express(); +const server = http.createServer(expressApp); + +// Create router for all routes (to be mounted at APP_BASE_PATH) +const router = express.Router(); +// Alias router as app for minimal code changes +const app = router; const ptySessionsMap = new Map(); const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; @@ -208,11 +218,11 @@ const wss = new WebSocketServer({ }); // Make WebSocket server available to routes -app.locals.wss = wss; +expressApp.locals.wss = wss; -app.use(cors()); -app.use(express.json({ limit: '50mb' })); -app.use(express.urlencoded({ limit: '50mb', extended: true })); +expressApp.use(cors()); +expressApp.use(express.json({ limit: '50mb' })); +expressApp.use(express.urlencoded({ limit: '50mb', extended: true })); // Public health check endpoint (no authentication required) app.get('/health', (req, res) => { @@ -261,12 +271,61 @@ app.use('/api/user', authenticateToken, userRoutes); // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); -// Serve public files (like api-docs.html) +// Dynamic manifest.json with base path injection +app.get('/manifest.json', async (req, res) => { + try { + const manifestPath = path.join(__dirname, '../public/manifest.json'); + let manifest = await fsPromises.readFile(manifestPath, 'utf8'); + + // Inject base path for sub-directory deployment + if (APP_BASE_PATH && APP_BASE_PATH !== '/') { + const basePath = APP_BASE_PATH.endsWith('/') ? APP_BASE_PATH.slice(0, -1) : APP_BASE_PATH; + manifest = manifest.replace(/"\//g, `"${basePath}/`); + } + + res.set('Content-Type', 'application/json'); + res.send(manifest); + } catch (error) { + console.error('Error serving manifest.json:', error); + res.status(404).send('Not found'); + } +}); + +// Dynamic HTML files from public directory with base path injection +app.get('/*.html', async (req, res, next) => { + try { + const htmlPath = path.join(__dirname, '../public', req.path); + if (!fs.existsSync(htmlPath)) { + return next(); // File doesn't exist, pass to next middleware + } + + let html = await fsPromises.readFile(htmlPath, 'utf8'); + + // Inject base path for sub-directory deployment + if (APP_BASE_PATH && APP_BASE_PATH !== '/') { + const basePath = APP_BASE_PATH.endsWith('/') ? APP_BASE_PATH.slice(0, -1) : APP_BASE_PATH; + html = html + .replace(/href="\//g, `href="${basePath}/`) + .replace(/src="\//g, `src="${basePath}/`) + .replace(/'\/sw\.js'/g, `'${basePath}/sw.js'`); + } + + res.set('Content-Type', 'text/html'); + res.send(html); + } catch (error) { + console.error('Error serving HTML file:', error); + next(); // Pass to next middleware on error + } +}); + +// Serve public files (like images, etc.) app.use(express.static(path.join(__dirname, '../public'))); // Static files served after API routes // Add cache control: HTML files should not be cached, but assets can be cached +// Note: index: false to allow manual serving of index.html for base path injection app.use(express.static(path.join(__dirname, '../dist'), { + index: false, setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { // Prevent HTML caching to avoid service worker issues after builds @@ -692,9 +751,14 @@ wss.on('connection', (ws, request) => { const urlObj = new URL(url, 'http://localhost'); const pathname = urlObj.pathname; - if (pathname === '/shell') { + // Construct expected paths based on APP_BASE_PATH + const basePath = APP_BASE_PATH.endsWith('/') ? APP_BASE_PATH.slice(0, -1) : APP_BASE_PATH; + const shellPath = basePath === '' ? '/shell' : basePath + '/shell'; + const chatPath = basePath === '' ? '/ws' : basePath + '/ws'; + + if (pathname === shellPath) { handleShellConnection(ws); - } else if (pathname === '/ws') { + } else if (pathname === chatPath) { handleChatConnection(ws); } else { console.log('[WARN] Unknown WebSocket path:', pathname); @@ -1421,29 +1485,52 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica }); // Serve React app for all other routes (excluding static files) -app.get('*', (req, res) => { - // Skip requests for static assets (files with extensions) - if (path.extname(req.path)) { - return res.status(404).send('Not found'); - } +app.get('*', async (req, res) => { + // Skip requests for static assets + if (path.extname(req.path)) return res.status(404).send('Not found'); - // Only serve index.html for HTML routes, not for static assets - // Static assets should already be handled by express.static middleware above const indexPath = path.join(__dirname, '../dist/index.html'); - // Check if dist/index.html exists (production build available) - if (fs.existsSync(indexPath)) { - // Set no-cache headers for HTML to prevent service worker issues - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); - res.sendFile(indexPath); - } else { - // In development, redirect to Vite dev server only if dist doesn't exist - res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`); + // In development, redirect to Vite dev server + if (!fs.existsSync(indexPath)) { + return res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`); + } + + try { + // Read index.html + let html = await fsPromises.readFile(indexPath, 'utf8'); + + // Inject base path configuration for runtime + const basePath = (APP_BASE_PATH && APP_BASE_PATH !== '/') + ? (APP_BASE_PATH.endsWith('/') ? APP_BASE_PATH.slice(0, -1) : APP_BASE_PATH) + : ''; + + // Inject base path as a global variable for client-side use + const configScript = ``; + html = html.replace('', `${configScript}`); + + // Replace absolute paths in HTML with base-prefixed paths + if (basePath) { + html = html + .replace(/href="\//g, `href="${basePath}/`) + .replace(/src="\//g, `src="${basePath}/`) + .replace(/'\/sw\.js'/g, `'${basePath}/sw.js'`); + } + + // Serve the file + res.set({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }).send(html); + } catch (error) { + console.error('Error serving index.html:', error); + res.status(500).send('Internal Server Error'); } }); +// Mount the router to the specified base path +expressApp.use(APP_BASE_PATH, router); + // Helper function to convert permissions to rwx format function permToRwx(perm) { const r = perm & 4 ? 'r' : '-'; diff --git a/src/App.jsx b/src/App.jsx index 6fa2aa7b..f7836fcb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -36,6 +36,7 @@ import ProtectedRoute from './components/ProtectedRoute'; import { useVersionCheck } from './hooks/useVersionCheck'; import useLocalStorage from './hooks/useLocalStorage'; import { api, authenticatedFetch } from './utils/api'; +import { ROUTER_BASENAME } from './utils/api'; // Main App component with routing @@ -951,7 +952,7 @@ function App() { - + } /> } /> diff --git a/src/components/ClaudeLogo.jsx b/src/components/ClaudeLogo.jsx index b751fc12..39f6651a 100644 --- a/src/components/ClaudeLogo.jsx +++ b/src/components/ClaudeLogo.jsx @@ -1,11 +1,7 @@ -import React from 'react'; +import { BASE_URL } from '../utils/api'; -const ClaudeLogo = ({className = 'w-5 h-5'}) => { - return ( - Claude - ); -}; +const ClaudeLogo = ({ className = 'w-5 h-5' }) => ( + Claude +); export default ClaudeLogo; - - diff --git a/src/components/CursorLogo.jsx b/src/components/CursorLogo.jsx index 18bda9d3..16c857cb 100644 --- a/src/components/CursorLogo.jsx +++ b/src/components/CursorLogo.jsx @@ -1,9 +1,7 @@ -import React from 'react'; +import { BASE_URL } from '../utils/api'; -const CursorLogo = ({ className = 'w-5 h-5' }) => { - return ( - Cursor - ); -}; +const CursorLogo = ({ className = 'w-5 h-5' }) => ( + Cursor +); export default CursorLogo; diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx index c095836f..02a498dd 100644 --- a/src/components/Shell.jsx +++ b/src/components/Shell.jsx @@ -5,6 +5,8 @@ import { WebglAddon } from '@xterm/addon-webgl'; import { WebLinksAddon } from '@xterm/addon-web-links'; import '@xterm/xterm/css/xterm.css'; +import { BASE_URL } from '../utils/api'; + const xtermStyles = ` .xterm .xterm-screen { outline: none !important; @@ -58,7 +60,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell if (isPlatform) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/shell`; + wsUrl = `${protocol}//${window.location.host}${BASE_URL}/shell`; } else { const token = localStorage.getItem('auth-token'); if (!token) { @@ -67,7 +69,8 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`; + const queryParams = (token && !skipAuth) ? `?token=${encodeURIComponent(token)}` : ''; + wsUrl = `${protocol}//${window.location.host}${BASE_URL}/shell${queryParams}`; } ws.current = new WebSocket(wsUrl); @@ -296,7 +299,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell data: text })); } - }).catch(() => {}); + }).catch(() => { }); return false; } diff --git a/src/utils/api.js b/src/utils/api.js index f0b8e6f0..3ac17385 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -1,4 +1,11 @@ // Utility function for authenticated API calls + +// Read base path from server-injected global variable (set in index.html by server) +// Falls back to empty string for root path deployment +export const BASE_URL = window.__APP_BASE_PATH__ || ''; +// Router basename (with leading slash, no trailing slash for root) +export const ROUTER_BASENAME = BASE_URL || '/'; + export const authenticatedFetch = (url, options = {}) => { const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true'; const token = localStorage.getItem('auth-token'); @@ -7,16 +14,29 @@ export const authenticatedFetch = (url, options = {}) => { 'Content-Type': 'application/json', }; + // Remove Content-Type if body is FormData to let browser set it with boundary + if (options.body instanceof FormData) { + delete defaultHeaders['Content-Type']; + } + + const headers = { + ...defaultHeaders, + ...options.headers, + }; + if (!isPlatform && token) { - defaultHeaders['Authorization'] = `Bearer ${token}`; + headers['Authorization'] = `Bearer ${token}`; + } + + // Prepend BASE_URL to relative paths + let finalUrl = url; + if (!url.startsWith('http') && url.startsWith('/')) { + finalUrl = `${BASE_URL}${url}`; } - return fetch(url, { + return fetch(finalUrl, { ...options, - headers: { - ...defaultHeaders, - ...options.headers, - }, + headers, }); }; @@ -24,15 +44,13 @@ export const authenticatedFetch = (url, options = {}) => { export const api = { // Auth endpoints (no token required) auth: { - status: () => fetch('/api/auth/status'), - login: (username, password) => fetch('/api/auth/login', { + status: () => authenticatedFetch('/api/auth/status'), + login: (username, password) => authenticatedFetch('/api/auth/login', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }), - register: (username, password) => fetch('/api/auth/register', { + register: (username, password) => authenticatedFetch('/api/auth/register', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }), user: () => authenticatedFetch('/api/auth/user'), @@ -42,7 +60,7 @@ export const api = { // Protected endpoints // config endpoint removed - no longer needed (frontend uses window.location) projects: () => authenticatedFetch('/api/projects'), - sessions: (projectName, limit = 5, offset = 0) => + sessions: (projectName, limit = 5, offset = 0) => authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`), sessionMessages: (projectName, sessionId, limit = null, offset = 0) => { const params = new URLSearchParams(); @@ -90,7 +108,7 @@ export const api = { authenticatedFetch('/api/transcribe', { method: 'POST', body: formData, - headers: {}, // Let browser set Content-Type for FormData + // headers handled automatically for FormData }), // TaskMaster endpoints diff --git a/src/utils/websocket.js b/src/utils/websocket.js index 553eaf2a..833ab8f9 100755 --- a/src/utils/websocket.js +++ b/src/utils/websocket.js @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { BASE_URL } from './api'; export function useWebSocket() { const [ws, setWs] = useState(null); @@ -29,7 +30,7 @@ export function useWebSocket() { if (isPlatform) { // Platform mode: Use same domain as the page (goes through proxy) const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/ws`; + wsUrl = `${protocol}//${window.location.host}${BASE_URL}/ws`; } else { // OSS mode: Connect to same host:port that served the page const token = localStorage.getItem('auth-token'); @@ -39,7 +40,7 @@ export function useWebSocket() { } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; + wsUrl = `${protocol}//${window.location.host}${BASE_URL}/ws?token=${encodeURIComponent(token)}`; } const websocket = new WebSocket(wsUrl);