mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-09 02:24:11 +08:00
feat: add support for dual frontend (JS and React) in the CCW application
- Updated CLI to include `--frontend` option for selecting frontend type (js, react, both). - Modified serve command to start React frontend when specified. - Implemented React frontend startup and shutdown logic. - Enhanced server routing to handle requests for both JS and React frontends. - Added workspace selector component with i18n support. - Updated tests to reflect changes in header and A2UI components. - Introduced new Radix UI components for improved UI consistency. - Refactored A2UIButton and A2UIDateTimeInput components for better code clarity. - Created migration plan for gradual transition from JS to React frontend.
This commit is contained in:
@@ -87,6 +87,7 @@ export function run(argv: string[]): void {
|
||||
.option('--port <port>', 'Server port', '3456')
|
||||
.option('--host <host>', 'Server host to bind', '127.0.0.1')
|
||||
.option('--no-browser', 'Start server without opening browser')
|
||||
.option('--frontend <type>', 'Frontend type: js, react, both', 'both')
|
||||
.action(viewCommand);
|
||||
|
||||
// Serve command (alias for view)
|
||||
@@ -97,6 +98,7 @@ export function run(argv: string[]): void {
|
||||
.option('--port <port>', 'Server port', '3456')
|
||||
.option('--host <host>', 'Server host to bind', '127.0.0.1')
|
||||
.option('--no-browser', 'Start server without opening browser')
|
||||
.option('--frontend <type>', 'Frontend type: js, react, both', 'both')
|
||||
.action(serveCommand);
|
||||
|
||||
// Stop command
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startServer } from '../core/server.js';
|
||||
import { launchBrowser } from '../utils/browser-launcher.js';
|
||||
import { resolvePath, validatePath } from '../utils/path-resolver.js';
|
||||
import { startReactFrontend, stopReactFrontend } from '../utils/react-frontend.js';
|
||||
import chalk from 'chalk';
|
||||
import type { Server } from 'http';
|
||||
|
||||
@@ -9,6 +10,7 @@ interface ServeOptions {
|
||||
path?: string;
|
||||
host?: string;
|
||||
browser?: boolean;
|
||||
frontend?: 'js' | 'react' | 'both';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,6 +20,7 @@ interface ServeOptions {
|
||||
export async function serveCommand(options: ServeOptions): Promise<void> {
|
||||
const port = options.port || 3456;
|
||||
const host = options.host || '127.0.0.1';
|
||||
const frontend = options.frontend || 'js';
|
||||
|
||||
// Validate project path
|
||||
let initialPath = process.cwd();
|
||||
@@ -33,12 +36,31 @@ export async function serveCommand(options: ServeOptions): Promise<void> {
|
||||
console.log(chalk.blue.bold('\n CCW Dashboard Server\n'));
|
||||
console.log(chalk.gray(` Initial project: ${initialPath}`));
|
||||
console.log(chalk.gray(` Host: ${host}`));
|
||||
console.log(chalk.gray(` Port: ${port}\n`));
|
||||
console.log(chalk.gray(` Port: ${port}`));
|
||||
console.log(chalk.gray(` Frontend: ${frontend}\n`));
|
||||
|
||||
// Start React frontend if needed
|
||||
let reactPort: number | undefined;
|
||||
if (frontend === 'react' || frontend === 'both') {
|
||||
reactPort = port + 1;
|
||||
try {
|
||||
await startReactFrontend(reactPort);
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`\n Failed to start React frontend: ${error}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Start server
|
||||
console.log(chalk.cyan(' Starting server...'));
|
||||
const server = await startServer({ port, host, initialPath });
|
||||
const server = await startServer({
|
||||
port,
|
||||
host,
|
||||
initialPath,
|
||||
frontend,
|
||||
reactPort
|
||||
});
|
||||
|
||||
const boundUrl = `http://${host}:${port}`;
|
||||
const browserUrl = host === '0.0.0.0' || host === '::' ? `http://localhost:${port}` : boundUrl;
|
||||
@@ -50,11 +72,24 @@ export async function serveCommand(options: ServeOptions): Promise<void> {
|
||||
|
||||
console.log(chalk.green(` Server running at ${boundUrl}`));
|
||||
|
||||
// Display frontend URLs
|
||||
if (frontend === 'both') {
|
||||
console.log(chalk.gray(` JS Frontend: ${boundUrl}`));
|
||||
console.log(chalk.gray(` React Frontend: ${boundUrl}/react`));
|
||||
} else if (frontend === 'react') {
|
||||
console.log(chalk.gray(` React Frontend: ${boundUrl}/react`));
|
||||
}
|
||||
|
||||
// Open browser
|
||||
if (options.browser !== false) {
|
||||
console.log(chalk.cyan(' Opening in browser...'));
|
||||
try {
|
||||
await launchBrowser(browserUrl);
|
||||
// Determine which URL to open based on frontend setting
|
||||
let openUrl = browserUrl;
|
||||
if (frontend === 'react') {
|
||||
openUrl = `${browserUrl}/react`;
|
||||
}
|
||||
await launchBrowser(openUrl);
|
||||
console.log(chalk.green.bold('\n Dashboard opened in browser!'));
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
@@ -68,6 +103,7 @@ export async function serveCommand(options: ServeOptions): Promise<void> {
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\n Shutting down server...'));
|
||||
stopReactFrontend();
|
||||
server.close(() => {
|
||||
console.log(chalk.green(' Server stopped.\n'));
|
||||
process.exit(0);
|
||||
@@ -81,6 +117,7 @@ export async function serveCommand(options: ServeOptions): Promise<void> {
|
||||
console.error(chalk.yellow(` Port ${port} is already in use.`));
|
||||
console.error(chalk.gray(` Try a different port: ccw serve --port ${port + 1}\n`));
|
||||
}
|
||||
stopReactFrontend();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +57,7 @@ import { getCliToolsStatus } from '../tools/cli-executor.js';
|
||||
import type { ServerConfig } from '../types/config.js';
|
||||
import type { PostRequestHandler } from './routes/types.js';
|
||||
|
||||
interface ServerOptions {
|
||||
port?: number;
|
||||
initialPath?: string;
|
||||
host?: string;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
|
||||
type PostHandler = PostRequestHandler;
|
||||
|
||||
@@ -664,22 +659,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleSystemRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Serve dashboard HTML
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
// Set session cookie and CSRF token for all requests
|
||||
const tokenResult = tokenManager.getOrCreateAuthToken();
|
||||
setAuthCookie(res, tokenResult.token, tokenResult.expiresAt);
|
||||
|
||||
const sessionId = getOrCreateSessionId(req, res);
|
||||
const csrfToken = getCsrfTokenManager().generateToken(sessionId);
|
||||
res.setHeader('X-CSRF-Token', csrfToken);
|
||||
setCsrfCookie(res, csrfToken, 15 * 60);
|
||||
|
||||
const html = generateServerDashboard(initialPath);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle favicon.ico (return empty response to prevent 404)
|
||||
if (pathname === '/favicon.ico') {
|
||||
|
||||
173
ccw/src/utils/react-frontend.ts
Normal file
173
ccw/src/utils/react-frontend.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let reactProcess: ChildProcess | null = null;
|
||||
let reactPort: number | null = null;
|
||||
|
||||
/**
|
||||
* Start React frontend development server
|
||||
* @param port - Port to run React frontend on
|
||||
* @returns Promise that resolves when server is ready
|
||||
*/
|
||||
export async function startReactFrontend(port: number): Promise<void> {
|
||||
// Check if already running
|
||||
if (reactProcess && reactPort === port) {
|
||||
console.log(chalk.yellow(` React frontend already running on port ${port}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find frontend directory (relative to ccw package)
|
||||
const possiblePaths = [
|
||||
join(__dirname, '../../frontend'), // From dist/utils
|
||||
join(__dirname, '../frontend'), // From src/utils (dev)
|
||||
join(process.cwd(), 'frontend'), // Current working directory
|
||||
];
|
||||
|
||||
let frontendDir: string | null = null;
|
||||
for (const path of possiblePaths) {
|
||||
const resolvedPath = resolve(path);
|
||||
try {
|
||||
const { existsSync } = await import('fs');
|
||||
if (existsSync(resolvedPath)) {
|
||||
frontendDir = resolvedPath;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
|
||||
if (!frontendDir) {
|
||||
throw new Error(
|
||||
'Could not find React frontend directory. ' +
|
||||
'Make sure the frontend folder exists in the ccw package.'
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(` Starting React frontend on port ${port}...`));
|
||||
console.log(chalk.gray(` Frontend dir: ${frontendDir}`));
|
||||
|
||||
// Check if package.json exists and has dev script
|
||||
const packageJsonPath = join(frontendDir, 'package.json');
|
||||
try {
|
||||
const { readFileSync, existsSync } = await import('fs');
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error('package.json not found in frontend directory');
|
||||
}
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
if (!packageJson.scripts?.dev) {
|
||||
throw new Error('No "dev" script found in package.json');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to validate frontend setup: ${error}`);
|
||||
}
|
||||
|
||||
// Spawn React dev server
|
||||
reactProcess = spawn('npm', ['run', 'dev', '--', '--port', port.toString()], {
|
||||
cwd: frontendDir,
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Prevent React from auto-opening browser
|
||||
VITE_BASE_URL: '/react/', // Set base URL for React frontend
|
||||
}
|
||||
});
|
||||
|
||||
reactPort = port;
|
||||
|
||||
// Wait for server to be ready
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reactProcess?.kill();
|
||||
reject(new Error(
|
||||
`React frontend startup timeout (30s).\n` +
|
||||
`Output: ${output}\n` +
|
||||
`Errors: ${errorOutput}`
|
||||
));
|
||||
}, 30000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
reactProcess?.stdout?.removeAllListeners();
|
||||
reactProcess?.stderr?.removeAllListeners();
|
||||
};
|
||||
|
||||
reactProcess?.stdout?.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
output += chunk;
|
||||
|
||||
// Check for ready signals
|
||||
if (
|
||||
chunk.includes('Local:') ||
|
||||
chunk.includes('ready in') ||
|
||||
chunk.includes('VITE') && chunk.includes(port.toString())
|
||||
) {
|
||||
cleanup();
|
||||
console.log(chalk.green(` React frontend ready at http://localhost:${port}`));
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
reactProcess?.stderr?.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
errorOutput += chunk;
|
||||
// Log warnings but don't fail
|
||||
if (chunk.toLowerCase().includes('warn')) {
|
||||
console.log(chalk.yellow(` React: ${chunk.trim()}`));
|
||||
}
|
||||
});
|
||||
|
||||
reactProcess?.on('error', (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
reactProcess?.on('exit', (code: number | null) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
cleanup();
|
||||
reject(new Error(`React process exited with code ${code}. Errors: ${errorOutput}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop React frontend development server
|
||||
*/
|
||||
export function stopReactFrontend(): void {
|
||||
if (reactProcess) {
|
||||
console.log(chalk.yellow(' Stopping React frontend...'));
|
||||
reactProcess.kill('SIGTERM');
|
||||
|
||||
// Force kill after timeout
|
||||
setTimeout(() => {
|
||||
if (reactProcess && !reactProcess.killed) {
|
||||
reactProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
reactProcess = null;
|
||||
reactPort = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get React frontend status
|
||||
* @returns Object with running status and port
|
||||
*/
|
||||
export function getReactFrontendStatus(): { running: boolean; port: number | null } {
|
||||
return {
|
||||
running: reactProcess !== null && !reactProcess.killed,
|
||||
port: reactPort
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user