diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f9c9a0c4..a00059a445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ planned for 2026-01-01 ### Added - [weather] feat: add configurable forecast date format option (#3918) +- [core] Add new `server:watch` script to run MagicMirror² server-only with automatic restarts when files (defined in `config.watchTargets`) change (#3920) ### Changed diff --git a/js/app.js b/js/app.js index 5e5d3aee85..1641d68bcd 100644 --- a/js/app.js +++ b/js/app.js @@ -15,7 +15,7 @@ const Utils = require(`${__dirname}/utils`); const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`); // used to control fetch timeout for node_helpers const { setGlobalDispatcher, Agent } = require("undici"); -const { getEnvVarsAsObj } = require("#server_functions"); +const { getEnvVarsAsObj, getConfigFilePath } = require("#server_functions"); // common timeout value, provide environment override in case const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000; @@ -72,7 +72,7 @@ function App () { // For this check proposed to TestSuite // https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8 - const configFilename = path.resolve(global.configuration_file || `${global.root_path}/config/config.js`); + const configFilename = getConfigFilePath(); let templateFile = `${configFilename}.template`; // check if templateFile exists diff --git a/js/main.js b/js/main.js index feee330419..6f7f65f8b2 100644 --- a/js/main.js +++ b/js/main.js @@ -1,4 +1,4 @@ -/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions */ +/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */ const MM = (function () { let modules = []; @@ -605,6 +605,18 @@ const MM = (function () { createDomObjects(); + // Setup global socket listener for RELOAD event (watch mode) + if (typeof io !== "undefined") { + const socket = io("/", { + path: `${config.basePath || "/"}socket.io` + }); + + socket.on("RELOAD", () => { + Log.warn("Reload notification received from server"); + window.location.reload(true); + }); + } + if (config.reloadAfterServerRestart) { setInterval(async () => { // if server startup time has changed (which means server was restarted) diff --git a/js/server.js b/js/server.js index 281d417385..f6105936f1 100644 --- a/js/server.js +++ b/js/server.js @@ -111,6 +111,13 @@ function Server (config) { app.get("/", (req, res) => getHtml(req, res)); + // Reload endpoint for watch mode - triggers browser reload + app.get("/reload", (req, res) => { + Log.info("Reload request received, notifying all clients"); + io.emit("RELOAD"); + res.status(200).send("OK"); + }); + server.on("listening", () => { resolve({ app, diff --git a/js/server_functions.js b/js/server_functions.js index 1f206ccd88..6772c7a480 100644 --- a/js/server_functions.js +++ b/js/server_functions.js @@ -176,4 +176,22 @@ function getEnvVars (req, res) { res.send(obj); } -module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent }; +/** + * Get the config file path from environment or default location + * @returns {string} The absolute config file path + */ +function getConfigFilePath () { + // Ensure root_path is set (for standalone contexts like watcher) + if (!global.root_path) { + global.root_path = path.resolve(`${__dirname}/../`); + } + + // Check environment variable if global not set + if (!global.configuration_file && process.env.MM_CONFIG_FILE) { + global.configuration_file = process.env.MM_CONFIG_FILE; + } + + return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`); +} + +module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath }; diff --git a/package.json b/package.json index 139baf9400..06fb3cf855 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lint:prettier": "prettier . --write", "prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.", "server": "node ./serveronly", + "server:watch": "node ./serveronly/watcher.js", "start": "node --run start:x11", "start:dev": "node --run start:x11 -- dev", "start:wayland": "WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY:=wayland-1}\" ./node_modules/.bin/electron js/electron.js --enable-features=UseOzonePlatform --ozone-platform=wayland", diff --git a/serveronly/watcher.js b/serveronly/watcher.js new file mode 100644 index 0000000000..0f75a5fb49 --- /dev/null +++ b/serveronly/watcher.js @@ -0,0 +1,261 @@ +// Load lightweight internal alias resolver to enable require("logger") +require("../js/alias-resolver"); + +const { spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const net = require("net"); +const http = require("http"); +const Log = require("logger"); +const { getConfigFilePath } = require("#server_functions"); + +const RESTART_DELAY_MS = 500; +const PORT_CHECK_MAX_ATTEMPTS = 20; +const PORT_CHECK_INTERVAL_MS = 500; + +let child = null; +let restartTimer = null; +let isShuttingDown = false; +let isRestarting = false; +let serverConfig = null; +const rootDir = path.join(__dirname, ".."); + +/** + * Get the server configuration (port and address) + * @returns {{port: number, address: string}} The server config + */ +function getServerConfig () { + if (serverConfig) return serverConfig; + + try { + const configPath = getConfigFilePath(); + delete require.cache[require.resolve(configPath)]; + const config = require(configPath); + serverConfig = { + port: global.mmPort || config.port || 8080, + address: config.address || "localhost" + }; + } catch (err) { + serverConfig = { port: 8080, address: "localhost" }; + } + + return serverConfig; +} + +/** + * Check if a port is available on the configured address + * @param {number} port The port to check + * @returns {Promise} True if port is available + */ +function isPortAvailable (port) { + return new Promise((resolve) => { + const server = net.createServer(); + + server.once("error", () => { + resolve(false); + }); + + server.once("listening", () => { + server.close(); + resolve(true); + }); + + // Use the same address as the actual server will bind to + const { address } = getServerConfig(); + server.listen(port, address); + }); +} + +/** + * Wait until port is available + * @param {number} port The port to wait for + * @param {number} maxAttempts Maximum number of attempts + * @returns {Promise} + */ +async function waitForPort (port, maxAttempts = PORT_CHECK_MAX_ATTEMPTS) { + for (let i = 0; i < maxAttempts; i++) { + if (await isPortAvailable(port)) { + Log.info(`Port ${port} is now available`); + return; + } + await new Promise((resolve) => setTimeout(resolve, PORT_CHECK_INTERVAL_MS)); + } + Log.warn(`Port ${port} still not available after ${maxAttempts} attempts`); +} + +/** + * Start the server process + */ +function startServer () { + // Start node directly instead of via npm to avoid process tree issues + child = spawn("node", ["./serveronly"], { + stdio: "inherit", + cwd: path.join(__dirname, "..") + }); + + child.on("error", (error) => { + Log.error("Failed to start server process:", error.message); + child = null; + }); + + child.on("exit", (code, signal) => { + child = null; + + if (isShuttingDown) { + return; + } + + if (isRestarting) { + // Expected restart - don't log as error + isRestarting = false; + } else { + // Unexpected exit + Log.error(`Server exited unexpectedly with code ${code} and signal ${signal}`); + } + }); +} + +/** + * Send reload notification to all connected clients + */ +function notifyClientsToReload () { + const { port, address } = getServerConfig(); + const options = { + hostname: address, + port: port, + path: "/reload", + method: "GET" + }; + + const req = http.request(options, (res) => { + if (res.statusCode === 200) { + Log.info("Reload notification sent to clients"); + } + }); + + req.on("error", (err) => { + // Server might not be running yet, ignore + Log.debug(`Could not send reload notification: ${err.message}`); + }); + + req.end(); +} + +/** + * Restart the server process + * @param {string} reason The reason for the restart + */ +async function restartServer (reason) { + if (restartTimer) clearTimeout(restartTimer); + + restartTimer = setTimeout(async () => { + Log.info(reason); + + if (child) { + isRestarting = true; + + // Get the actual port being used + const { port } = getServerConfig(); + + // Notify clients to reload before restart + notifyClientsToReload(); + + // Set up one-time listener for the exit event + child.once("exit", async () => { + // Wait until port is actually available + await waitForPort(port); + // Reset config cache in case it changed + serverConfig = null; + startServer(); + }); + + child.kill("SIGTERM"); + } else { + startServer(); + } + }, RESTART_DELAY_MS); +} + +/** + * Watch a specific file for changes and restart the server on change + * Watches the parent directory to handle editors that use atomic writes + * @param {string} file The file path to watch + */ +function watchFile (file) { + try { + const fileName = path.basename(file); + const dirName = path.dirname(file); + + const watcher = fs.watch(dirName, (_eventType, changedFile) => { + // Only trigger for the specific file we're interested in + if (changedFile !== fileName) return; + + Log.info(`[watchFile] Change detected in: ${file}`); + if (restartTimer) clearTimeout(restartTimer); + + restartTimer = setTimeout(() => { + Log.info(`[watchFile] Triggering restart due to change in: ${file}`); + restartServer(`File changed: ${path.basename(file)} — restarting...`); + }, RESTART_DELAY_MS); + }); + + watcher.on("error", (error) => { + Log.error(`Watcher error for ${file}:`, error.message); + }); + + Log.log(`Watching file: ${file}`); + } catch (error) { + Log.error(`Failed to watch file ${file}:`, error.message); + } +} + +startServer(); + +// Setup file watching based on config +try { + const configPath = getConfigFilePath(); + delete require.cache[require.resolve(configPath)]; + const config = require(configPath); + + let watchTargets = []; + if (Array.isArray(config.watchTargets) && config.watchTargets.length > 0) { + watchTargets = config.watchTargets.filter((target) => typeof target === "string" && target.trim() !== ""); + } + + if (watchTargets.length === 0) { + Log.warn("Watch mode is enabled but no watchTargets are configured. No files will be monitored. Set the watchTargets array in your config.js to enable file watching."); + } + + Log.log(`Watch mode enabled. Watching ${watchTargets.length} file(s)`); + + // Watch each target file + for (const target of watchTargets) { + const targetPath = path.isAbsolute(target) + ? target + : path.join(rootDir, target); + + // Check if file exists + if (!fs.existsSync(targetPath)) { + Log.warn(`Watch target does not exist: ${targetPath}`); + continue; + } + + // Check if it's a file (directories are not supported) + const stats = fs.statSync(targetPath); + if (stats.isFile()) { + watchFile(targetPath); + } else { + Log.warn(`Watch target is not a file (directories not supported): ${targetPath}`); + } + } +} catch (err) { + // Config file might not exist or be invalid, use fallback targets + Log.warn("Could not load watchTargets from config."); +} + +process.on("SIGINT", () => { + isShuttingDown = true; + if (restartTimer) clearTimeout(restartTimer); + if (child) child.kill("SIGTERM"); + process.exit(0); +});