From d3a3ad9caf948170787eaea9c8857cdbffcd7060 Mon Sep 17 00:00:00 2001 From: Karsten Hassel Date: Sun, 12 Apr 2026 22:50:00 +0200 Subject: [PATCH] config endpoint must handle functions in module configs (#4106) Fixes #4105 ```bash In JavaScript, standard JSON does not support functions. If you use JSON.stringify() on an object containing functions, those functions will be omitted (if they are object properties) or changed to null (if they are in an array). ``` --------- Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> --- js/main.js | 15 ++++++++++++- js/server.js | 19 +++++++++++----- tests/configs/config_functions.js | 35 ++++++++++++++++++++++++++++++ tests/e2e/config_functions_spec.js | 21 ++++++++++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 tests/configs/config_functions.js create mode 100644 tests/e2e/config_functions_spec.js diff --git a/js/main.js b/js/main.js index 0e20c41e..73d6075f 100644 --- a/js/main.js +++ b/js/main.js @@ -475,7 +475,20 @@ const MM = (function () { const loadConfig = async function () { try { const res = await fetch(new URL("config/", `${location.origin}${config.basePath}`)); - config = JSON.parse(await res.text()); + + // The server tags functions as { __mmFunction: "" } because + // JSON.stringify can't serialise live functions. This reviver turns + // those tagged objects back into callable functions. + config = JSON.parse(await res.text(), (key, value) => { + if (value && typeof value === "object" && typeof value.__mmFunction === "string") { + try { + return new Function(`return (${value.__mmFunction})`)(); + } catch { + Log.warn(`Failed to revive function for config key "${key}".`); + } + } + return value; + }); } catch (error) { Log.error("Unable to retrieve config", error); } diff --git a/js/server.js b/js/server.js index 081e9089..f8a51e71 100644 --- a/js/server.js +++ b/js/server.js @@ -111,12 +111,21 @@ function Server (configObj) { const getStartup = (req, res) => res.send(startUp); const getConfig = (req, res) => { - if (config.hideConfigSecrets) { - res.send(configObj.redactedConf); - } else { - res.send(configObj.fullConf); - } + const obj = config.hideConfigSecrets ? configObj.redactedConf : configObj.fullConf; + // Functions can't survive JSON.stringify, so we wrap them in a + // tagged object { __mmFunction: "" }. The client-side + // JSON reviver in main.js recognises this tag and reconstructs + // the live function from the source string. + const jsonString = JSON.stringify(obj, (key, value) => { + if (typeof value === "function") { + return { __mmFunction: value.toString() }; + } + return value; + }); + res.set("Content-Type", "application/json"); + res.send(jsonString); }; + app.get("/config", (req, res) => getConfig(req, res)); app.get("/cors", async (req, res) => await cors(req, res)); diff --git a/tests/configs/config_functions.js b/tests/configs/config_functions.js new file mode 100644 index 00000000..27e265f1 --- /dev/null +++ b/tests/configs/config_functions.js @@ -0,0 +1,35 @@ +/*eslint object-shorthand: ["error", "always", { "methodsIgnorePattern": "^roundToInt2$" }]*/ + +let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ + modules: [ + { + module: "clock", + position: "middle_center", + config: { + moduleFunctions: { + roundToInt1: (value) => { + try { + return Math.round(parseFloat(value)); + } catch { + return value; + } + }, + roundToInt2: function (value) { + try { + return Math.round(parseFloat(value)); + } catch { + return value; + } + } + }, + stringWithArrow: "a => b is not a function", + stringWithFunction: "this function keyword is just text" + } + } + ] +}); + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/e2e/config_functions_spec.js b/tests/e2e/config_functions_spec.js new file mode 100644 index 00000000..b40361cd --- /dev/null +++ b/tests/e2e/config_functions_spec.js @@ -0,0 +1,21 @@ +const helpers = require("./helpers/global-setup"); + +describe("config with module function", () => { + beforeAll(async () => { + await helpers.startApplication("tests/configs/config_functions.js"); + }); + + afterAll(async () => { + await helpers.stopApplication(); + }); + + it("config should resolve module functions", () => { + expect(config.modules[0].config.moduleFunctions.roundToInt1(13.3)).toBe(13); + expect(config.modules[0].config.moduleFunctions.roundToInt2(13.3)).toBe(13); + }); + + it("config should not revive plain strings containing arrow or function keywords", () => { + expect(config.modules[0].config.stringWithArrow).toBe("a => b is not a function"); + expect(config.modules[0].config.stringWithFunction).toBe("this function keyword is just text"); + }); +});