/** * Static Deployment Builder * * Compiles projects from SQLite using VirtualServer (Handlebars rendering) * and writes compiled static files to public directory */ import { promises as fs } from 'fs'; import path from 'path'; import { createServerAdapter, getWorkspaceAdapter } from '@/lib/vfs/adapters/server'; import { VirtualServer } from '@/lib/preview/virtual-server'; import { VirtualFile, FileTreeNode, Deployment } from '@/lib/vfs/types'; import { logger } from '@/lib/utils'; import { processHtml } from '@/lib/publishing/html-processor'; import { stripPreviewScripts } from '@/lib/preview/strip-preview-scripts'; import { generateSitemap, generateRobotsTxt } from '@/lib/publishing/seo-generator'; import { extractBackendFeatures } from './backend-feature-extractor'; import { resolveDeploymentServing, replaceAssetPathsWithDeploymentPrefix } from './deployment-paths'; export interface BuildResult { success: boolean; deploymentId: string; projectId: string; filesWritten: number; outputPath: string; error?: string; } /** * Create a minimal VFS-like wrapper for server-side VirtualServer compilation * Only implements the methods that VirtualServer actually uses */ function createServerVfs( projectId: string, allFiles: VirtualFile[] ) { const generatedFiles = new Map(); return { async getAllFilesAndDirectories(pid: string): Promise { if (pid !== projectId) throw new Error('Invalid project ID'); return allFiles; }, async listDirectory(pid: string, dirPath: string): Promise { if (pid !== projectId) throw new Error('Invalid project ID'); if (dirPath === '/') return allFiles; return allFiles.filter(f => f.path.startsWith(dirPath)); }, async readFile(pid: string, filePath: string): Promise { if (pid !== projectId) throw new Error('Invalid project ID'); const file = allFiles.find(f => f.path === filePath); if (!file) throw new Error(`File not found: ${filePath}`); return file; }, async fileExists(pid: string, filePath: string): Promise { if (pid !== projectId) throw new Error('Invalid project ID'); return allFiles.some(f => f.path === filePath); }, // Generated file methods used by VirtualServer during bundled runtime compilation clearGeneratedFiles(): void { generatedFiles.clear(); }, setGeneratedFile(path: string, content: string, mimeType: string): void { const now = new Date(); generatedFiles.set(path, { id: `generated-${path}`, projectId, path, name: path.split('/').pop() || path, type: path.endsWith('.css') ? 'css' : 'js', content, mimeType, size: content.length, createdAt: now, updatedAt: now, metadata: { isGenerated: true }, }); }, getGeneratedFiles(): VirtualFile[] { return Array.from(generatedFiles.values()); }, isGeneratedPath(path: string): boolean { return generatedFiles.has(path); }, }; } /** * Build a static deployment from a deployment entity * Uses VirtualServer to compile Handlebars templates (same as export) */ export async function buildStaticDeployment(deploymentId: string, workspaceId?: string): Promise { try { const adapter = workspaceId ? getWorkspaceAdapter(workspaceId) : await createServerAdapter(); await adapter.init(); // Get deployment const deployment = await adapter.getDeployment?.(deploymentId); if (!deployment) { logger.error(`[Static Builder] Deployment ${deploymentId} not found in database`); return { success: false, deploymentId, projectId: '', filesWritten: 0, outputPath: '', error: 'Deployment not found', }; } // Get project const project = await adapter.getProject(deployment.projectId); if (!project) { logger.error(`[Static Builder] Project ${deployment.projectId} not found in database`); return { success: false, deploymentId, projectId: deployment.projectId, filesWritten: 0, outputPath: '', error: 'Project not found', }; } // Check if under construction - if so, replace entire deployment with construction page if (deployment.underConstruction) { // Output directory: public/deployments/[deploymentId] const outputDir = path.join(process.cwd(), 'public', 'deployments', deploymentId); // Clean existing output directory try { await fs.rm(outputDir, { recursive: true, force: true }); } catch (error) { // Directory doesn't exist, that's fine } // Create output directory await fs.mkdir(outputDir, { recursive: true }); // Generate and write under construction page as index.html const constructionHtml = generateUnderConstructionHtml(deployment.name); await fs.writeFile(path.join(outputDir, 'index.html'), constructionHtml, 'utf-8'); logger.info(`[Static Builder] Built under construction page for deployment ${deploymentId}`); return { success: true, deploymentId, projectId: deployment.projectId, filesWritten: 1, outputPath: `/deployments/${deploymentId}`, }; } // Get all files from SQLite const allFiles = await adapter.listFiles(deployment.projectId); // Create a minimal VFS-like wrapper for server-side compilation const serverVfs = createServerVfs(deployment.projectId, allFiles); // Check if project has edge functions (for conditional interceptor injection) const edgeFunctions = adapter.listEdgeFunctions ? await adapter.listEdgeFunctions(deployment.projectId) : []; const hasEdgeFunctions = edgeFunctions.some(f => f.enabled); // Compile project using VirtualServer (renders Handlebars templates) const server = new VirtualServer(serverVfs as any, deployment.projectId, { runtime: project.settings?.runtime }); const compiledProject = await server.compileProject(); // Create reverse map: blobUrl -> filePath for replacements const blobUrlToPath = new Map(); for (const [filePath, blobUrl] of compiledProject.blobUrls) { blobUrlToPath.set(blobUrl, filePath); } // Decide how the deployment is served — controls asset path style and SEO URLs. const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; const { servedAtRoot, baseUrl } = resolveDeploymentServing(deployment, deploymentId, { staticProxyEnabled: process.env.STATIC_PROXY === 'true', appUrl, }); // Post-process files to replace asset references with absolute paths // and apply deployment settings (scripts, CDN, SEO, etc.) const htmlFiles: string[] = []; for (const file of compiledProject.files) { if (typeof file.content === 'string') { // Replace both blob URLs and file path references with absolute paths file.content = replaceAssetPathsWithDeploymentPrefix( file.content, blobUrlToPath, allFiles, deploymentId, servedAtRoot ); // Remove preview instrumentation scripts from HTML files if (file.path.endsWith('.html')) { file.content = stripPreviewScripts(file.content); htmlFiles.push(file.path); // Apply deployment settings to HTML files file.content = processHtml(file.content, { publishSettings: { enabled: deployment.enabled, underConstruction: deployment.underConstruction, customDomain: deployment.customDomain, headScripts: deployment.headScripts, bodyScripts: deployment.bodyScripts, cdnLinks: deployment.cdnLinks, analytics: deployment.analytics, seo: deployment.seo, compliance: deployment.compliance, settingsVersion: deployment.settingsVersion, lastPublishedVersion: deployment.lastPublishedVersion, }, projectId: deployment.projectId, baseUrl, deploymentId, hasEdgeFunctions, }); } } } // Output directory: public/deployments/[deploymentId] const outputDir = path.join(process.cwd(), 'public', 'deployments', deploymentId); // Clean existing output directory try { await fs.rm(outputDir, { recursive: true, force: true }); } catch (error) { // Directory doesn't exist (first build), that's fine } // Create output directory await fs.mkdir(outputDir, { recursive: true }); let filesWritten = 0; // Write compiled files for (const file of compiledProject.files) { // Skip template files and development files (same as export) if (shouldExcludeFromExport(file.path)) { continue; } // Determine file path (remove leading slash) const relativePath = file.path.startsWith('/') ? file.path.slice(1) : file.path; const filePath = path.join(outputDir, relativePath); // Create directory if needed const fileDir = path.dirname(filePath); await fs.mkdir(fileDir, { recursive: true }); // Write file content if (typeof file.content === 'string') { await fs.writeFile(filePath, file.content, 'utf-8'); } else { // Binary content (ArrayBuffer) await fs.writeFile(filePath, Buffer.from(file.content)); } filesWritten++; } // Generate and write sitemap.xml if htmlFiles exist if (htmlFiles.length > 0) { const sitemapContent = generateSitemap({ baseUrl, htmlFiles, publishSettings: { enabled: deployment.enabled, underConstruction: deployment.underConstruction, customDomain: deployment.customDomain, headScripts: deployment.headScripts, bodyScripts: deployment.bodyScripts, cdnLinks: deployment.cdnLinks, analytics: deployment.analytics, seo: deployment.seo, compliance: deployment.compliance, settingsVersion: deployment.settingsVersion, lastPublishedVersion: deployment.lastPublishedVersion, }, }); await fs.writeFile(path.join(outputDir, 'sitemap.xml'), sitemapContent, 'utf-8'); filesWritten++; } // Generate and write robots.txt const robotsContent = generateRobotsTxt({ baseUrl, publishSettings: { enabled: deployment.enabled, underConstruction: deployment.underConstruction, customDomain: deployment.customDomain, headScripts: deployment.headScripts, bodyScripts: deployment.bodyScripts, cdnLinks: deployment.cdnLinks, analytics: deployment.analytics, seo: deployment.seo, compliance: deployment.compliance, settingsVersion: deployment.settingsVersion, lastPublishedVersion: deployment.lastPublishedVersion, }, }); await fs.writeFile(path.join(outputDir, 'robots.txt'), robotsContent, 'utf-8'); filesWritten++; // Extract backend features from project → deployment runtime database const extractionResult = await extractBackendFeatures(deployment.projectId, deploymentId, workspaceId); if (extractionResult.errors.length > 0) { logger.warn('[Static Builder] Backend feature extraction warnings:', extractionResult.errors); } if (extractionResult.edgeFunctions > 0 || extractionResult.serverFunctions > 0 || extractionResult.secrets > 0 || extractionResult.scheduledFunctions > 0) { logger.info(`[Static Builder] Backend features provisioned: ${extractionResult.edgeFunctions} edge functions, ${extractionResult.serverFunctions} server functions, ${extractionResult.secrets} secrets, ${extractionResult.scheduledFunctions} scheduled functions`); } // Update lastPublishedVersion after successful build if (adapter.updateDeployment) { await adapter.updateDeployment({ ...deployment, lastPublishedVersion: deployment.settingsVersion, publishedAt: new Date(), }); } // Clean up VirtualServer resources server.cleanupBlobUrls(); logger.info(`[Static Builder] Build complete: ${filesWritten} files written to /deployments/${deploymentId}`); return { success: true, deploymentId, projectId: deployment.projectId, filesWritten, outputPath: `/deployments/${deploymentId}`, }; } catch (error) { logger.error('[Static Builder] Build failed:', error); return { success: false, deploymentId: deploymentId || '', projectId: '', filesWritten: 0, outputPath: '', error: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Clean up static files for a deployment */ export async function cleanStaticDeployment(deploymentId: string): Promise { try { const outputDir = path.join(process.cwd(), 'public', 'deployments', deploymentId); await fs.rm(outputDir, { recursive: true, force: true }); return true; } catch (error) { logger.error('[Static Builder] Error cleaning deployment:', error); return false; } } /** * Check if a file should be excluded from published deployment output */ function shouldExcludeFromExport(filePath: string): boolean { // Exclude template files if (filePath.endsWith('.hbs') || filePath.endsWith('.handlebars')) { return true; } // Exclude templates directory if (filePath.startsWith('/templates/')) { return true; } // Exclude data.json file (since it's compiled into HTML) if (filePath === '/data.json') { return true; } // Exclude TypeScript/JSX/SFC source files (compiled into bundle.js) if (filePath.endsWith('.ts') || filePath.endsWith('.tsx') || filePath.endsWith('.jsx') || filePath.endsWith('.svelte') || filePath.endsWith('.vue')) { return true; } // Exclude CSS source files under src/ (compiled into bundle.css by esbuild) if (filePath.startsWith('/src/') && filePath.endsWith('.css')) { return true; } // Exclude dot-prefixed files and directories (e.g. .PROMPT.md, .DESIGN.md, .skills/) const firstSegment = filePath.split('/').filter(Boolean)[0]; if (firstSegment && firstSegment.startsWith('.')) { return true; } return false; } /** * Generate under construction HTML page */ function generateUnderConstructionHtml(projectName?: string): string { const escapedName = projectName ? escapeHtml(projectName) : ''; return ` Under Construction${projectName ? ` - ${escapedName}` : ''}

Under Construction

${projectName ? `
${escapedName}
` : ''}

This site is currently being updated and improved.

Please check back soon!

`; } /** * Escape HTML special characters */ function escapeHtml(text: string): string { const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; return text.replace(/[&<>"']/g, (m) => map[m]); } /** * Get the primary published deployment ID * Returns the first enabled deployment */ export async function getPrimaryPublishedDeploymentId(): Promise { try { const adapter = await createServerAdapter(); await adapter.init(); const deployments = await adapter.listDeployments?.() || []; // Find the first enabled deployment const enabledDeployment = deployments.find((s: Deployment) => s.enabled === true); return enabledDeployment?.id || null; } catch (error) { logger.error('[Static Builder] Error getting published deployment:', error); return null; } }