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
${pages
.map((page) => `- ${page}
`)
.join("")}
`);
} 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;