diff --git a/js/main.js b/js/main.js index 0e20c41e82..73d6075f3b 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 081e90892c..f8a51e714d 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 0000000000..27e265f14d --- /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 0000000000..b40361cdcb --- /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"); + }); +});