@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
JavaScript
;
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