const express = require("express"); const expressWs = require("express-ws"); const chokidar = require("chokidar"); const path = require("path"); const fs = require("fs"); const BlueprintBuilder = require("./BlueprintBuilder"); class BlueprintServer { constructor(options = {}) { this.app = express(); this.wsInstance = expressWs(this.app); this.options = { port: 3000, apiPort: 3001, srcDir: "./src", outDir: "./dist", liveReload: false, minified: true, ...options, }; this.clients = new Map(); this.filesWithErrors = new Set(); this.apiServers = new Map(); this.apiPorts = new Map(); this.setupServer(); if (this.options.liveReload) { const watcher = chokidar.watch([], { ignored: /(^|[\/\\])\../, persistent: true, ignoreInitial: true, }); setTimeout(() => { watcher.add(this.options.srcDir); this.setupWatcher(watcher); }, 1000); } } log(tag, message, color) { const colorCodes = { blue: "\x1b[34m", green: "\x1b[32m", red: "\x1b[31m", orange: "\x1b[33m", lightGray: "\x1b[90m", reset: "\x1b[0m", bgBlue: "\x1b[44m", }; console.log( `${colorCodes.bgBlue} BP ${colorCodes.reset} ${ colorCodes[color] || "" }${message}${colorCodes.reset}` ); } async buildFile(filePath) { const relativePath = path.relative(this.options.srcDir, filePath); const outputPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); this.ensureDirectoryExistence(outputPath); const builder = new BlueprintBuilder({ minified: this.options.minified, debug: this.options.debug, }); const buildResult = builder.build(filePath, path.dirname(outputPath)); if (buildResult.success && buildResult.hasServerCode) { this.startApiServer(relativePath.replace(/\.bp$/, "")); } return buildResult; } async buildAll() { this.log("INFO", "Building all Blueprint files...", "lightGray"); if (fs.existsSync(this.options.outDir)) { fs.rmSync(this.options.outDir, { recursive: true }); } fs.mkdirSync(this.options.outDir, { recursive: true }); const files = this.getAllFiles(this.options.srcDir); let success = true; const errors = []; const startTime = Date.now(); for (const file of files) { const relativePath = path.relative(this.options.srcDir, file); const outputPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); this.ensureDirectoryExistence(outputPath); const builder = new BlueprintBuilder({ minified: this.options.minified, debug: this.options.debug }); const result = builder.build(file, path.dirname(outputPath)); if (result.success && result.hasServerCode) { const fileName = relativePath.replace(/\.bp$/, ""); this.startApiServer(fileName); } if (!result.success) { success = false; errors.push({ file, errors: result.errors }); } } const totalTime = Date.now() - startTime; if (success) { this.log( "SUCCESS", `All files built successfully in ${totalTime}ms!`, "green" ); } else { this.log("ERROR", "Build failed with errors:", "red"); errors.forEach(({ file, errors }) => { this.log("ERROR", `File: ${file}`, "red"); errors.forEach((err) => { this.log( "ERROR", `${err.type} at line ${err.line}, column ${err.column}: ${err.message}`, "red" ); }); }); process.exit(1); } } ensureDirectoryExistence(filePath) { const dirname = path.dirname(filePath); if (fs.existsSync(dirname)) { return true; } this.ensureDirectoryExistence(dirname); fs.mkdirSync(dirname); } getAllFiles(dirPath, arrayOfFiles) { const files = fs.readdirSync(dirPath); arrayOfFiles = arrayOfFiles || []; files.forEach((file) => { if (fs.statSync(path.join(dirPath, file)).isDirectory()) { arrayOfFiles = this.getAllFiles(path.join(dirPath, file), arrayOfFiles); } else if (file.endsWith(".bp")) { arrayOfFiles.push(path.join(dirPath, file)); } }); return arrayOfFiles; } setupServer() { this.app.use((req, res, next) => { const isHtmlRequest = req.path.endsWith(".html") || !path.extname(req.path); if (this.options.liveReload && isHtmlRequest) { const htmlPath = req.path.endsWith(".html") ? path.join(this.options.outDir, req.path) : path.join(this.options.outDir, req.path + ".html"); fs.readFile(htmlPath, "utf8", (err, data) => { if (err) return next(); let html = data; const filePath = req.path.endsWith(".html") ? req.path.slice(0, -5) : req.path === "/" ? "index" : req.path.replace(/^\//, ""); const apiPort = this.apiPorts.get(filePath) || this.options.apiPort; const script = ` `; html = html.replace("", script + ""); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); return res.send(html); }); } else { next(); } }); this.app.use(express.static(this.options.outDir)); this.app.get("*", (req, res, next) => { if (path.extname(req.path)) return next(); const htmlPath = path.join(this.options.outDir, req.path + ".html"); if (fs.existsSync(htmlPath)) { fs.readFile(htmlPath, "utf8", (err, data) => { if (err) return res.sendFile(htmlPath); let html = data; const filePath = req.path === "/" ? "index" : req.path.replace(/^\//, ""); const apiPort = this.apiPorts.get(filePath) || this.options.apiPort; if (!html.includes('window.blueprintServerPort =')) { const script = ` `; html = html.replace("", script + ""); } res.setHeader("Content-Type", "text/html"); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); return res.send(html); }); } else if (req.path === "/") { const pages = fs .readdirSync(this.options.outDir) .filter((f) => f.endsWith(".html")) .map((f) => f.replace(".html", "")); res.send(` Blueprint Pages

Blueprint Pages

`); } else { next(); } }); if (this.options.liveReload) { this.app.ws("/live-reload", (ws, req) => { ws.on("message", (msg) => { try { const data = JSON.parse(msg); if (data.type === "register" && data.page) { this.clients.set(ws, data.page); } } catch (error) {} }); ws.on("close", () => { this.clients.delete(ws); }); ws.on("error", (error) => { this.log("ERROR", "WebSocket error:", "red"); this.clients.delete(ws); }); }); } } setupWatcher(watcher) { watcher.on("change", async (filePath) => { if (!filePath.endsWith(".bp")) return; this.log("INFO", `File changed: ${filePath}`, "blue"); const result = await this.buildFile(filePath); if (result.success) { this.log("SUCCESS", `Rebuilt ${filePath}`, "green"); this.filesWithErrors.delete(filePath); if (result.hasServerCode) { const relativePath = path.relative(this.options.srcDir, filePath); const fileName = relativePath.replace(/\.bp$/, ""); this.startApiServer(fileName); } if (this.options.liveReload) { this.notifyClients(filePath); } } else { this.log("ERROR", `Build failed for ${filePath}`, "red"); result.errors.forEach((err) => { this.log( "ERROR", `${err.type} at line ${err.line}, column ${err.column}: ${err.message}`, "red" ); }); this.filesWithErrors.add(filePath); } }); watcher.on("add", async (filepath) => { if (filepath.endsWith(".bp")) { this.log("INFO", `New file detected: ${filepath}`, "lightGray"); try { const builder = new BlueprintBuilder({ minified: this.options.minified, debug: this.options.debug, }); const relativePath = path.relative(this.options.srcDir, filepath); const outputPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); this.ensureDirectoryExistence(outputPath); const result = builder.build(filepath, path.dirname(outputPath)); if (result.success) { this.log("SUCCESS", "Built new file successfully", "green"); this.filesWithErrors.delete(filepath); } else { this.filesWithErrors.add(filepath); this.log("ERROR", "Build failed for new file", "red"); } } catch (error) { this.log("ERROR", "Unexpected error building new file:", "red"); this.filesWithErrors.add(filepath); } } }); watcher.on("unlink", async (filepath) => { if (filepath.endsWith(".bp")) { this.log("INFO", `File ${filepath} removed`, "orange"); const relativePath = path.relative(this.options.srcDir, filepath); const htmlPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); const cssPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".css") ); if (fs.existsSync(htmlPath)) { fs.unlinkSync(htmlPath); } if (fs.existsSync(cssPath)) { fs.unlinkSync(cssPath); } const dirPath = path.dirname(htmlPath); if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) { fs.rmdirSync(dirPath); } } }); } async start() { await this.buildAll(); this.app.listen(this.options.port, () => { this.log( "INFO", `Blueprint dev server running at http://localhost:${this.options.port}`, "green" ); this.log( "INFO", `Mode: ${this.options.minified ? "Minified" : "Human Readable"}`, "lightGray" ); if (this.options.liveReload) { this.log( "INFO", "Live reload enabled - watching for changes...", "lightGray" ); } }); } startApiServer(fileName) { const serverFilePath = path.join(this.options.outDir, 'server', `${fileName}-server.js`); if (!fs.existsSync(serverFilePath)) { this.log("ERROR", `API server file not found: ${serverFilePath}`, "red"); return; } let apiPort; if (this.apiPorts.has(fileName)) { apiPort = this.apiPorts.get(fileName); this.log("INFO", `Reusing port ${apiPort} for ${fileName}`, "blue"); } else { apiPort = this.options.apiPort; this.options.apiPort++; this.log("INFO", `Assigning new port ${apiPort} for ${fileName}`, "blue"); } const startNewServer = () => { try { delete require.cache[require.resolve(path.resolve(serverFilePath))]; const createApiServer = require(path.resolve(serverFilePath)); const apiServer = createApiServer(apiPort); this.apiServers.set(fileName, apiServer); this.apiPorts.set(fileName, apiPort); this.log("SUCCESS", `API server started for ${fileName} on port ${apiPort}`, "green"); } catch (error) { this.log("ERROR", `Failed to start API server: ${error.message}`, "red"); console.error(error); } }; if (this.apiServers.has(fileName)) { const existingServer = this.apiServers.get(fileName); this.log("INFO", `Stopping previous API server for ${fileName}`, "blue"); try { if (existingServer && typeof existingServer.close === 'function') { existingServer.close(() => { this.log("INFO", `Previous server closed, starting new one`, "blue"); setTimeout(startNewServer, 300); }); return; } if (existingServer && existingServer.server && existingServer.server.close) { existingServer.server.close(() => { this.log("INFO", `Previous server closed, starting new one`, "blue"); setTimeout(startNewServer, 300); }); return; } this.log("WARNING", `Could not properly close previous server, waiting longer`, "orange"); setTimeout(startNewServer, 1000); } catch (err) { this.log("WARNING", `Error closing previous server: ${err.message}`, "orange"); setTimeout(startNewServer, 2000); } } else { startNewServer(); } } notifyClients(filePath) { const relativePath = path.relative(this.options.srcDir, filePath); const htmlFile = relativePath.replace(/\.bp$/, ".html"); const htmlPath = path.join(this.options.outDir, htmlFile); const fileName = relativePath.replace(/\.bp$/, ""); try { let newContent = fs.readFileSync(htmlPath, "utf8"); const apiPort = this.apiPorts.get(fileName) || this.options.apiPort; if (!newContent.includes('window.blueprintServerPort =')) { newContent = newContent.replace( '', ` ` ); } for (const [client, page] of this.clients.entries()) { if ( page === htmlFile.replace(/\\/g, "/") && client.readyState === 1 ) { try { client.send( JSON.stringify({ type: "reload", content: newContent, }) ); this.log("INFO", `Sent update to client for ${htmlFile}`, "blue"); } catch (error) { this.log("ERROR", "Error sending content:", "red"); this.clients.delete(client); } } } } catch (error) { this.log("ERROR", `Error reading new content from ${htmlPath}: ${error.message}`, "red"); } } } module.exports = BlueprintServer;