Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=/
139 changes: 113 additions & 26 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = `<script>window.__APP_BASE_PATH__='${basePath}';</script>`;
html = html.replace('</head>', `${configScript}</head>`);

// 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' : '-';
Expand Down
3 changes: 2 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -951,7 +952,7 @@ function App() {
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router>
<Router basename={ROUTER_BASENAME}>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
Expand Down
12 changes: 4 additions & 8 deletions src/components/ClaudeLogo.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import React from 'react';
import { BASE_URL } from '../utils/api';

const ClaudeLogo = ({className = 'w-5 h-5'}) => {
return (
<img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} />
);
};
const ClaudeLogo = ({ className = 'w-5 h-5' }) => (
<img src={`${BASE_URL}/icons/claude-ai-icon.svg`} alt="Claude" className={className} />
);

export default ClaudeLogo;


10 changes: 4 additions & 6 deletions src/components/CursorLogo.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import React from 'react';
import { BASE_URL } from '../utils/api';

const CursorLogo = ({ className = 'w-5 h-5' }) => {
return (
<img src="/icons/cursor.svg" alt="Cursor" className={className} />
);
};
const CursorLogo = ({ className = 'w-5 h-5' }) => (
<img src={`${BASE_URL}/icons/cursor.svg`} alt="Cursor" className={className} />
);

export default CursorLogo;
9 changes: 6 additions & 3 deletions src/components/Shell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -296,7 +299,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
data: text
}));
}
}).catch(() => {});
}).catch(() => { });
return false;
}

Expand Down
44 changes: 31 additions & 13 deletions src/utils/api.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -7,32 +14,43 @@ 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,
});
};

// API endpoints
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'),
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/utils/websocket.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { BASE_URL } from './api';

export function useWebSocket() {
const [ws, setWs] = useState(null);
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand Down