Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default defineConfig([
}
},
{
files: ["**/*.js"],
files: ["**/*.js", "js/app.mjs"],
languageOptions: {
ecmaVersion: "latest",
globals: {
Expand Down Expand Up @@ -104,7 +104,7 @@ export default defineConfig([
}
},
{
files: ["**/*.js"],
files: ["**/*.js", "js/app.mjs"],
ignores: [
"clientonly/index.js",
"js/logger.js",
Expand All @@ -118,6 +118,7 @@ export default defineConfig([
},
{
files: ["**/*.mjs"],
ignores: ["js/app.mjs"],
languageOptions: {
ecmaVersion: "latest",
globals: {
Expand Down
54 changes: 29 additions & 25 deletions js/app.js → js/app.mjs
Original file line number Diff line number Diff line change
@@ -1,47 +1,44 @@
// Load lightweight internal alias resolver
require("./alias-resolver");

const fs = require("node:fs");
const path = require("node:path");
const Spawn = require("node:child_process").spawn;
const Log = require("logger");
import "./alias-resolver.js";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { Agent, setGlobalDispatcher } from "undici";
import packageJson from "../package.json" with { type: "json" };
import Log from "./logger.js";
import Server from "./server.js";
import Utils from "./utils.js";
import serverFunctions from "#server_functions";

const require = createRequire(import.meta.url);
const { ConfigError } = Utils;
const { getEnvVarsAsObj } = serverFunctions;

// global absolute root path
global.root_path = path.resolve(`${__dirname}/../`);

// used to control fetch timeout for node_helpers
const { setGlobalDispatcher, Agent } = require("undici");
global.root_path = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

const Server = require("./server");
const Utils = require("./utils");
const { ConfigError } = require("./utils");

const { getEnvVarsAsObj } = require("#server_functions");
// common timeout value, provide environment override in case
const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;

// Get version number.
global.version = require(`${global.root_path}/package.json`).version;
global.version = packageJson.version;
global.mmTestMode = process.env.mmTestMode === "true";
Log.log(`Starting MagicMirror: v${global.version}`);

// Log system information in a subprocess so it is shown even on early startup failures.
Spawn("node ./js/systeminformation.js", {
spawn("node ./js/systeminformation.js", {
env: {
...process.env,
ELECTRON_VERSION: `${process.versions.electron}`,
USED_NODE_VERSION: `${process.versions.node}`
},
cwd: this.root_path,
cwd: global.root_path,
shell: true,
detached: true,
stdio: "inherit"
});

if (process.env.MM_CONFIG_FILE) {
global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, "");
}

// The next part is here to prevent a major exception when there
// is no internet connection. This could probable be solved better.
process.on("uncaughtException", (err) => {
Expand Down Expand Up @@ -213,6 +210,10 @@ class App {
*/
async start () {
try {
if (process.env.MM_CONFIG_FILE) {
global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, "");
}

const configObj = Utils.loadConfig();
global.config = configObj.fullConf;
// Keep a copy of the redacted config to later verify module secret permissions
Expand Down Expand Up @@ -334,15 +335,18 @@ class App {
});

Log.log("Node_helpers stopped ...");
this.#nodeHelpers = [];

// To be able to stop the app even if it hasn't been started (when
// running with Electron against another server)
if (!this.#httpServer) {
return Promise.resolve();
}

return this.#httpServer.close();
const result = await this.#httpServer.close();
this.#httpServer = null;
return result;
}
}

module.exports = new App();
export default new App();
10 changes: 8 additions & 2 deletions js/electron.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"use strict";

require("./alias-resolver");

const electron = require("electron");
const core = require("./app");
const Log = require("./logger");
const { applyElectronSwitches } = require("./electron_helper");
const { getServerPort } = require("#server_functions");

const corePromise = import("./app.mjs").then(({ default: core }) => core);

// Config
let config = process.env.config ? JSON.parse(process.env.config) : {};
// Module to control application life.
Expand Down Expand Up @@ -173,14 +176,15 @@ app.on("activate", () => {
* Added to fix #1056 (no method to gracefully shut down modules on exit).
*
* Note: this is only used if running Electron. Otherwise
* core.stop() is called by process.on("SIGINT"... in `app.js`
* core.stop() is called by process.on("SIGINT"... in `app.mjs`
*/
app.on("before-quit", async (event) => {
Log.log("Shutting down server...");
event.preventDefault();
setTimeout(() => {
process.exit(0);
}, 3000); // Force-quit after 3 seconds.
const core = await corePromise;
await core.stop();
process.exit(0);
});
Expand All @@ -197,6 +201,8 @@ app.on("certificate-error", (event, webContents, url, error, certificate, callba
* Bootstrap Electron: launch the client-only viewer and/or the full core application.
*/
const bootstrapElectron = async () => {
const core = await corePromise;

if (process.env.clientonly) {
await app.whenReady();
Log.log("Launching client viewer application.");
Expand Down
6 changes: 3 additions & 3 deletions js/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ if (typeof module !== "undefined") {
const lines = new Error().stack.split("\n");
for (const line of lines) {
if (line.includes("node:") || line.includes("js/logger.js") || line.includes("node_modules")) continue;
const match = line.match(/\((.+?\.js):\d+:\d+\)/) || line.match(/at\s+(.+?\.js):\d+:\d+/);
const match = line.match(/\((.+?\.[cm]?js):\d+:\d+\)/) || line.match(/at\s+(.+?\.[cm]?js):\d+:\d+/);
if (match) {
const file = match[1];
const baseName = file.replace(/.*\/(.*)\.js/, "$1");
const parentDir = file.replace(/.*\/(.*)\/.*\.js/, "$1");
const baseName = file.replace(/.*\/(.*)\.[cm]?js/, "$1");
const parentDir = file.replace(/.*\/(.*)\/.*\.[cm]?js/, "$1");
return styleText("gray", parentDir === "js" ? `[${baseName}]` : `[${parentDir}]`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion js/systeminformation.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const si = require("systeminformation");
// needed with relative path because logSystemInformation is called in an own process in app.js:
// needed with relative path because logSystemInformation is called in an own process in app.mjs:
const mmVersion = require("../package").version;
const Log = require("./logger");

Expand Down
12 changes: 9 additions & 3 deletions serveronly/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
const app = require("../js/app");
require("../js/alias-resolver");

const Log = require("../js/logger");
const { getServerPort } = require("#server_functions");

app.start().then((config) => {

const startServer = async () => {
const { default: app } = await import("../js/app.mjs");
const config = await app.start();
const bindAddress = config.address ? config.address : "localhost";
const httpType = config.useHttps ? "https" : "http";
Log.info(`\n>>> Ready to go! Please point your browser to: ${httpType}://${bindAddress}:${getServerPort(config)} <<<`);
});
};

startServer();
32 changes: 32 additions & 0 deletions tests/e2e/app_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const NodeHelper = require("../../js/node_helper");
const helpers = require("./helpers/global-setup");

describe("App restart lifecycle", () => {
afterEach(async () => {
await helpers.stopApplication(0);
});

it("reloads configuration without retaining stopped helpers or adding signal handlers", async () => {
const startHelper = vi.spyOn(NodeHelper.prototype, "start");
const stopHelper = vi.spyOn(NodeHelper.prototype, "stop");
await helpers.startApplication("tests/configs/esm_helper.js");
const app = global.app;
const sigintListeners = process.listenerCount("SIGINT");
const sigtermListeners = process.listenerCount("SIGTERM");
expect(startHelper).toHaveBeenCalledTimes(1);
expect(global.config.modules[0].module).toBe("esmHelper");

await helpers.startApplication("tests/configs/modules/clock/clock_24hr.js");
expect(global.app).toBe(app);
expect(global.configuration_file).toBe("tests/configs/modules/clock/clock_24hr.js");
expect(global.config.modules[0].module).toBe("clock");
expect(startHelper).toHaveBeenCalledTimes(1);
expect(stopHelper).toHaveBeenCalledTimes(1);
expect(process.listenerCount("SIGINT")).toBe(sigintListeners);
expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners);

await app.stop();
await app.stop();
expect(stopHelper).toHaveBeenCalledTimes(1);
});
});
5 changes: 2 additions & 3 deletions tests/e2e/helpers/global-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,11 @@ exports.getPage = () => {
exports.startApplication = async (configFilename) => {
vi.resetModules();

// Clear Node's require cache for config and app files to prevent stale configs and middlewares
// Clear CommonJS config and server caches; the ESM app is reused across starts.
Object.keys(require.cache).forEach((key) => {
if (
key.includes("/tests/configs/")
|| key.includes("/config/config")
|| key.includes("/js/app.js")
|| key.includes("/js/server.js")
) {
delete require.cache[key];
Expand Down Expand Up @@ -126,7 +125,7 @@ exports.startApplication = async (configFilename) => {

process.env.mmTestMode = "true";
process.setMaxListeners(0);
global.app = require(`${global.root_path}/js/app`);
global.app = (await import(`${global.root_path}/js/app.mjs`)).default;

return global.app.start();
};
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/functions/logger_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const { execFileSync } = require("node:child_process");
const path = require("node:path");

describe("logger caller prefixes", () => {
const loggerPath = path.resolve(__dirname, "../../../js/logger.js");

it.each(["js", "mjs", "cjs"])("recognizes .%s callers", (extension) => {
const script = `
require(${JSON.stringify(loggerPath)});
const vm = require("node:vm");
vm.runInThisContext('console.log("top-level");', {filename: "file:///MagicMirror/js/probe.${extension}"});
vm.runInThisContext('(() => console.log("function"))();', {filename: "file:///MagicMirror/js/probe.${extension}"});
vm.runInThisContext('console.log("module");', {filename: "file:///MagicMirror/modules/MMM-test/node_helper.${extension}"});
`;
const output = execFileSync(process.execPath, ["-e", script], {
env: { ...process.env, mmTestMode: "false", FORCE_COLOR: "0" },
encoding: "utf8"
});

expect(output).toContain("[probe] top-level");
expect(output).toContain("[probe] function");
expect(output).toContain("[MMM-test] module");
});
});
4 changes: 2 additions & 2 deletions tests/unit/global_vars/root_path_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ const path = require("node:path");
const root_path = path.join(__dirname, "../../..");
const version = require(`${root_path}/package.json`).version;

describe("'global.root_path' set in js/app.js", () => {
const expectedSubPaths = ["modules", "serveronly", "js", "js/app.js", "js/main.js", "js/electron.js", "config"];
describe("'global.root_path' set in js/app.mjs", () => {
const expectedSubPaths = ["modules", "serveronly", "js", "js/app.mjs", "js/main.js", "js/electron.js", "config"];

expectedSubPaths.forEach((subpath) => {
it(`contains a file/folder "${subpath}"`, () => {
Expand Down
Loading