import { VirtualFileSystem } from '../vfs'; import { VirtualFile, ProjectRuntime } from '../vfs/types'; import { ProcessedFile, Route, CompiledProject } from './types'; import Handlebars from 'handlebars'; import { logger } from '@/lib/utils'; import { beginCompilation, pushCompileError, commitCompilation } from './compile-errors'; import { isRuntimeBundled } from '@/lib/runtimes/registry'; export class VirtualServer { private vfs: VirtualFileSystem; private projectId: string; private deploymentId?: string; private baseUrl: string; private blobUrls: Map = new Map(); private fileHashes: Map = new Map(); private handlebars: typeof Handlebars; private templateCache: Map = new Map(); private partialsRegistered: boolean = false; private entryPoint: string; private runtime: ProjectRuntime; constructor(vfs: VirtualFileSystem, projectId: string, opts?: { deploymentId?: string; entryPoint?: string; runtime?: ProjectRuntime }) { this.vfs = vfs; this.projectId = projectId; this.deploymentId = opts?.deploymentId; this.entryPoint = opts?.entryPoint || '/index.html'; this.runtime = opts?.runtime || 'handlebars'; this.baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; // Initialize Handlebars instance this.handlebars = Handlebars.create(); this.registerHelpers(); } private registerHelpers(): void { // Register common comparison helpers that LLMs expect this.handlebars.registerHelper('eq', (a: any, b: any) => a === b); this.handlebars.registerHelper('ne', (a: any, b: any) => a !== b); this.handlebars.registerHelper('lt', (a: any, b: any) => a < b); this.handlebars.registerHelper('gt', (a: any, b: any) => a > b); this.handlebars.registerHelper('lte', (a: any, b: any) => a <= b); this.handlebars.registerHelper('gte', (a: any, b: any) => a >= b); // Logical helpers this.handlebars.registerHelper('and', (...helperArgs: unknown[]) => { // Last argument is the Handlebars options object return helperArgs.slice(0, -1).every((arg) => arg); }); this.handlebars.registerHelper('or', (...helperArgs: unknown[]) => { return helperArgs.slice(0, -1).some((arg) => arg); }); this.handlebars.registerHelper('not', (value: any) => !value); // Math helpers this.handlebars.registerHelper('add', (a: number, b: number) => a + b); this.handlebars.registerHelper('subtract', (a: number, b: number) => a - b); this.handlebars.registerHelper('multiply', (a: number, b: number) => a * b); this.handlebars.registerHelper('divide', (a: number, b: number) => a / b); // String helpers this.handlebars.registerHelper('uppercase', (str: string) => str?.toUpperCase()); this.handlebars.registerHelper('lowercase', (str: string) => str?.toLowerCase()); this.handlebars.registerHelper('concat', (...helperArgs: unknown[]) => { return helperArgs.slice(0, -1).join(''); }); // Utility helpers this.handlebars.registerHelper('json', (context: any) => JSON.stringify(context, null, 2)); this.handlebars.registerHelper('formatDate', (date: Date | string) => { const d = new Date(date); return d.toLocaleDateString(); }); // Array helpers this.handlebars.registerHelper('limit', (array: any[], max: number) => array?.slice(0, max) ); // Repeat helpers - repeat content N times (times, repeat, for are all equivalent) const repeatHelper = function(this: any, n: number, options: Handlebars.HelperOptions) { let result = ''; for (let i = 0; i < n; i++) { result += options.fn({ index: i, first: i === 0, last: i === n - 1 }); } return result; }; this.handlebars.registerHelper('times', repeatHelper); this.handlebars.registerHelper('repeat', repeatHelper); this.handlebars.registerHelper('for', repeatHelper); } private async registerPartials(): Promise { if (this.partialsRegistered) { return; } try { // Get ALL files in the project const allItems = await this.vfs.getAllFilesAndDirectories(this.projectId); // Filter for files only (not directories) and handlebars files in /templates directory const templateFiles = allItems.filter((item): item is VirtualFile => 'content' in item && item.path.startsWith('/templates/') && (item.path.endsWith('.hbs') || item.path.endsWith('.handlebars')) ); for (const file of templateFiles) { const content = file.content as string; // Extract path relative to /templates/ // e.g., /templates/components/header.hbs → components/header const relativePath = file.path .replace(/^\/templates\//, '') .replace(/\.hbs$/, '') .replace(/\.handlebars$/, ''); // Register with multiple names for maximum compatibility: // 1. Full relative path: components/header this.handlebars.registerPartial(relativePath, content); // 2. Just filename: header (for backwards compatibility) const filename = relativePath.split('/').pop(); if (filename) { this.handlebars.registerPartial(filename, content); } // 3. Dash-separated variant: components-header (some LLMs prefer this) if (relativePath.includes('/')) { const dashName = relativePath.replace(/\//g, '-'); this.handlebars.registerPartial(dashName, content); } } this.partialsRegistered = true; } catch (error) { // Templates directory might not exist, which is fine } } private async compileTemplate(templatePath: string, context: any = {}): Promise { // Check cache first let compiled = this.templateCache.get(templatePath); if (!compiled) { try { const file = await this.vfs.readFile(this.projectId, templatePath); const templateContent = file.content as string; compiled = this.handlebars.compile(templateContent); this.templateCache.set(templatePath, compiled); } catch (error) { logger.error(`Failed to compile template ${templatePath}:`, error); return ''; } } return compiled(context); } async compileProject(incrementalUpdate = false): Promise { beginCompilation(); try { // Clear any stale generated files from previous compiles (e.g. switching from bundled to non-bundled runtime) this.vfs.clearGeneratedFiles(); // Register partials before processing await this.registerPartials(); let files = await this.vfs.listDirectory(this.projectId, '/'); files = await this.runBundleStep(files); const oldBlobUrls = new Map(this.blobUrls); const newBlobUrls = new Map(); const rawProcessedFiles: ProcessedFile[] = []; // First pass: Create blob URLs for all non-HTML files (images, JS, etc.) for (const file of files) { let processedFile: ProcessedFile; // Skip template files and HTML files in first pass if (file.type === 'template' || file.type === 'html' || file.type === 'css') { continue; } if (file.type === 'image' || file.type === 'video') { processedFile = { path: file.path, content: file.content, mimeType: file.mimeType }; } else { processedFile = { path: file.path, content: file.content as string, mimeType: file.mimeType }; } const contentHash = this.hashContent(processedFile.content); const previousHash = this.fileHashes.get(processedFile.path); if (incrementalUpdate && previousHash === contentHash && oldBlobUrls.has(processedFile.path)) { const existingUrl = oldBlobUrls.get(processedFile.path)!; newBlobUrls.set(processedFile.path, existingUrl); processedFile.blobUrl = existingUrl; oldBlobUrls.delete(processedFile.path); } else { const blob = new Blob([processedFile.content], { type: processedFile.mimeType }); const blobUrl = URL.createObjectURL(blob); newBlobUrls.set(processedFile.path, blobUrl); processedFile.blobUrl = blobUrl; this.fileHashes.set(processedFile.path, contentHash); } rawProcessedFiles.push(processedFile); } // Second pass: Process HTML files with available blob URLs for (const file of files) { if (file.type !== 'html') { continue; } const processedFile = await this.processHTML(file, newBlobUrls); const contentHash = this.hashContent(processedFile.content); const previousHash = this.fileHashes.get(processedFile.path); if (incrementalUpdate && previousHash === contentHash && oldBlobUrls.has(processedFile.path)) { const existingUrl = oldBlobUrls.get(processedFile.path)!; newBlobUrls.set(processedFile.path, existingUrl); processedFile.blobUrl = existingUrl; oldBlobUrls.delete(processedFile.path); } else { const blob = new Blob([processedFile.content], { type: processedFile.mimeType }); const blobUrl = URL.createObjectURL(blob); newBlobUrls.set(processedFile.path, blobUrl); processedFile.blobUrl = blobUrl; this.fileHashes.set(processedFile.path, contentHash); } rawProcessedFiles.push(processedFile); } const processedFiles = [...rawProcessedFiles]; for (const file of files) { if (file.type === 'css') { const processedFile = await this.processCSS(file, newBlobUrls); const contentHash = this.hashContent(processedFile.content); const previousHash = this.fileHashes.get(processedFile.path); if (incrementalUpdate && previousHash === contentHash && oldBlobUrls.has(processedFile.path)) { const existingUrl = oldBlobUrls.get(processedFile.path)!; newBlobUrls.set(processedFile.path, existingUrl); processedFile.blobUrl = existingUrl; oldBlobUrls.delete(processedFile.path); } else { const blob = new Blob([processedFile.content], { type: processedFile.mimeType }); const blobUrl = URL.createObjectURL(blob); newBlobUrls.set(processedFile.path, blobUrl); processedFile.blobUrl = blobUrl; this.fileHashes.set(processedFile.path, contentHash); } processedFiles.push(processedFile); } } const routes = this.generateRoutes(files); if (incrementalUpdate) { for (const [, url] of oldBlobUrls) { URL.revokeObjectURL(url); } } else if (!incrementalUpdate) { this.cleanupBlobUrls(); } this.blobUrls = newBlobUrls; return { entryPoint: this.entryPoint, files: processedFiles, routes, blobUrls: this.blobUrls }; } finally { commitCompilation(); } } private async runBundleStep(files: VirtualFile[]): Promise { if (!isRuntimeBundled(this.runtime)) return files; // Pre-compiled bundle from client (synced before publish) — skip server-side bundling. // Only skip if bundle.js exists AND no source files are present (source files // mean we should rebundle, even if a stale bundle.js was restored from checkpoint). const hasBundle = files.some(f => f.path === '/bundle.js'); const hasSourceFiles = files.some(f => /\.(tsx|ts|jsx|svelte|vue)$/.test(f.path) && !f.path.startsWith('/.')); if (hasBundle && !hasSourceFiles) { return files.filter(f => !/\.(tsx|ts|jsx|svelte|vue)$/.test(f.path)); } // Lazy-import to avoid loading esbuild for non-bundleable projects const { detectBundleEntryPoint, bundleProject, isBundleableSource } = await import('./esbuild-bundler'); const entryPoint = detectBundleEntryPoint(files); if (!entryPoint) return files; const result = await bundleProject({ files, entryPoint, runtime: this.runtime }); // Push errors through the compile-errors system for (const err of result.errors) { pushCompileError(entryPoint, err); } if (result.errors.length > 0) { // Bundle failed — clear any previous generated files and return unmodified this.vfs.clearGeneratedFiles(); return files; } // Filter out source files that were compiled into the bundle const filtered = files.filter(f => !isBundleableSource(f.path)); // Inject synthetic bundle.js const now = new Date(); filtered.push({ id: '__bundle_js__', projectId: this.projectId, path: '/bundle.js', name: 'bundle.js', type: 'js', content: result.js, mimeType: 'application/javascript', size: result.js.length, createdAt: now, updatedAt: now, metadata: { isTransient: true }, }); // Inject synthetic bundle.css (empty if esbuild produced no CSS, to avoid 404s // from templates that reference /bundle.css unconditionally) const cssContent = result.css || ''; filtered.push({ id: '__bundle_css__', projectId: this.projectId, path: '/bundle.css', name: 'bundle.css', type: 'css', content: cssContent, mimeType: 'text/css', size: cssContent.length, createdAt: now, updatedAt: now, metadata: { isTransient: true }, }); // Publish bundle files to VFS so they appear in file explorer and are readable this.vfs.setGeneratedFile('/bundle.js', result.js, 'application/javascript'); this.vfs.setGeneratedFile('/bundle.css', cssContent, 'text/css'); return filtered; } private hashContent(content: string | ArrayBuffer): string { let hash = 0; if (content instanceof ArrayBuffer) { const view = new Uint8Array(content); for (let i = 0; i < Math.min(view.length, 10000); i++) { hash = ((hash << 5) - hash) + view[i]; hash = hash & hash; } } else { for (let i = 0; i < content.length; i++) { const char = content.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } } return hash.toString(36); } private async processHTML(file: VirtualFile, blobUrls?: Map): Promise { let content = file.content as string; // Only run Handlebars for the handlebars runtime; skip /output/ files (script-generated) if (this.runtime === 'handlebars' && !file.path.startsWith('/output/')) { content = await this.processHandlebarsTemplates(content, file.path); } // Then process internal references with available blob URLs content = await this.processInternalReferences(content, blobUrls); // Inject VFS asset interceptor for transparent HTTP requests // Always inject the interceptor, even if no blob URLs yet (for future dynamic loading) const blobUrlMap = blobUrls ? Object.fromEntries(blobUrls) : {}; const deploymentIdForScript = this.deploymentId || ''; const vfsScript = ``; const consoleScript = ``; // Build ES module import map for non-bundled runtimes. // Maps VFS JS/TS paths to blob URLs so \n`; } } // Insert in head for early execution. // Import map must precede any