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 (
-
- );
-};
+const ClaudeLogo = ({ className = 'w-5 h-5' }) => (
+
+);
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 (
-
- );
-};
+const CursorLogo = ({ className = 'w-5 h-5' }) => (
+
+);
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);