UNPKG

@mdfriday/foundry

Version:

The core engine of MDFriday. Convert Markdown and shortcodes into fully themed static sites – Hugo-style, powered by TypeScript.

399 lines (392 loc) 14.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.FoundryLiveReloadServer = void 0; const http = __importStar(require("http")); const path = __importStar(require("path")); const fs = __importStar(require("fs/promises")); const ws_1 = require("ws"); const log_1 = require("../../log"); const log = (0, log_1.getDomainLogger)('web', { component: 'livereload-server' }); /** * Node.js 环境下的 LiveReload 服务器实现 * 使用 WebSocket 进行实时通信 * * 对于 Electron 环境,请使用 ElectronLiveReloadServer * 或通过 createLiveReloadServer() 工厂函数自动选择合适的实现 */ class FoundryLiveReloadServer { constructor(config) { this.httpServer = null; this.wsServer = null; this.clients = new Set(); this.running = false; this.config = { port: config.port || 8091, host: config.host || 'localhost', livereloadPort: config.livereloadPort || 35729, enableLiveReload: config.enableLiveReload !== false, publicDir: config.publicDir }; } async start() { if (this.running) { log.warn('LiveReloadServer already running'); return; } try { // 启动 HTTP 服务器 await this.startHttpServer(); // 启动 LiveReload WebSocket 服务器 if (this.config.enableLiveReload) { await this.startLiveReloadServer(); } this.running = true; } catch (error) { log.error('Failed to start LiveReloadServer:', error); throw error; } } async stop() { if (!this.running) { return; } try { // 关闭所有 WebSocket 连接 for (const client of this.clients) { client.close(); } this.clients.clear(); // 关闭 WebSocket 服务器 if (this.wsServer) { this.wsServer.close(); this.wsServer = null; } // 关闭 HTTP 服务器 if (this.httpServer) { await new Promise((resolve) => { this.httpServer.close(() => resolve()); }); this.httpServer = null; } this.running = false; } catch (error) { log.error('Error stopping LiveReloadServer:', error); } } notifyReload(changedFiles) { if (!this.config.enableLiveReload || this.clients.size === 0) { return; } const reloadEvent = { command: 'reload', liveCSS: this.shouldLiveReloadCSS(changedFiles), liveImg: this.shouldLiveReloadImages(changedFiles) }; // 如果只是 CSS 或图片变化,设置具体路径 if (changedFiles && changedFiles.length === 1) { const file = changedFiles[0]; const ext = path.extname(file).toLowerCase(); if (ext === '.css') { reloadEvent.path = file; reloadEvent.liveCSS = true; } else if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'].includes(ext)) { reloadEvent.path = file; reloadEvent.liveImg = true; } } const message = JSON.stringify(reloadEvent); // 广播给所有连接的客户端 for (const client of this.clients) { if (client.readyState === ws_1.WebSocket.OPEN) { client.send(message); } } } getUrl() { return `http://${this.config.host}:${this.config.port}`; } isServerRunning() { return this.running; } async startHttpServer() { this.httpServer = http.createServer(async (req, res) => { try { await this.handleHttpRequest(req, res); } catch (error) { log.error('HTTP request error:', error); res.statusCode = 500; res.end('Internal Server Error'); } }); await new Promise((resolve, reject) => { this.httpServer.listen(this.config.port, this.config.host, () => { resolve(); }); this.httpServer.on('error', (error) => { if (error.code === 'EADDRINUSE') { // 端口被占用,尝试下一个端口 this.config.port++; if (this.config.port < 8099) { this.httpServer.listen(this.config.port, this.config.host); } else { reject(new Error('No available ports')); } } else { reject(error); } }); }); } async startLiveReloadServer() { this.wsServer = new ws_1.WebSocketServer({ port: this.config.livereloadPort, host: this.config.host }); this.wsServer.on('connection', (ws) => { this.clients.add(ws); // 发送握手消息 const helloMessage = { command: 'hello', protocols: ['http://livereload.com/protocols/official-7'], serverName: 'foundry-livereload' }; ws.send(JSON.stringify(helloMessage)); ws.on('close', () => { this.clients.delete(ws); }); ws.on('message', (data) => { try { JSON.parse(data.toString()); } catch (error) { log.error('Invalid LiveReload message:', data.toString()); } }); }); this.wsServer.on('error', (error) => { log.error('LiveReload WebSocket server error:', error); }); } async handleHttpRequest(req, res) { const url = req.url || '/'; let filePath = this.resolveFilePath(url); try { // 检查文件是否存在 const stats = await fs.stat(filePath); if (stats.isDirectory()) { // 尝试查找 index.html const indexPath = path.join(filePath, 'index.html'); try { await fs.stat(indexPath); filePath = indexPath; } catch { // 如果没有 index.html,返回目录列表或 404 res.statusCode = 404; res.end('Not Found'); return; } } // 读取文件内容 let content = await fs.readFile(filePath); // 设置 Content-Type const contentType = this.getContentType(filePath); res.setHeader('Content-Type', contentType); // 如果是 HTML 文件且启用了 LiveReload,注入 LiveReload 脚本 if (contentType.includes('text/html') && this.config.enableLiveReload) { const htmlContent = content.toString(); const liveReloadScript = this.getLiveReloadScript(); // 在 </body> 标签前插入 LiveReload 脚本 const modifiedHtml = htmlContent.replace(/<\/body>/i, `${liveReloadScript}\n</body>`); content = Buffer.from(modifiedHtml, 'utf8'); } // 设置缓存头(开发环境不缓存) res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.statusCode = 200; res.end(content); } catch (error) { if (error.code === 'ENOENT') { res.statusCode = 404; res.end('Not Found'); } else { throw error; } } } resolveFilePath(url) { // 移除查询参数和哈希 const cleanUrl = url.split('?')[0].split('#')[0]; // 解码 URL - 添加错误处理 let decodedUrl; try { decodedUrl = decodeURIComponent(cleanUrl); } catch (error) { log.warn('Failed to decode URL:', cleanUrl, error); // 如果解码失败,使用原始URL decodedUrl = cleanUrl; } // 规范化路径,防止目录遍历攻击 // 使用更严格的正则表达式,同时处理Windows和POSIX路径分隔符 const normalizedPath = path.normalize(decodedUrl).replace(/^(\.\.[\/\\])+/, ''); // 移除前导斜杠 const relativePath = normalizedPath.startsWith('/') ? normalizedPath.slice(1) : normalizedPath; // 使用 path.join 确保跨平台兼容性 const resolvedPath = path.join(this.config.publicDir, relativePath); // 验证路径长度(Windows有260字符的路径长度限制) if (process.platform === 'win32' && resolvedPath.length > 260) { log.warn('Path too long for Windows filesystem:', resolvedPath); } // 验证路径不包含非法字符(主要针对Windows) if (process.platform === 'win32') { const invalidChars = /[<>:"|?*\x00-\x1f]/; if (invalidChars.test(relativePath)) { log.warn('Path contains invalid characters for Windows:', relativePath); } } return resolvedPath; } getContentType(filePath) { const ext = path.extname(filePath).toLowerCase(); const mimeTypes = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.eot': 'application/vnd.ms-fontobject', '.xml': 'application/xml; charset=utf-8', '.txt': 'text/plain; charset=utf-8' }; return mimeTypes[ext] || 'application/octet-stream'; } getLiveReloadScript() { return ` <script> (function() { 'use strict'; var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; var address = protocol + '//${this.config.host}:${this.config.livereloadPort}/livereload'; var socket = new WebSocket(address); socket.onmessage = function(event) { var data = JSON.parse(event.data); if (data.command === 'reload') { if (data.liveCSS) { // 热更新 CSS reloadCSS(); } else if (data.liveImg) { // 热更新图片 reloadImages(); } else { // 完整页面刷新 window.location.reload(); } } }; socket.onopen = function() { console.log('LiveReload connected'); }; socket.onclose = function() { console.log('LiveReload disconnected'); // 尝试重连 setTimeout(function() { window.location.reload(); }, 1000); }; function reloadCSS() { var links = document.querySelectorAll('link[rel="stylesheet"]'); for (var i = 0; i < links.length; i++) { var link = links[i]; var href = link.href; if (href) { var url = new URL(href); url.searchParams.set('_t', Date.now().toString()); link.href = url.toString(); } } console.log('CSS reloaded'); } function reloadImages() { var images = document.querySelectorAll('img'); for (var i = 0; i < images.length; i++) { var img = images[i]; var src = img.src; if (src) { var url = new URL(src); url.searchParams.set('_t', Date.now().toString()); img.src = url.toString(); } } console.log('Images reloaded'); } })(); </script>`; } shouldLiveReloadCSS(changedFiles) { if (!changedFiles) return false; return changedFiles.some(file => path.extname(file).toLowerCase() === '.css'); } shouldLiveReloadImages(changedFiles) { if (!changedFiles) return false; const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp']; return changedFiles.some(file => imageExts.includes(path.extname(file).toLowerCase())); } } exports.FoundryLiveReloadServer = FoundryLiveReloadServer; //# sourceMappingURL=livereload-server.js.map