import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { serveStatic } from 'hono/bun'; import { streamSSE } from 'hono/streaming'; import { createProductionDokployClient } from './api/dokploy-production.js'; import { ProductionDeployer } from './orchestrator/production-deployer.js'; import type { DeploymentState as OrchestratorDeploymentState } from './orchestrator/production-deployer.js'; const PORT = parseInt(process.env.PORT || '3000', 10); const HOST = process.env.HOST || '0.0.0.0'; // Extended deployment state for HTTP server (adds logs and language) interface HttpDeploymentState extends OrchestratorDeploymentState { logs: string[]; lang: string; } const deployments = new Map(); // Generate a unique deployment ID function generateDeploymentId(): string { return `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } // Validate stack name function validateStackName(name: string): { valid: boolean; error?: string } { if (!name || typeof name !== 'string') { return { valid: false, error: 'Name is required' }; } const trimmedName = name.trim().toLowerCase(); if (trimmedName.length < 3 || trimmedName.length > 20) { return { valid: false, error: 'Name must be between 3 and 20 characters' }; } if (!/^[a-z0-9-]+$/.test(trimmedName)) { return { valid: false, error: 'Name can only contain lowercase letters, numbers, and hyphens' }; } if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) { return { valid: false, error: 'Name cannot start or end with a hyphen' }; } const reservedNames = (process.env.RESERVED_NAMES || 'admin,api,www,root,system,test,demo,portal').split(','); if (reservedNames.includes(trimmedName)) { return { valid: false, error: `Name "${trimmedName}" is reserved` }; } return { valid: true }; } // Main deployment orchestration using production components async function deployStack(deploymentId: string): Promise { const deployment = deployments.get(deploymentId); if (!deployment) { throw new Error('Deployment not found'); } try { const client = createProductionDokployClient(); // Progress callback to update state in real-time const progressCallback = (state: OrchestratorDeploymentState) => { const currentDeployment = deployments.get(deploymentId); if (currentDeployment) { // Update all fields from orchestrator state currentDeployment.phase = state.phase; currentDeployment.status = state.status; currentDeployment.progress = state.progress; currentDeployment.message = state.message; currentDeployment.url = state.url; currentDeployment.error = state.error; currentDeployment.resources = state.resources; currentDeployment.timestamps = state.timestamps; deployments.set(deploymentId, { ...currentDeployment }); } }; const deployer = new ProductionDeployer(client, progressCallback); const result = await deployer.deploy({ stackName: deployment.stackName, dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/flexinit/agent-stack:latest', domainSuffix: process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl', port: 8080, healthCheckTimeout: 180000, healthCheckInterval: 5000, registryId: process.env.STACK_REGISTRY_ID, sharedProjectId: process.env.SHARED_PROJECT_ID, sharedEnvironmentId: process.env.SHARED_ENVIRONMENT_ID, lang: deployment.lang, }); // Final update with logs const finalDeployment = deployments.get(deploymentId); if (finalDeployment) { finalDeployment.logs = result.logs; deployments.set(deploymentId, { ...finalDeployment }); } } catch (error) { // Deployment failed catastrophically (before orchestrator could handle it) const currentDeployment = deployments.get(deploymentId); if (currentDeployment) { currentDeployment.status = 'failure'; currentDeployment.phase = 'failed'; currentDeployment.error = { phase: currentDeployment.phase, message: error instanceof Error ? error.message : 'Unknown error', code: 'DEPLOYMENT_FAILED', }; currentDeployment.message = 'Deployment failed'; currentDeployment.timestamps.completed = new Date().toISOString(); deployments.set(deploymentId, { ...currentDeployment }); } throw error; } } const app = new Hono(); app.use('*', logger()); app.use('*', cors()); app.get('/health', (c) => { return c.json({ status: 'healthy', timestamp: new Date().toISOString(), version: '0.2.0', // Bumped version for production components service: 'ai-stack-deployer', activeDeployments: deployments.size, features: { productionClient: true, retryLogic: true, circuitBreaker: true, autoRollback: true, healthVerification: true, } }); }); // Deploy endpoint app.post('/api/deploy', async (c) => { try { const body = await c.req.json(); const { name, lang = 'en' } = body; // Validate name const validation = validateStackName(name); if (!validation.valid) { return c.json({ success: false, error: validation.error, code: 'INVALID_NAME' }, 400); } const normalizedName = name.trim().toLowerCase(); const client = createProductionDokployClient(); const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID; const appName = `opencode-${normalizedName}`; if (sharedEnvironmentId) { const existingApp = await client.findApplicationByName(sharedEnvironmentId, appName); if (existingApp) { return c.json({ success: false, error: 'Name already taken', code: 'NAME_EXISTS' }, 409); } } else { const projectName = `ai-stack-${normalizedName}`; const existingProject = await client.findProjectByName(projectName); if (existingProject) { return c.json({ success: false, error: 'Name already taken', code: 'NAME_EXISTS' }, 409); } } // Create deployment state const deploymentId = generateDeploymentId(); const deployment: HttpDeploymentState = { id: deploymentId, stackName: normalizedName, phase: 'initializing', status: 'in_progress', progress: 0, message: 'Initializing deployment', resources: {}, timestamps: { started: new Date().toISOString(), }, logs: [], lang, }; deployments.set(deploymentId, deployment); // Start deployment in background deployStack(deploymentId).catch(err => { console.error(`Deployment ${deploymentId} failed:`, err); }); return c.json({ success: true, deploymentId, url: `https://${normalizedName}.${process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl'}`, statusEndpoint: `/api/status/${deploymentId}` }); } catch (error) { console.error('Deploy endpoint error:', error); return c.json({ success: false, error: error instanceof Error ? error.message : 'Internal server error', code: 'INTERNAL_ERROR' }, 500); } }); // Status endpoint with SSE app.get('/api/status/:deploymentId', (c) => { const deploymentId = c.req.param('deploymentId'); const deployment = deployments.get(deploymentId); if (!deployment) { return c.json({ success: false, error: 'Deployment not found', code: 'NOT_FOUND' }, 404); } return streamSSE(c, async (stream) => { let lastStatus = ''; try { // Stream updates until deployment completes or fails while (true) { const currentDeployment = deployments.get(deploymentId); if (!currentDeployment) { await stream.writeSSE({ event: 'error', data: JSON.stringify({ message: 'Deployment not found' }) }); break; } // Send update if status changed const currentStatus = JSON.stringify({ phase: currentDeployment.phase, status: currentDeployment.status, progress: currentDeployment.progress, message: currentDeployment.message, }); if (currentStatus !== lastStatus) { await stream.writeSSE({ event: 'progress', data: JSON.stringify({ phase: currentDeployment.phase, status: currentDeployment.status, progress: currentDeployment.progress, message: currentDeployment.message, currentStep: currentDeployment.message, // Backward compatibility url: currentDeployment.url, error: currentDeployment.error?.message, resources: currentDeployment.resources, }) }); lastStatus = currentStatus; } // Exit if terminal state if (currentDeployment.status === 'success' || currentDeployment.phase === 'completed') { await stream.writeSSE({ event: 'complete', data: JSON.stringify({ url: currentDeployment.url, status: 'ready', resources: currentDeployment.resources, duration: currentDeployment.timestamps.completed && currentDeployment.timestamps.started ? (new Date(currentDeployment.timestamps.completed).getTime() - new Date(currentDeployment.timestamps.started).getTime()) / 1000 : null, }) }); break; } if (currentDeployment.status === 'failure' || currentDeployment.phase === 'failed') { await stream.writeSSE({ event: 'error', data: JSON.stringify({ message: currentDeployment.error?.message || 'Deployment failed', status: 'failed', phase: currentDeployment.error?.phase, code: currentDeployment.error?.code, }) }); break; } // Wait before next check await stream.sleep(1000); } } catch (error) { console.error('SSE stream error:', error); } }); }); // Get deployment details (new endpoint for debugging) app.get('/api/deployment/:deploymentId', (c) => { const deploymentId = c.req.param('deploymentId'); const deployment = deployments.get(deploymentId); if (!deployment) { return c.json({ success: false, error: 'Deployment not found', code: 'NOT_FOUND' }, 404); } return c.json({ success: true, deployment: { id: deployment.id, stackName: deployment.stackName, phase: deployment.phase, status: deployment.status, progress: deployment.progress, message: deployment.message, url: deployment.url, error: deployment.error, resources: deployment.resources, timestamps: deployment.timestamps, logs: deployment.logs.slice(-50), // Last 50 log entries } }); }); // Check name availability app.get('/api/check/:name', async (c) => { try { const name = c.req.param('name'); const validation = validateStackName(name); if (!validation.valid) { return c.json({ available: false, valid: false, error: validation.error }); } const normalizedName = name.trim().toLowerCase(); const client = createProductionDokployClient(); const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID; let exists = false; if (sharedEnvironmentId) { const appName = `opencode-${normalizedName}`; const existingApp = await client.findApplicationByName(sharedEnvironmentId, appName); exists = !!existingApp; } else { const projectName = `ai-stack-${normalizedName}`; const existingProject = await client.findProjectByName(projectName); exists = !!existingProject; } return c.json({ available: !exists, valid: true, name: normalizedName }); } catch (error) { console.error('Check endpoint error:', error); return c.json({ available: false, valid: false, error: 'Failed to check availability' }, 500); } }); app.delete('/api/stack/:name', async (c) => { try { const name = c.req.param('name'); const normalizedName = name.trim().toLowerCase(); const client = createProductionDokployClient(); const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID; if (sharedEnvironmentId) { const appName = `opencode-${normalizedName}`; const existingApp = await client.findApplicationByName(sharedEnvironmentId, appName); if (!existingApp) { return c.json({ success: false, error: 'Stack not found', code: 'NOT_FOUND' }, 404); } console.log(`Deleting stack: ${appName} (applicationId: ${existingApp.applicationId})`); await client.deleteApplication(existingApp.applicationId); return c.json({ success: true, message: `Stack ${normalizedName} deleted successfully`, deletedApplicationId: existingApp.applicationId }); } else { const projectName = `ai-stack-${normalizedName}`; const existingProject = await client.findProjectByName(projectName); if (!existingProject) { return c.json({ success: false, error: 'Stack not found', code: 'NOT_FOUND' }, 404); } console.log(`Deleting stack: ${projectName} (projectId: ${existingProject.project.projectId})`); await client.deleteProject(existingProject.project.projectId); return c.json({ success: true, message: `Stack ${normalizedName} deleted successfully`, deletedProjectId: existingProject.project.projectId }); } } catch (error) { console.error('Delete endpoint error:', error); return c.json({ success: false, error: error instanceof Error ? error.message : 'Failed to delete stack', code: 'DELETE_FAILED' }, 500); } }); const REACT_BUILD_PATH = './dist/client'; const LEGACY_FRONTEND_PATH = './src/frontend'; const USE_REACT = process.env.USE_REACT_FRONTEND !== 'false'; async function serveFile(reactPath: string, legacyPath: string, contentType: string) { const filePath = USE_REACT ? `${REACT_BUILD_PATH}${reactPath}` : `${LEGACY_FRONTEND_PATH}${legacyPath}`; const file = Bun.file(filePath); if (await file.exists()) { return new Response(file, { headers: { 'Content-Type': contentType } }); } const fallbackFile = Bun.file(`${LEGACY_FRONTEND_PATH}${legacyPath}`); if (await fallbackFile.exists()) { return new Response(fallbackFile, { headers: { 'Content-Type': contentType } }); } return new Response('Not Found', { status: 404 }); } app.get('/assets/*', async (c) => { const path = c.req.path; const file = Bun.file(`${REACT_BUILD_PATH}${path}`); if (await file.exists()) { const ext = path.split('.').pop(); const contentTypes: Record = { js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', woff: 'font/woff', woff2: 'font/woff2', }; return new Response(file, { headers: { 'Content-Type': contentTypes[ext || ''] || 'application/octet-stream' } }); } return new Response('Not Found', { status: 404 }); }); app.get('/style.css', (c) => serveFile('/style.css', '/style.css', 'text/css')); app.get('/app.js', (c) => serveFile('/app.js', '/app.js', 'application/javascript')); app.get('/og-image.png', (c) => serveFile('/og-image.png', '/og-image.png', 'image/png')); app.get('/favicon.svg', (c) => serveFile('/favicon.svg', '/favicon.svg', 'image/svg+xml')); app.get('/favicon.ico', (c) => serveFile('/favicon.ico', '/favicon.ico', 'image/x-icon')); app.get('/favicon.png', (c) => serveFile('/favicon.png', '/favicon.png', 'image/png')); app.get('/apple-touch-icon.png', (c) => serveFile('/apple-touch-icon.png', '/apple-touch-icon.png', 'image/png')); app.get('*', async (c) => { if (c.req.path.startsWith('/api/') || c.req.path === '/health') { return c.notFound(); } const indexPath = USE_REACT ? `${REACT_BUILD_PATH}/index.html` : `${LEGACY_FRONTEND_PATH}/index.html`; const file = Bun.file(indexPath); if (await file.exists()) { return new Response(file, { headers: { 'Content-Type': 'text/html' } }); } const fallback = Bun.file(`${LEGACY_FRONTEND_PATH}/index.html`); return new Response(fallback, { headers: { 'Content-Type': 'text/html' } }); }); console.log(`🚀 AI Stack Deployer (Production) starting on http://${HOST}:${PORT}`); console.log(`✅ Production features enabled:`); console.log(` - Retry logic with exponential backoff`); console.log(` - Circuit breaker pattern`); console.log(` - Automatic rollback on failure`); console.log(` - Health verification`); console.log(` - Structured logging`); export default { port: PORT, hostname: HOST, fetch: app.fetch, idleTimeout: 255, };