-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(core): add server:watch script with automatic restart on file changes
#3920
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
khassel
merged 17 commits into
MagicMirrorOrg:develop
from
jboucly:feat/add-server-auto-reload-and-nvm-config
Oct 28, 2025
Merged
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
8da1f1c
feat: add .nvmrc file with Node.js v22.20.0
74506e5
feat: add nodemon for development et and created nodemon.json file fo…
4dbfe29
feat: update changelog file
925d91d
feat: remove .nvmrc file
833aca9
refactor(deps): remove nodemon
95c6d7f
feat: add feature to reload server with rs or restart in stdin
fa3dcad
feat: add a monitoring script to restart the server on changes
8b24dbf
feat: update changelog to reflect new server:watch script and remove …
dfb1a55
feat: remove useless config file
c2103f5
refactor(core): improve server:watch script with better error handlin…
KristjanESPERANTO 6b7433c
feat(core): improve server watch mode with CSS support and client reload
KristjanESPERANTO b79bfcc
refactor(watcher): remove hardcoded config path and localhost IP
KristjanESPERANTO d8fe63b
refactor: centralize config file path resolution
KristjanESPERANTO 5d90279
fix(watcher): bind port checks to config.address for container compat…
KristjanESPERANTO f38be2d
fix(watcher): use config.address for reload notifications
KristjanESPERANTO 6fff61b
docs: update changelog entry
KristjanESPERANTO 487d270
refactor(watcher): simplify by removing fallback logic and making get…
KristjanESPERANTO File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| const { spawn } = require("child_process"); | ||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const net = require("net"); | ||
| const Log = require("../js/logger"); | ||
|
|
||
| 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 watcherErrorLogged = false; | ||
| let serverPort = null; | ||
|
|
||
| /** | ||
| * Get the server port from config | ||
| * @returns {number} The port number | ||
| */ | ||
| function getServerPort () { | ||
| if (serverPort) return serverPort; | ||
|
|
||
| try { | ||
| // Try to read the config file to get the port | ||
| const configPath = path.join(__dirname, "..", "config", "config.js"); | ||
| delete require.cache[require.resolve(configPath)]; | ||
| const config = require(configPath); | ||
| serverPort = global.mmPort || config.port || 8080; | ||
khassel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } catch (err) { | ||
| serverPort = 8080; | ||
| } | ||
|
|
||
| return serverPort; | ||
| } | ||
|
|
||
| /** | ||
| * Check if a port is available | ||
| * @param {number} port The port to check | ||
| * @returns {Promise<boolean>} 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); | ||
| }); | ||
|
|
||
| server.listen(port, "127.0.0.1"); | ||
khassel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Wait until port is available | ||
| * @param {number} port The port to wait for | ||
| * @param {number} maxAttempts Maximum number of attempts | ||
| * @returns {Promise<void>} | ||
| */ | ||
| 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}`); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 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 = getServerPort(); | ||
|
|
||
| // Set up one-time listener for the exit event | ||
| child.once("exit", async () => { | ||
| // Wait until port is actually available | ||
| await waitForPort(port); | ||
| // Reset port cache in case config changed | ||
| serverPort = null; | ||
| startServer(); | ||
| }); | ||
|
|
||
| child.kill("SIGTERM"); | ||
| } else { | ||
| startServer(); | ||
| } | ||
| }, RESTART_DELAY_MS); | ||
| } | ||
|
|
||
| /** | ||
| * Watch a directory for changes and restart the server on change | ||
| * @param {string} dir The directory path to watch | ||
| */ | ||
| function watchDir (dir) { | ||
| try { | ||
| const watcher = fs.watch(dir, { recursive: true }, (_eventType, filename) => { | ||
| if (!filename) return; | ||
|
|
||
| // Ignore node_modules - too many changes during npm install | ||
| // After installing dependencies, manually restart the watcher | ||
| if (filename.includes("node_modules")) return; | ||
|
|
||
| // Only watch .js, .mjs and .cjs files | ||
| if (!filename.endsWith(".js") && !filename.endsWith(".mjs") && !filename.endsWith(".cjs")) return; | ||
|
|
||
| if (restartTimer) clearTimeout(restartTimer); | ||
|
|
||
| restartTimer = setTimeout(() => { | ||
| restartServer(`Changes detected in ${dir}: ${filename} — restarting...`); | ||
| }, RESTART_DELAY_MS); | ||
| }); | ||
|
|
||
| watcher.on("error", (error) => { | ||
| if (error.code === "ENOSPC") { | ||
| if (!watcherErrorLogged) { | ||
| watcherErrorLogged = true; | ||
| Log.error("System limit for file watchers reached. Try increasing: sudo sysctl fs.inotify.max_user_watches=524288"); | ||
| } | ||
| } else { | ||
| Log.error(`Watcher error for ${dir}:`, error.message); | ||
| } | ||
| }); | ||
| } catch (error) { | ||
| Log.error(`Failed to watch directory ${dir}:`, error.message); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Watch a specific file for changes and restart the server on change | ||
| * @param {string} file The file path to watch | ||
| */ | ||
| function watchFile (file) { | ||
| try { | ||
| const watcher = fs.watch(file, (_eventType) => { | ||
| if (restartTimer) clearTimeout(restartTimer); | ||
|
|
||
| restartTimer = setTimeout(() => { | ||
| restartServer(`Config file changed: ${path.basename(file)} — restarting...`); | ||
| }, RESTART_DELAY_MS); | ||
| }); | ||
|
|
||
| watcher.on("error", (error) => { | ||
| Log.error(`Watcher error for ${file}:`, error.message); | ||
| }); | ||
|
|
||
| Log.log(`Watching config file: ${file}`); | ||
| } catch (error) { | ||
| Log.error(`Failed to watch file ${file}:`, error.message); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Get the config file path from environment or default location | ||
| * @returns {string} The config file path | ||
| */ | ||
| function getConfigFilePath () { | ||
| if (process.env.MM_CONFIG_FILE) { | ||
| return process.env.MM_CONFIG_FILE; | ||
| } | ||
|
|
||
| if (global.configuration_file && global.root_path) { | ||
| return path.resolve(global.root_path, global.configuration_file); | ||
| } | ||
|
|
||
| return path.join(__dirname, "..", "config", "config.js"); | ||
| } | ||
khassel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| startServer(); | ||
|
|
||
| // Watch the config file (might be in custom location) | ||
| // Priority: MM_CONFIG_FILE env var, then global.configuration_file, then default | ||
| const configFile = getConfigFilePath(); | ||
| watchFile(configFile); | ||
|
|
||
| // Watch core directories (modules, js and serveronly) | ||
| // We watch specific directories instead of the whole project root to avoid | ||
| // watching unnecessary files like node_modules (even though we filter it), | ||
| // tests, translations, css, fonts, vendor, etc. | ||
| watchDir(path.join(__dirname, "..", "modules")); | ||
khassel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| watchDir(path.join(__dirname, "..", "js")); | ||
| watchDir(path.join(__dirname)); // serveronly | ||
|
|
||
| process.on("SIGINT", () => { | ||
| isShuttingDown = true; | ||
| if (restartTimer) clearTimeout(restartTimer); | ||
| if (child) child.kill("SIGTERM"); | ||
| process.exit(0); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.