quarc/cli/scripts/serve.ts

560 lines
16 KiB
JavaScript

#!/usr/bin/env node
import * as fs from 'fs';
import * as path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process';
import * as http from 'http';
import * as https from 'https';
import { WebSocketServer, WebSocket } from 'ws';
import { BaseBuilder } from './base-builder';
import {
StaticPath,
StaticRemotePath,
} from '../types';
class Server extends BaseBuilder {
private isBuilding = false;
private buildQueued = false;
private wsClients: Set<WebSocket> = new Set();
private httpServer: http.Server | null = null;
private wsServer: WebSocketServer | null = null;
private actionProcesses: ChildProcess[] = [];
private mergedWsConnections: WebSocket[] = [];
private getDevServerPort(): number {
const args = process.argv.slice(2);
const portIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
if (portIndex !== -1 && args[portIndex + 1]) {
const port = parseInt(args[portIndex + 1], 10);
if (!isNaN(port) && port > 0 && port < 65536) {
return port;
}
}
const envConfig = this.config.environments[this.config.environment];
return envConfig?.devServer?.port || 4200;
}
private getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: { [key: string]: string } = {
'.html': 'text/html',
'.js': 'text/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',
};
return mimeTypes[ext] || 'application/octet-stream';
}
private getWebSocketConfig() {
const envConfig = this.config.environments[this.config.environment];
return envConfig?.devServer?.websocket;
}
private attachWebSocketServer(server: http.Server): void {
this.wsServer = new WebSocketServer({ server, path: '/qu-ws/' });
this.wsServer.on('connection', (ws: WebSocket) => {
this.wsClients.add(ws);
if (this.isVerbose()) console.log('Client connected to live reload WebSocket');
ws.on('message', (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
this.handleIncomingMessage(message, ws);
} catch {
}
});
ws.on('close', () => {
this.wsClients.delete(ws);
if (this.isVerbose()) console.log('Client disconnected from live reload WebSocket');
});
ws.send(JSON.stringify({ type: 'connected' }));
});
if (this.isVerbose()) console.log('WebSocket server attached to HTTP server');
this.connectToMergedSources();
}
private connectToMergedSources(): void {
const wsConfig = this.getWebSocketConfig();
const mergeFrom = wsConfig?.mergeFrom || [];
for (const url of mergeFrom) {
this.connectToMergedSource(url);
}
}
private connectToMergedSource(url: string): void {
if (this.isVerbose()) console.log(`Connecting to merged WebSocket source: ${url}`);
const ws = new WebSocket(url);
ws.on('open', () => {
if (this.isVerbose()) console.log(`Connected to merged source: ${url}`);
this.mergedWsConnections.push(ws);
});
ws.on('message', (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
if (message.type === 'reload') {
this.broadcastToClients(message);
}
} catch {
}
});
ws.on('close', () => {
if (this.isVerbose()) console.log(`Disconnected from merged source: ${url}, reconnecting...`);
this.mergedWsConnections = this.mergedWsConnections.filter(c => c !== ws);
setTimeout(() => this.connectToMergedSource(url), 2000);
});
ws.on('error', (err: Error) => {
console.warn(`WebSocket error for ${url}:`, err.message);
});
}
private handleIncomingMessage(message: { type: string; [key: string]: unknown }, sender: WebSocket): void {
if (message.type === 'reload') {
this.broadcastToClients(message, sender);
this.broadcastToMergedSources(message);
}
}
private broadcastToClients(message: { type: string; [key: string]: unknown }, excludeSender?: WebSocket): void {
const data = JSON.stringify(message);
for (const client of this.wsClients) {
if (client !== excludeSender && client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
private broadcastToMergedSources(message: { type: string; [key: string]: unknown }): void {
const data = JSON.stringify(message);
for (const ws of this.mergedWsConnections) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
}
}
private getStaticPaths(): StaticPath[] {
return this.config.serve?.staticPaths || [];
}
private proxyRequest(targetUrl: string, req: http.IncomingMessage, res: http.ServerResponse): void {
console.log(`[Proxy] ${req.method} ${req.url} -> ${targetUrl}`);
const parsedUrl = new URL(targetUrl);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const proxyReq = protocol.request(
targetUrl,
{
method: req.method,
headers: {
...req.headers,
host: parsedUrl.host,
},
},
(proxyRes) => {
console.log(`[Proxy] Response: ${proxyRes.statusCode} for ${req.url}`);
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
proxyRes.pipe(res);
},
);
proxyReq.on('error', (err) => {
console.error(`[Proxy] Error for ${req.url}:`, err.message);
res.writeHead(502);
res.end('Bad Gateway');
});
req.pipe(proxyReq);
}
private isRemotePath(staticPath: StaticPath): staticPath is StaticRemotePath {
return 'url' in staticPath;
}
private tryServeStaticPath(reqUrl: string, req: http.IncomingMessage, res: http.ServerResponse): boolean {
const staticPaths = this.getStaticPaths();
for (const staticPath of staticPaths) {
if (reqUrl.startsWith(staticPath.location)) {
const relativePath = reqUrl.slice(staticPath.location.length);
if (this.isRemotePath(staticPath)) {
const targetUrl = staticPath.url + relativePath;
this.proxyRequest(targetUrl, req, res);
return true;
}
const basePath = path.resolve(this.projectRoot, staticPath.path);
let filePath = path.join(basePath, relativePath || 'index.html');
const normalizedFilePath = path.normalize(filePath);
if (!normalizedFilePath.startsWith(basePath)) {
res.writeHead(403);
res.end('Forbidden');
return true;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end('Not Found');
return true;
}
const mimeType = this.getMimeType(filePath);
const content = fs.readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mimeType,
'Cache-Control': 'no-cache',
});
res.end(content);
return true;
}
}
return false;
}
private startHttpServer(port: number): void {
this.httpServer = http.createServer((req, res) => {
const reqUrl = req.url || '/';
if (this.tryServeStaticPath(reqUrl, req, res)) {
return;
}
let filePath = path.join(this.distDir, reqUrl === '/' ? 'index.html' : reqUrl);
if (filePath.includes('..')) {
res.writeHead(403);
res.end('Forbidden');
return;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
if (!fs.existsSync(filePath)) {
const indexPath = path.join(this.distDir, 'index.html');
if (fs.existsSync(indexPath)) {
filePath = indexPath;
} else {
res.writeHead(404);
res.end('Not Found');
return;
}
}
const mimeType = this.getMimeType(filePath);
const content = fs.readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mimeType,
'Cache-Control': 'no-cache',
});
res.end(content);
});
this.httpServer.listen(port, () => {
if (!this.isVerbose()) {
console.log(`\n🌐 Server: http://localhost:${port}`);
} else {
console.log(`\n** Quarc Live Development Server is listening on localhost:${port} **`);
console.log(`** Open your browser on http://localhost:${port}/ **\n`);
}
});
this.attachWebSocketServer(this.httpServer);
}
private notifyClients(): void {
const message = { type: 'reload' };
this.broadcastToClients(message);
this.broadcastToMergedSources(message);
if (this.wsClients.size > 0 && this.isVerbose()) {
console.log('📢 Notified clients to reload');
}
}
private async runBuild(): Promise<void> {
if (this.isBuilding) {
this.buildQueued = true;
return;
}
this.isBuilding = true;
this.buildQueued = false;
if (this.isVerbose()) console.log('\n🔨 Building application...');
const startTime = Date.now();
try {
const buildScript = path.join(__dirname, 'build.ts');
const tsNodePath = path.join(this.projectRoot, 'node_modules', '.bin', 'ts-node');
const configArg = ` -c ${this.config.environment}`;
const verboseArg = this.isVerbose() ? ' -v' : '';
execSync(`${tsNodePath} ${buildScript}${configArg}${verboseArg}`, {
stdio: 'inherit',
cwd: this.projectRoot,
});
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
if (this.isVerbose()) console.log(`✅ Build completed in ${duration}s`);
this.notifyClients();
} catch (error) {
console.error('❌ Build failed');
} finally {
this.isBuilding = false;
if (this.buildQueued) {
console.log('⏳ Running queued build...');
setTimeout(() => this.runBuild(), 100);
}
}
}
private watchFiles(): void {
if (this.isVerbose()) console.log(`👀 Watching for changes in ${this.srcDir}...`);
const debounceDelay = 300;
let debounceTimer: NodeJS.Timeout | null = null;
const watcher = fs.watch(this.srcDir, { recursive: true }, (eventType, filename) => {
if (!filename) return;
const ext = path.extname(filename);
if (!['.ts', '.scss', '.sass', '.css', '.html'].includes(ext)) {
return;
}
if (this.isVerbose()) console.log(`📝 File changed: ${filename}`);
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
this.runBuild();
}, debounceDelay);
});
const cleanup = () => {
console.log('\n👋 Stopping watch mode...');
watcher.close();
for (const client of this.wsClients) {
client.close();
}
this.wsClients.clear();
for (const ws of this.mergedWsConnections) {
ws.close();
}
this.mergedWsConnections = [];
if (this.wsServer) {
this.wsServer.close();
}
if (this.httpServer) {
this.httpServer.close();
}
this.terminateActionProcesses();
this.runPostServeActions();
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
}
private injectLiveReloadScript(): void {
const indexPath = path.join(this.distDir, 'index.html');
const wsPort = this.getDevServerPort();
if (!fs.existsSync(indexPath)) {
console.warn('index.html not found in dist directory');
return;
}
let html = fs.readFileSync(indexPath, 'utf-8');
const liveReloadScript = `
<script>
(function() {
let ws;
let reconnectAttempts = 0;
const maxReconnectDelay = 5000;
function connect() {
ws = new WebSocket('ws://localhost:${wsPort}/qu-ws/');
ws.onopen = () => {
console.log('[Live Reload] Connected');
reconnectAttempts = 0;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'reload') {
console.log('[Live Reload] Reloading page...');
window.location.reload();
}
} catch {}
};
ws.onclose = () => {
console.warn('[Live Reload] Connection lost, attempting to reconnect...');
reconnectAttempts++;
const delay = Math.min(1000 * reconnectAttempts, maxReconnectDelay);
setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close();
};
}
connect();
})();
</script>
`;
if (!html.includes('Live Reload')) {
html = html.replace('</body>', `${liveReloadScript}</body>`);
fs.writeFileSync(indexPath, html, 'utf-8');
if (this.isVerbose()) console.log('✅ Injected live reload script into index.html');
}
}
private runPreServeActions(): void {
const actions = this.config.serve?.actions?.preserve || [];
if (actions.length === 0) return;
if (this.isVerbose()) console.log('🔧 Running preserve actions...');
for (const action of actions) {
if (this.isVerbose()) console.log(`${action}`);
const child = spawn(action, [], {
shell: true,
cwd: this.projectRoot,
stdio: 'inherit',
detached: true,
});
this.actionProcesses.push(child);
child.on('error', (err) => {
console.error(` ❌ Action failed: ${action}`, err.message);
});
}
}
private runPostServeActions(): void {
const actions = this.config.serve?.actions?.postserve || [];
if (actions.length === 0) return;
if (this.isVerbose()) console.log('🔧 Running postserve actions...');
for (const action of actions) {
if (this.isVerbose()) console.log(`${action}`);
try {
execSync(action, {
cwd: this.projectRoot,
stdio: 'inherit',
});
} catch (err) {
console.error(` ❌ Action failed: ${action}`);
}
}
}
private terminateActionProcesses(): void {
if (this.actionProcesses.length === 0) return;
if (this.isVerbose()) console.log('🛑 Terminating action processes...');
for (const child of this.actionProcesses) {
if (child.pid && !child.killed) {
try {
process.kill(-child.pid, 'SIGTERM');
if (this.isVerbose()) console.log(` ✓ Terminated process group ${child.pid}`);
} catch (err) {
try {
child.kill('SIGTERM');
if (this.isVerbose()) console.log(` ✓ Terminated process ${child.pid}`);
} catch {
console.warn(` ⚠ Could not terminate process ${child.pid}`);
}
}
}
}
this.actionProcesses = [];
}
async run(): Promise<void> {
const port = this.getDevServerPort();
if (this.isVerbose()) {
console.log('🚀 Starting development server...\n');
console.log(`Environment: ${this.config.environment}`);
}
this.runPreServeActions();
if (this.isVerbose()) console.log('📦 Running initial build...');
await this.runBuild();
this.injectLiveReloadScript();
this.startHttpServer(port);
if (this.isVerbose()) {
console.log('✨ Development server is ready!');
console.log('📂 Serving files from:', this.distDir);
console.log('🔄 Live reload WebSocket enabled on port', port);
console.log('\nPress Ctrl+C to stop\n');
}
this.watchFiles();
}
}
const server = new Server();
server.run().catch(error => {
console.error('Serve process failed:', error);
process.exit(1);
});