fix(frontend): include frontend/dist in npm package and support static file serving

- Add ccw/frontend/dist/ to package.json files field
- Modify react-frontend.ts to detect and use production build
- Add static file serving to server.ts with MIME type support
- Update prepublishOnly to build frontend before publishing
- Fix unused import in TerminalDashboardPage.tsx

This fixes the 'Could not find React frontend directory' error when users install from npm.
This commit is contained in:
catlog22
2026-02-28 08:39:07 +08:00
parent 54f15b6bda
commit 902ee8528a
5 changed files with 129 additions and 538 deletions

View File

@@ -28,7 +28,6 @@ import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPan
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
import { useConfigStore } from '@/stores/configStore';
import { useQueueSchedulerStore } from '@/stores/queueSchedulerStore';
// ========== Main Page Component ==========

View File

@@ -1,5 +1,8 @@
import http from 'http';
import { URL } from 'url';
import { readFile } from 'fs/promises';
import { join, extname } from 'path';
import { existsSync } from 'fs';
// Import route handlers
import { handleStatusRoutes } from './routes/status-routes.js';
import { handleCliRoutes, cleanupStaleExecutions } from './routes/cli-routes.js';
@@ -53,6 +56,7 @@ import { getCorsOrigin } from './cors.js';
import { csrfValidation } from './auth/csrf-middleware.js';
import { getCsrfTokenManager } from './auth/csrf-manager.js';
import { randomBytes } from 'crypto';
import { getFrontendStaticDir } from '../utils/react-frontend.js';
// Import health check service
import { getHealthCheckService } from './services/health-check-service.js';
@@ -77,6 +81,54 @@ interface ServerOptions {
type PostHandler = PostRequestHandler;
/**
* MIME type mapping for static files
*/
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
};
/**
* Serve static file from frontend dist directory
*/
async function serveStaticFile(
filePath: string,
res: http.ServerResponse
): Promise<boolean> {
try {
if (!existsSync(filePath)) {
return false;
}
const content = await readFile(filePath);
const ext = extname(filePath);
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
res.writeHead(200, {
'Content-Type': mimeType,
'Cache-Control': 'public, max-age=31536000',
});
res.end(content);
return true;
} catch (error) {
console.error(`[Static] Error serving ${filePath}:`, error);
return false;
}
}
/**
* Handle POST request with JSON body
*/
@@ -664,8 +716,31 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
return;
}
// React frontend proxy - forward all non-API requests to Vite dev server
// React frontend proxy - forward all non-API requests to Vite dev server or serve static files
{
const frontendStaticDir = getFrontendStaticDir();
// If we have a static build, serve files directly
if (frontendStaticDir) {
let filePath = join(frontendStaticDir, pathname === '/' ? 'index.html' : pathname.slice(1));
// Try to serve the file
const served = await serveStaticFile(filePath, res);
// If file not found, serve index.html for SPA routing
if (!served && !pathname.startsWith('/api/')) {
filePath = join(frontendStaticDir, 'index.html');
const indexServed = await serveStaticFile(filePath, res);
if (!indexServed) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Frontend not found');
}
}
return;
}
// Otherwise, proxy to Vite dev server
const reactUrl = `http://localhost:${reactPort}${pathname}${url.search}`;
try {

View File

@@ -9,6 +9,14 @@ const __dirname = dirname(__filename);
let reactProcess: ChildProcess | null = null;
let reactPort: number | null = null;
let frontendStaticDir: string | null = null;
/**
* Get the frontend directory path (for serving static files in production)
*/
export function getFrontendStaticDir(): string | null {
return frontendStaticDir;
}
/**
* Start React frontend development server
@@ -24,18 +32,29 @@ export async function startReactFrontend(port: number): Promise<void> {
// Try to find frontend directory (relative to ccw package)
const possiblePaths = [
join(__dirname, '../../frontend'), // From dist/utils
join(__dirname, '../../frontend'), // From dist/utils (dev mode with full frontend)
join(__dirname, '../frontend'), // From src/utils (dev)
join(process.cwd(), 'frontend'), // Current working directory
];
// Also check for production build (frontend/dist)
const possibleDistPaths = [
join(__dirname, '../../frontend/dist'), // From dist/utils (production)
join(__dirname, '../frontend/dist'), // From src/utils (dev)
join(process.cwd(), 'frontend/dist'), // Current working directory
];
let frontendDir: string | null = null;
for (const path of possiblePaths) {
let isProductionBuild = false;
// First try to find production build
for (const path of possibleDistPaths) {
const resolvedPath = resolve(path);
try {
const { existsSync } = await import('fs');
if (existsSync(resolvedPath)) {
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.html'))) {
frontendDir = resolvedPath;
isProductionBuild = true;
break;
}
} catch {
@@ -43,6 +62,22 @@ export async function startReactFrontend(port: number): Promise<void> {
}
}
// If no production build, try dev mode
if (!frontendDir) {
for (const path of possiblePaths) {
const resolvedPath = resolve(path);
try {
const { existsSync } = await import('fs');
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'package.json'))) {
frontendDir = resolvedPath;
break;
}
} catch {
// Continue to next path
}
}
}
if (!frontendDir) {
throw new Error(
'Could not find React frontend directory. ' +
@@ -52,6 +87,18 @@ export async function startReactFrontend(port: number): Promise<void> {
console.log(chalk.cyan(` Starting React frontend on port ${port}...`));
console.log(chalk.gray(` Frontend dir: ${frontendDir}`));
console.log(chalk.gray(` Mode: ${isProductionBuild ? 'production (static)' : 'development (vite)'}`));
// If production build exists, serve static files instead of running dev server
if (isProductionBuild) {
frontendStaticDir = frontendDir;
console.log(chalk.green(` React frontend ready at http://localhost:${port} (static files)`));
// Return immediately - static files will be served by the main server
return;
}
// Reset static dir if we're in dev mode
frontendStaticDir = null;
// Check if package.json exists and has dev script
const packageJsonPath = join(frontendDir, 'package.json');